diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 31527acef3d..1bc8a5eb477 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,27 @@ +## [2026-01-12] - v0.155.0 + + +### Added: + +- `Akamai Cloud Pulse Logs LKE-E Audit ` to the `AccountCapability` type ([#13171](https://github.com/linode/manager/pull/13171)) + +### Changed: + +- Use v4beta endpoints for /events and /events/ ([#13084](https://github.com/linode/manager/pull/13084)) +- Renamed updated_at, created_at to updated,created in NotificationChannelBase interface ([#13193](https://github.com/linode/manager/pull/13193)) +- Updated getDatabaseConnectionPools signature to accept params for pagination ([#13195](https://github.com/linode/manager/pull/13195)) +- AlertNotificationType from `custom | default` to `user | system` ([#13203](https://github.com/linode/manager/pull/13203)) +- ACLP-Alerting: Notification Channel types to support API changes and backward compatibility ([#13227](https://github.com/linode/manager/pull/13227)) +- Move to `v4 endpoint` instead of v4beta for `CloudPulse metrics` api calls ([#13239](https://github.com/linode/manager/pull/13239)) + +### Upcoming Features: + +- Add new API endpoints and types for Resource Locking feature(RESPROT2) ([#13187](https://github.com/linode/manager/pull/13187)) +- Change range property of IPv6SLAAC to be optional ([#13209](https://github.com/linode/manager/pull/13209)) +- Add API endpoints for `Marketplace` ([#13215](https://github.com/linode/manager/pull/13215)) +- CloudPulse-Alerts: Add `CreateNotificationChannelPayload` in types.ts and add request function `createNotificationChannel` in alerts.ts ([#13225](https://github.com/linode/manager/pull/13225)) +- CloudPulse-Alerts: Add type for edition of notification channel payload ([#13235](https://github.com/linode/manager/pull/13235)) + ## [2025-12-16] - v0.154.1 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 4b3a45e9a92..adf478a78a7 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.154.1", + "version": "0.155.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/account/events.ts b/packages/api-v4/src/account/events.ts index 4f8c8d21e4b..9f2359d42c3 100644 --- a/packages/api-v4/src/account/events.ts +++ b/packages/api-v4/src/account/events.ts @@ -1,4 +1,4 @@ -import { API_ROOT } from '../constants'; +import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; import type { Filter, Params, ResourcePage } from '../types'; @@ -12,7 +12,7 @@ import type { Event, Notification } from './types'; */ export const getEvents = (params: Params = {}, filter: Filter = {}) => Request>( - setURL(`${API_ROOT}/account/events`), + setURL(`${BETA_API_ROOT}/account/events`), setMethod('GET'), setXFilter(filter), setParams(params), @@ -26,7 +26,7 @@ export const getEvents = (params: Params = {}, filter: Filter = {}) => */ export const getEvent = (eventId: number) => Request( - setURL(`${API_ROOT}/account/events/${encodeURIComponent(eventId)}`), + setURL(`${BETA_API_ROOT}/account/events/${encodeURIComponent(eventId)}`), setMethod('GET'), ); diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 4518b92c486..33e59842ada 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -1,6 +1,8 @@ import { createAlertDefinitionSchema, + createNotificationChannelPayloadSchema, editAlertDefinitionSchema, + editNotificationChannelPayloadSchema, } from '@linode/validation'; import { BETA_API_ROOT as API_ROOT } from '../constants'; @@ -17,7 +19,9 @@ import type { Alert, CloudPulseAlertsPayload, CreateAlertDefinitionPayload, + CreateNotificationChannelPayload, EditAlertDefinitionPayload, + EditNotificationChannelPayload, NotificationChannel, } from './types'; @@ -139,3 +143,32 @@ export const updateServiceAlerts = ( setMethod('PUT'), setData(payload), ); + +export const createNotificationChannel = ( + data: CreateNotificationChannelPayload, +) => + Request( + setURL(`${API_ROOT}/monitor/alert-channels`), + setMethod('POST'), + setData(data, createNotificationChannelPayloadSchema), + ); + +export const getNotificationChannelById = (channelId: number) => + Request( + setURL( + `${API_ROOT}/monitor/alert-channels/${encodeURIComponent(channelId)}`, + ), + setMethod('GET'), + ); + +export const updateNotificationChannel = ( + channelId: number, + data: EditNotificationChannelPayload, +) => + Request( + setURL( + `${API_ROOT}/monitor/alert-channels/${encodeURIComponent(channelId)}`, + ), + setMethod('PUT'), + setData(data, editNotificationChannelPayloadSchema), + ); diff --git a/packages/api-v4/src/cloudpulse/dashboards.ts b/packages/api-v4/src/cloudpulse/dashboards.ts index 9a84a74aabd..83aee84da58 100644 --- a/packages/api-v4/src/cloudpulse/dashboards.ts +++ b/packages/api-v4/src/cloudpulse/dashboards.ts @@ -1,4 +1,4 @@ -import { BETA_API_ROOT as API_ROOT } from 'src/constants'; +import { API_ROOT } from 'src/constants'; import Request, { setMethod, setURL } from '../request'; diff --git a/packages/api-v4/src/cloudpulse/services.ts b/packages/api-v4/src/cloudpulse/services.ts index f3a0e311f64..364ca3ea121 100644 --- a/packages/api-v4/src/cloudpulse/services.ts +++ b/packages/api-v4/src/cloudpulse/services.ts @@ -1,4 +1,4 @@ -import { BETA_API_ROOT as API_ROOT } from 'src/constants'; +import { API_ROOT } from 'src/constants'; import Request, { setData, diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 07dca57b539..43f725b6c7d 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -41,11 +41,16 @@ export type MetricUnitType = | 'second'; export type NotificationStatus = 'Disabled' | 'Enabled'; export type ChannelType = 'email' | 'pagerduty' | 'slack' | 'webhook'; -export type AlertNotificationType = 'custom' | 'default'; +export type AlertNotificationType = 'system' | 'user'; type AlertNotificationEmail = 'email'; type AlertNotificationSlack = 'slack'; type AlertNotificationPagerDuty = 'pagerduty'; type AlertNotificationWebHook = 'webhook'; +type EmailRecipientType = + | 'admin_users' + | 'read_users' + | 'read_write_users' + | 'user'; export interface Dashboard { created: string; group_by?: string[]; @@ -277,50 +282,68 @@ export interface Alert { updated_by: string; } -interface NotificationChannelAlerts { - id: number; - label: string; +interface NotificationChannelAlertInfo { + alert_count: number; type: 'alerts-definitions'; url: string; } interface NotificationChannelBase { - alerts: NotificationChannelAlerts[]; + alerts: NotificationChannelAlertInfo; channel_type: ChannelType; - created_at: string; + created: string; created_by: string; id: number; label: string; status: NotificationStatus; type: AlertNotificationType; - updated_at: string; + updated: string; updated_by: string; } interface NotificationChannelEmail extends NotificationChannelBase { channel_type: AlertNotificationEmail; - content: { + content?: { email: { email_addresses: string[]; message: string; subject: string; }; }; + details?: { + email: { + recipient_type: EmailRecipientType; + usernames: string[]; + }; + }; } interface NotificationChannelSlack extends NotificationChannelBase { channel_type: AlertNotificationSlack; - content: { + content?: { slack: { message: string; slack_channel: string; slack_webhook_url: string; }; }; + details?: { + slack: { + slack_channel: string; + slack_webhook_url: string; + }; + }; } interface NotificationChannelPagerDuty extends NotificationChannelBase { channel_type: AlertNotificationPagerDuty; - content: { + content?: { + pagerduty: { + attributes: string[]; + description: string; + service_api_key: string; + }; + }; + details?: { pagerduty: { attributes: string[]; description: string; @@ -330,12 +353,27 @@ interface NotificationChannelPagerDuty extends NotificationChannelBase { } interface NotificationChannelWebHook extends NotificationChannelBase { channel_type: AlertNotificationWebHook; - content: { + content?: { + webhook: { + http_headers: { + header_key: string; + header_value: string; + }[]; + webhook_url: string; + }; + }; + details?: { webhook: { + alert_body: { + body: string; + subject: string; + }; http_headers: { header_key: string; header_value: string; }[]; + method: 'GET' | 'POST' | 'PUT'; + request_body: string; webhook_url: string; }; }; @@ -411,3 +449,43 @@ export interface CloudPulseAlertsPayload { */ user_alerts?: number[]; } + +interface EmailDetail { + email: { + usernames: string[]; + }; +} + +export interface CreateNotificationChannelPayload { + /** + * The type of channel to create. + */ + channel_type: ChannelType; + /** + * The details of the channel to create. + */ + details: EmailDetail; + /** + * The label of the channel to create. + */ + label: string; +} + +export interface EditNotificationChannelPayload { + /** + * The details of the channel to edit. + */ + details: EmailDetail; + /** + * The label of the channel to edit. + */ + label: string; +} + +export interface EditNotificationChannelPayloadWithId + extends EditNotificationChannelPayload { + /** + * The ID of the channel to edit. + */ + channelId: number; +} diff --git a/packages/api-v4/src/databases/databases.ts b/packages/api-v4/src/databases/databases.ts index e23f1ac3536..7a9fdbd23ec 100644 --- a/packages/api-v4/src/databases/databases.ts +++ b/packages/api-v4/src/databases/databases.ts @@ -371,12 +371,16 @@ export const getDatabaseEngineConfig = (engine: Engine) => /** * Get a paginated list of connection pools for a database */ -export const getDatabaseConnectionPools = (databaseID: number) => +export const getDatabaseConnectionPools = ( + databaseID: number, + params?: Params, +) => Request>( setURL( `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools`, ), setMethod('GET'), + setParams(params), ); /** diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 3b90f57ec71..1691900b86c 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -24,10 +24,14 @@ export * from './kubernetes'; export * from './linodes'; +export * from './locks'; + export * from './longview'; export * from './managed'; +export * from './marketplace'; + export * from './netloadbalancers'; export * from './network-transfer'; diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index db79101e5bc..0d298c662a9 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -192,7 +192,7 @@ export interface ConfigInterfaceIPv4 { export interface IPv6SLAAC { address?: string; - range: string; + range?: string; } // The legacy interface type - for Configuration Profile Interfaces diff --git a/packages/api-v4/src/locks/index.ts b/packages/api-v4/src/locks/index.ts new file mode 100644 index 00000000000..0783eccc39f --- /dev/null +++ b/packages/api-v4/src/locks/index.ts @@ -0,0 +1,3 @@ +export * from './locks'; + +export * from './types'; diff --git a/packages/api-v4/src/locks/locks.ts b/packages/api-v4/src/locks/locks.ts new file mode 100644 index 00000000000..bcb9fb65102 --- /dev/null +++ b/packages/api-v4/src/locks/locks.ts @@ -0,0 +1,63 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setData, setMethod, setURL, setXFilter } from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { CreateLockPayload, ResourceLock } from './types'; + +/** + * getLocks + * + * Returns a paginated list of resource locks on your Account. + * + * @param params { Params } Pagination parameters + * @param filters { Filter } X-Filter for API + */ +export const getLocks = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/locks`), + setMethod('GET'), + setXFilter(filters), + ); + +/** + * getLock + * + * Returns information about a single resource lock. + * + * @param lockId { number } The ID of the lock to retrieve. + */ +export const getLock = (lockId: number) => + Request( + setURL(`${BETA_API_ROOT}/locks/${encodeURIComponent(lockId)}`), + setMethod('GET'), + ); + +/** + * createLock + * + * Creates a new resource lock to prevent accidental deletion or modification. + * + * @param payload { CreateLockPayload } The lock creation payload + * @param payload.entity_type { string } The type of entity to lock (e.g., 'linode') + * @param payload.entity_id { number | string } The ID of the entity to lock + * @param payload.lock_type { string } The type of lock ('cannot_delete', 'cannot_delete_with_subresources') + */ +export const createLock = (payload: CreateLockPayload) => + Request( + setURL(`${BETA_API_ROOT}/locks`), + setData(payload), + setMethod('POST'), + ); + +/** + * deleteLock + * + * Deletes a resource lock, allowing the resource to be deleted or modified. + * + * @param lockId { number } The ID of the lock to delete. + */ +export const deleteLock = (lockId: number) => + Request<{}>( + setURL(`${BETA_API_ROOT}/locks/${encodeURIComponent(lockId)}`), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/locks/types.ts b/packages/api-v4/src/locks/types.ts new file mode 100644 index 00000000000..5528af7b249 --- /dev/null +++ b/packages/api-v4/src/locks/types.ts @@ -0,0 +1,42 @@ +import type { EntityType } from '../entities'; + +/** + * Types of locks that can be applied to a resource + */ +export type LockType = 'cannot_delete' | 'cannot_delete_with_subresources'; + +/** + * Entity information attached to a lock + */ +export interface LockEntity { + id: number | string; + label?: string; + type: EntityType; + url?: string; +} + +/** + * Request payload for creating a lock + * POST /v4beta/locks + */ +export interface CreateLockPayload { + /** Required: ID of the entity being locked */ + entity_id: number | string; + /** Required: Type of the entity being locked */ + entity_type: EntityType; + /** Required: Type of lock to apply */ + lock_type: LockType; +} + +/** + * Resource Lock object returned from API + * Response from POST /v4beta/locks + */ +export interface ResourceLock { + /** Information about the locked entity */ + entity: LockEntity; + /** Unique identifier for the lock */ + id: number; + /** Type of lock applied */ + lock_type: LockType; +} diff --git a/packages/api-v4/src/marketplace/index.ts b/packages/api-v4/src/marketplace/index.ts new file mode 100644 index 00000000000..a16c03c32d8 --- /dev/null +++ b/packages/api-v4/src/marketplace/index.ts @@ -0,0 +1,2 @@ +export * from './marketplace'; +export * from './types'; diff --git a/packages/api-v4/src/marketplace/marketplace.ts b/packages/api-v4/src/marketplace/marketplace.ts new file mode 100644 index 00000000000..e253226a592 --- /dev/null +++ b/packages/api-v4/src/marketplace/marketplace.ts @@ -0,0 +1,68 @@ +import { createPartnerReferralSchema } from '@linode/validation'; + +import { BETA_API_ROOT } from 'src/constants'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from 'src/request'; + +import type { + MarketplaceCategory, + MarketplacePartner, + MarketplacePartnerReferralPayload, + MarketplaceProduct, + MarketplaceType, +} from './types'; +import type { Filter, ResourcePage as Page, Params } from 'src/types'; + +export const getMarketplaceProducts = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/marketplace/products`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +export const getMarketplaceProduct = (productId: number) => + Request( + setURL( + `${BETA_API_ROOT}/marketplace/products/${encodeURIComponent(productId)}`, + ), + setMethod('GET'), + ); + +export const getMarketplaceCategories = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/marketplace/categories`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +export const getMarketplaceTypes = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/marketplace/types`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +export const getMarketplacePartners = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/marketplace/partners`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +export const createPartnerReferral = ( + data: MarketplacePartnerReferralPayload, +) => + Request<{}>( + setURL(`${BETA_API_ROOT}/marketplace/referral`), + setMethod('POST'), + setData(data, createPartnerReferralSchema), + ); diff --git a/packages/api-v4/src/marketplace/types.ts b/packages/api-v4/src/marketplace/types.ts new file mode 100644 index 00000000000..430f4dc395d --- /dev/null +++ b/packages/api-v4/src/marketplace/types.ts @@ -0,0 +1,55 @@ +export interface MarketplaceProductDetail { + documentation: string; + overview: { + description: string; + }; + pricing: string; + support: string; +} + +export interface MarketplaceProduct { + category_ids: number[]; + details?: MarketplaceProductDetail; + id: number; + info_banner?: string; + name: string; + partner_id: number; + product_tags?: string[]; + short_description: string; + title_tag?: string; + type_id: number; +} + +export interface MarketplaceCategory { + category: string; + id: number; + product_count: number; +} + +export interface MarketplaceType { + id: number; + name: string; + product_count: number; +} + +export interface MarketplacePartner { + id: number; + logo_url_light_mode: string; + logo_url_night_mode?: string; + name: string; + url: string; +} + +export interface MarketplacePartnerReferralPayload { + account_executive_email?: string; + additional_emails?: string[]; + comments?: string; + company_name?: string; + country_code: string; + email: string; + name: string; + partner_id: number; + phone: string; + phone_country_code: string; + tc_consent_given: boolean; +} diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index ce60b989007..b4bfcd99a93 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,78 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2026-01-12] - v1.157.0 + + +### Added: + +- Elastic Stack and Weaviate to Marketplace Apps ([#13149](https://github.com/linode/manager/pull/13149)) +- IAM: a permission check to the users table input based on view_account permission ([#13206](https://github.com/linode/manager/pull/13206)) +- Add humanization support for selected units in CloudPulse metrics graphs yAxis, legend and tooltip ([#13220](https://github.com/linode/manager/pull/13220)) +- Add customized humanize method for `cloudpulse metric` graphs ([#13224](https://github.com/linode/manager/pull/13224)) +- Extract EUUID from authenticated API calls at the interceptor level and share it with Adobe analytics through the page view event ([#13229](https://github.com/linode/manager/pull/13229)) +- New feature marker in navigation menu and primary breadcrumbs of `CloudPulse metrics` ([#13230](https://github.com/linode/manager/pull/13230)) + +### Changed: + +- Logs: in Stream Form limit access to "lke_audit_logs" type based on Akamai Cloud Pulse Logs LKE-E Audit capability ([#13171](https://github.com/linode/manager/pull/13171)) +- IAM: Enable account_viewer to access users table ([#13189](https://github.com/linode/manager/pull/13189)) +- DBaaS table action menu wrapper and settings item styles are shared and connection pool queries updated for pagination ([#13195](https://github.com/linode/manager/pull/13195)) +- IAM: allow users with account_viewer role to see the roles table ([#13200](https://github.com/linode/manager/pull/13200)) +- UX/UI changes in Linode Create flow - Networking ([#13223](https://github.com/linode/manager/pull/13223)) +- Allow line breaks in Support Tickets markdown ([#13228](https://github.com/linode/manager/pull/13228)) + +### Fixed: + +- Show edit RDNS button for VPC NAT IPv4 address row in linode network tab ([#13170](https://github.com/linode/manager/pull/13170)) +- Typo in NodeBalancer Settings tooltip ([#13186](https://github.com/linode/manager/pull/13186)) +- Ensure browser history integrity when redirecting in IAM ([#13190](https://github.com/linode/manager/pull/13190)) +- IAM: Enable account_viewer to access IAM User Details, User Roles and User Entities ([#13194](https://github.com/linode/manager/pull/13194)) +- CloudPulse-Metrics: Update `CloudPulseDashboardFilterBuilder.tsx` to add type-check for usage of dependent filters ref ([#13196](https://github.com/linode/manager/pull/13196)) +- IAM: Remove Role filter (already assigned roles) in ChangeRoleForEntityDrawer ([#13201](https://github.com/linode/manager/pull/13201)) +- IAM: Roles table layout shifts when switching tabs, and the email input is enabled for users without the account_admin role ([#13208](https://github.com/linode/manager/pull/13208)) +- IAM: Remove Role filter (already assigned roles) in ChangeRoleDrawer ([#13212](https://github.com/linode/manager/pull/13212)) +- IAM: User can’t edit their own email on the user details page ([#13214](https://github.com/linode/manager/pull/13214)) +- CloudPulse-Metrics: Fix alignment for region filter in lke service. ([#13218](https://github.com/linode/manager/pull/13218)) +- Exclude newly added unsaved Rule Sets from dropdown for the given Firewall ([#13226](https://github.com/linode/manager/pull/13226)) +- Null and Undefined checks in components and tests to support ACLP-Alerting: Notification Channel Type changes ([#13227](https://github.com/linode/manager/pull/13227)) +- IAM Assigned Entities - Increase MAX_ITEMS_TO_RENDER to 25 ([#13231](https://github.com/linode/manager/pull/13231)) +- Fix logic to remove linode interface from firewall's device page ([#13238](https://github.com/linode/manager/pull/13238)) + +### Tech Stories: + +- Code clean up for firewall add event factory ([#13211](https://github.com/linode/manager/pull/13211)) + +### Tests: + +- Proactive IAM e2e gating ([#13120](https://github.com/linode/manager/pull/13120)) +- Mock Destination data update values ([#13176](https://github.com/linode/manager/pull/13176)) +- Temporarily disable DBaaS update tests ([#13185](https://github.com/linode/manager/pull/13185)) +- Fix e2e tests impacted by Generational Plans release ([#13192](https://github.com/linode/manager/pull/13192)) +- Add coverage for the CloudPulse alerts notification channels listing ([#13204](https://github.com/linode/manager/pull/13204)) +- Validate widget-level dimension filtering, edit scenarios, and filter limits across supported operators and dimensions ([#13210](https://github.com/linode/manager/pull/13210)) +- Temporarily skip time range verification Cypress tests ([#13244](https://github.com/linode/manager/pull/13244)) + +### Upcoming Features: + +- Display maintenance type (emergency/scheduled) and config information in linode_migrate event messages ([#13084](https://github.com/linode/manager/pull/13084)) +- Introduce and conditionally render Notification Channels tab under ACLP-Alerting ([#13150](https://github.com/linode/manager/pull/13150)) +- Add back navigation functionality to Drawer and integrate it with PrefixList Drawer ([#13151](https://github.com/linode/manager/pull/13151)) +- Added PG Bouncer ServiceURI component ([#13182](https://github.com/linode/manager/pull/13182)) +- UI/UX enhancements and fixes for Rule Sets & Prefix Lists (part-2) ([#13188](https://github.com/linode/manager/pull/13188)) +- Introduce Listing for ACLP-Alerting Notification channels with ordering, pagination ([#13193](https://github.com/linode/manager/pull/13193)) +- DBaaS PgBouncer Connection Pools section to be displayed in Networking tab for PostgreSQL database clusters ([#13195](https://github.com/linode/manager/pull/13195)) +- Enable Action Item for ACLP-Alerting Notification Channel Listing ([#13203](https://github.com/linode/manager/pull/13203)) +- Add VPC IPv6 support in Linode Add/Edit Config dialog ([#13209](https://github.com/linode/manager/pull/13209)) +- UX enhancements of `CloudPulseDateTimeRangePicker` and `DateTimeRangePicker` components in cloudpulse metrics ([#13216](https://github.com/linode/manager/pull/13216)) +- CloudPulse-Alerts: Add components for create notification channel flow ([#13217](https://github.com/linode/manager/pull/13217)) +- Allow system channel selection based on selected service type in `CloudPulse` create and edit `alerts` ([#13219](https://github.com/linode/manager/pull/13219)) +- Implement routing for Cloud Manager Marketplace ([#13222](https://github.com/linode/manager/pull/13222)) +- CloudPulse-Alerts: Add create notification channel page ([#13225](https://github.com/linode/manager/pull/13225)) +- CloudPulse-Alerts: Add edit feature for notification channels ([#13235](https://github.com/linode/manager/pull/13235)) +- Delete Database Connection Pool dialog ([#13236](https://github.com/linode/manager/pull/13236)) +- CloudPulse-Alerts: Use simpler query approach in notification recipients for fetching users ([#13242](https://github.com/linode/manager/pull/13242)) + ## [2025-12-16] - v1.156.1 diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index 8db26e068d3..d68aba62661 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -13,6 +13,7 @@ import { mockCancelAccountError, mockGetAccount, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockWebpageUrl } from 'support/intercepts/general'; import { mockGetProfile, @@ -35,6 +36,14 @@ import { import type { CancelAccount } from '@linode/api-v4'; describe('Account cancellation', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Confirms that a user can cancel their account from the Account Settings page. * - Confirms that user is warned that account cancellation is destructive. @@ -227,6 +236,13 @@ describe('Account cancellation', () => { }); describe('Parent/Child account cancellation', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); /* * - Confirms that a child user cannot close the account. */ diff --git a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts index c9466939e48..7edf0fc8d84 100644 --- a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts @@ -20,6 +20,7 @@ import { mockEnableLinodeManagedError, mockGetAccount, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetProfile, @@ -33,6 +34,14 @@ import { accountFactory } from 'src/factories/account'; import type { Linode } from '@linode/api-v4'; describe('Account Linode Managed', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Confirms that a user can add linode managed from the Account Settings page. * - Confirms that user is told about the Managed price. diff --git a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts index c0fed1236d1..7db08d47829 100644 --- a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts @@ -8,6 +8,7 @@ import { loginHelperText, } from 'support/constants/account'; import { mockGetAccountLogins } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, @@ -18,6 +19,14 @@ import { PARENT_USER } from 'src/features/Account/constants'; import { formatDate } from 'src/utilities/formatDate'; describe('Account login history', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Confirms that a user can navigate to and view the login history page. * - Confirms that login table displays the expected column headers. diff --git a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts index 3ed8e8724d1..3d95d600dc6 100644 --- a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts @@ -1,6 +1,7 @@ import { grantsFactory, profileFactory } from '@linode/utilities'; import { getProfile } from 'support/api/account'; import { mockUpdateUsername } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { interceptGetProfile, mockGetProfileGrants, @@ -50,6 +51,14 @@ const verifyUsernameAndEmail = ( }; describe('Display Settings', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Validates username update flow via the profile display page using mocked data. */ diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index 5d66a00e002..8efd2e884d0 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -173,6 +173,9 @@ describe('User permission management', () => { // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. mockAppendFeatureFlags({ iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }).as('getFeatureFlags'); }); diff --git a/packages/manager/cypress/e2e/core/account/user-profile.spec.ts b/packages/manager/cypress/e2e/core/account/user-profile.spec.ts index b288cdd14ea..1beba9a9374 100644 --- a/packages/manager/cypress/e2e/core/account/user-profile.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-profile.spec.ts @@ -5,6 +5,7 @@ import { mockGetUsers, mockUpdateUsername, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockUpdateProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomString } from 'support/util/random'; @@ -23,6 +24,12 @@ describe('User Profile', () => { const newUsername = randomString(12); const newEmail = `${newUsername}@example.com`; + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + getProfile().then((profile) => { const activeUsername = profile.body.username; const activeEmail = profile.body.email; diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index f3badb4c852..b343ac638d5 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -84,6 +84,9 @@ describe('Users landing page', () => { // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. mockAppendFeatureFlags({ iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }).as('getFeatureFlags'); }); diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index 61f2c081cc2..febde9e4661 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -227,6 +227,9 @@ describe('restricted user billing flows', () => { // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. mockAppendFeatureFlags({ iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }); }); diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index 9a9f8946d11..d130776eb56 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -120,6 +120,9 @@ describe('Billing Activity Feed', () => { mockAppendFeatureFlags({ // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }); }); /* diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts new file mode 100644 index 00000000000..eddcbd0d53d --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts @@ -0,0 +1,352 @@ +/** + * @file Integration Tests for CloudPulse Alerting — Notification Channel Listing Page + */ +import { profileFactory } from '@linode/utilities'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetAlertChannels } from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; + +import { + accountFactory, + flagsFactory, + notificationChannelFactory, +} from 'src/factories'; +import { + ChannelAlertsTooltipText, + ChannelListingTableLabelMap, +} from 'src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants'; +import { formatDate } from 'src/utilities/formatDate'; + +import type { NotificationChannel } from '@linode/api-v4'; + +const sortOrderMap = { + ascending: 'asc', + descending: 'desc', +}; + +const LabelLookup = Object.fromEntries( + ChannelListingTableLabelMap.map((item) => [item.colName, item.label]) +); +type SortOrder = 'ascending' | 'descending'; + +interface VerifyChannelSortingParams { + columnLabel: string; + expected: number[]; + sortOrder: SortOrder; +} + +const notificationChannels = notificationChannelFactory + .buildList(26) + .map((ch, i) => { + const isEmail = i % 2 === 0; + const alerts = { + alert_count: isEmail ? 5 : 3, + url: `monitor/alert-channels/${i + 1}/alerts`, + type: 'alerts-definitions', + }; + + if (isEmail) { + return { + ...ch, + id: i + 1, + label: `Channel-${i + 1}`, + type: 'user', + created_by: 'user', + updated_by: 'user', + channel_type: 'email', + updated: new Date(2024, 0, i + 1).toISOString(), + alerts, + content: { + email: { + email_addresses: [`test-${i + 1}@example.com`], + subject: 'Test Subject', + message: 'Test message', + }, + }, + } as NotificationChannel; + } else { + return { + ...ch, + id: i + 1, + label: `Channel-${i + 1}`, + type: 'system', + created_by: 'system', + updated_by: 'system', + channel_type: 'webhook', + updated: new Date(2024, 0, i + 1).toISOString(), + alerts, + content: { + webhook: { + webhook_url: `https://example.com/webhook/${i + 1}`, + http_headers: [ + { + header_key: 'Authorization', + header_value: 'Bearer secret-token', + }, + ], + }, + }, + } as NotificationChannel; + } + }); + +const isEmailContent = ( + content: NotificationChannel['content'] +): content is { + email: { + email_addresses: string[]; + message: string; + subject: string; + }; +} => content !== undefined && 'email' in content; +const mockProfile = profileFactory.build({ + timezone: 'gmt', +}); + +/** + * Verifies sorting of a column in the alerts table. + * + * @param params - Configuration object for sorting verification. + * @param params.columnLabel - The label of the column to sort. + * @param params.sortOrder - Expected sorting order (ascending | descending). + * @param params.expected - Expected row order after sorting. + */ +const VerifyChannelSortingParams = ( + columnLabel: string, + sortOrder: 'ascending' | 'descending', + expected: number[] +) => { + cy.get(`[data-qa-header="${columnLabel}"]`).click({ force: true }); + + cy.get(`[data-qa-header="${columnLabel}"]`) + .invoke('attr', 'aria-sort') + .then((current) => { + if (current !== sortOrder) { + cy.get(`[data-qa-header="${columnLabel}"]`).click({ force: true }); + } + }); + + cy.get(`[data-qa-header="${columnLabel}"]`).should( + 'have.attr', + 'aria-sort', + sortOrder + ); + + cy.get('[data-qa="notification-channels-table"] tbody:last-of-type tr').then( + ($rows) => { + const actualOrder = $rows + .toArray() + .map((row) => + Number(row.getAttribute('data-qa-notification-channel-cell')) + ); + expect(actualOrder).to.eqls(expected); + } + ); + + const order = sortOrderMap[sortOrder]; + const orderBy = encodeURIComponent(LabelLookup[columnLabel]); + + cy.url().should( + 'endWith', + `/alerts/notification-channels?order=${order}&orderBy=${orderBy}` + ); +}; + +describe('Notification Channel Listing Page', () => { + /** + * Validates the listing page for CloudPulse notification channels. + * Confirms channel data rendering, search behavior, and table sorting + * across all columns using a controlled 26-item mock dataset. + */ + beforeEach(() => { + mockAppendFeatureFlags(flagsFactory.build()); + mockGetProfile(mockProfile); + mockGetAccount(accountFactory.build()); + mockGetAlertChannels(notificationChannels).as( + 'getAlertNotificationChannels' + ); + + cy.visitWithLogin('/alerts/notification-channels'); + + ui.pagination.findPageSizeSelect().click(); + + cy.get('[data-qa-pagination-page-size-option="100"]') + .should('exist') + .click(); + + ui.tooltip.findByText(ChannelAlertsTooltipText).should('be.visible'); + + cy.wait('@getAlertNotificationChannels').then(({ response }) => { + const body = response?.body; + const data = body?.data; + + const channels = data as NotificationChannel[]; + + expect(body?.results).to.eq(notificationChannels.length); + + channels.forEach((item, index) => { + const expected = notificationChannels[index]; + + // Basic fields + expect(item.id).to.eq(expected.id); + expect(item.label).to.eq(expected.label); + expect(item.type).to.eq(expected.type); + expect(item.status).to.eq(expected.status); + expect(item.channel_type).to.eq(expected.channel_type); + + // Creator/updater fields + expect(item.created_by).to.eq(expected.created_by); + expect(item.updated_by).to.eq(expected.updated_by); + + // Email content (safe narrow) + if (isEmailContent(item.content) && isEmailContent(expected.content)) { + expect(item.content.email.email_addresses).to.deep.eq( + expected.content.email.email_addresses + ); + expect(item.content.email.subject).to.eq( + expected.content.email.subject + ); + expect(item.content.email.message).to.eq( + expected.content.email.message + ); + } + + // Alerts list + expect(item.alerts.alert_count).to.eq(expected.alerts.alert_count); + }); + }); + }); + + it('searches and validates notification channel details', () => { + cy.findByPlaceholderText('Search for Notification Channels').as( + 'searchInput' + ); + + cy.get('[data-qa="notification-channels-table"]') + .find('tbody') + .last() + .within(() => { + cy.get('tr').should('have.length', 26); + }); + + cy.get('@searchInput').clear(); + cy.get('@searchInput').type('Channel-9'); + cy.get('[data-qa="notification-channels-table"]') + .find('tbody') + .last() + .within(() => { + cy.get('tr').should('have.length', 1); + + cy.get('tr').each(($row) => { + const expected = notificationChannels[8]; + + cy.wrap($row).within(() => { + cy.findByText(expected.label).should('be.visible'); + cy.findByText(String(expected.alerts.alert_count)).should( + 'be.visible' + ); + cy.findByText('Email').should('be.visible'); + cy.get('td').eq(3).should('have.text', expected.created_by); + cy.findByText( + formatDate(expected.updated, { + format: 'MMM dd, yyyy, h:mm a', + timezone: 'GMT', + }) + ).should('be.visible'); + cy.get('td').eq(5).should('have.text', expected.updated_by); + }); + }); + }); + }); + + it('sorting and validates notification channel details', () => { + const sortColumns = [ + { + column: 'Channel Name', + ascending: [...notificationChannels] + .sort((a, b) => a.label.localeCompare(b.label)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.label.localeCompare(a.label)) + .map((ch) => ch.id), + }, + { + column: 'Alerts', + ascending: [...notificationChannels] + .sort((a, b) => a.alerts.alert_count - b.alerts.alert_count) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.alerts.alert_count - a.alerts.alert_count) + .map((ch) => ch.id), + }, + + { + column: 'Channel Type', + ascending: [...notificationChannels] + .sort((a, b) => a.channel_type.localeCompare(b.channel_type)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.channel_type.localeCompare(a.channel_type)) + .map((ch) => ch.id), + }, + + { + column: 'Created By', + ascending: [...notificationChannels] + .sort((a, b) => a.created_by.localeCompare(b.created_by)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.created_by.localeCompare(a.created_by)) + .map((ch) => ch.id), + }, + { + column: 'Last Modified', + ascending: [...notificationChannels] + .sort((a, b) => a.updated.localeCompare(b.updated)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.updated.localeCompare(a.updated)) + .map((ch) => ch.id), + }, + { + column: 'Last Modified By', + ascending: [...notificationChannels] + .sort((a, b) => a.updated_by.localeCompare(b.updated_by)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.updated_by.localeCompare(a.updated_by)) + .map((ch) => ch.id), + }, + ]; + + cy.get('[data-qa="notification-channels-table"] thead th').as('headers'); + + cy.get('@headers').then(($headers) => { + const actual = Array.from($headers) + .map((th) => th.textContent?.trim()) + .filter(Boolean); + + expect(actual).to.deep.equal([ + 'Channel Name', + 'Alerts', + 'Channel Type', + 'Created By', + 'Last Modified', + 'Last Modified By', + ]); + }); + + sortColumns.forEach(({ column, ascending, descending }) => { + VerifyChannelSortingParams(column, 'ascending', ascending); + VerifyChannelSortingParams(column, 'descending', descending); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts new file mode 100644 index 00000000000..ad08eabc340 --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts @@ -0,0 +1,109 @@ +/** + * @file Integration Tests for CloudPulse Alerting — Notification Channel Listing Page + * + * Covers four access-control behaviors: + * 1. Access is allowed when `notificationChannels` is true. + * 2. Navigation/tab visibility is blocked when `notificationChannels` is false. + * 3. Direct URL access is blocked when `notificationChannels` is false. + * 4. All access is blocked when CloudPulse (`aclp`) is disabled. + */ + +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +import { accountFactory } from 'src/factories'; + +import type { Flags } from 'src/featureFlags'; + +describe('Notification Channel Listing Page — Access Control', () => { + beforeEach(() => { + mockGetAccount(accountFactory.build()); + }); + + it('allows access when notificationChannels is enabled', () => { + const flags: Partial = { + aclp: { beta: true, enabled: true }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + recentActivity: false, + notificationChannels: true, + }, + }; + + mockAppendFeatureFlags(flags); + cy.visitWithLogin('/linodes'); + + ui.nav.findItemByTitle('Alerts').should('be.visible').click(); + ui.tabList + .findTabByTitle('Notification Channels') + .should('be.visible') + .click(); + + cy.url().should('endWith', 'alerts/notification-channels'); + }); + + it('hides the Notification Channels tab when notificationChannels is disabled', () => { + const flags: Partial = { + aclp: { beta: true, enabled: true }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + recentActivity: false, + notificationChannels: false, + }, + }; + + mockAppendFeatureFlags(flags); + cy.visitWithLogin('/linodes'); + + ui.nav.findItemByTitle('Alerts').should('be.visible').click(); + + // Tab should not render at all + ui.tabList.findTabByTitle('Notification Channels').should('not.exist'); + }); + + it('blocks all access when CloudPulse is disabled', () => { + const flags: Partial = { + aclp: { beta: true, enabled: false }, // CloudPulse OFF + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + recentActivity: false, + notificationChannels: true, + }, + }; + + mockAppendFeatureFlags(flags); + cy.visitWithLogin('/alerts/notification-channels'); + + // Application should return fallback + cy.findByText('Not Found').should('be.visible'); + }); + + it('blocks direct URL access to /alerts/notification-channels when notificationChannels is disabled', () => { + const flags: Partial = { + aclp: { beta: true, enabled: true }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + recentActivity: false, + notificationChannels: false, // feature OFF → user should not enter page + }, + }; + + mockAppendFeatureFlags(flags); + cy.visitWithLogin('/alerts/notification-channels'); + // Tab must not exist + ui.tabList.findTabByTitle('Notification Channels').should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts index e86622b72bf..3a561db9645 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts @@ -22,14 +22,17 @@ describe('Moniter navigation', () => { it('can navigate to metrics landing page', () => { mockAppendFeatureFlags({ aclp: { - beta: true, + beta: false, enabled: true, + new: true, }, }).as('getFeatureFlags'); cy.visitWithLogin('/linodes'); cy.wait('@getFeatureFlags'); - + cy.get('[data-testid="menu-item-Metrics"]').within(() => { + cy.get('[data-testid="newFeatureChip"]').should('be.visible'); // check for new feature chip + }); cy.get('[data-testid="menu-item-Metrics"]').should('be.visible').click(); cy.url().should('endWith', '/metrics'); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts index e2b882a866b..07d00cbae43 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -81,7 +81,7 @@ const notificationChannels = notificationChannelFactory.build({ channel_type: 'email', id: 1, label: 'channel-1', - type: 'custom', + type: 'user', }); const customAlertDefinition = alertDefinitionFactory.build({ diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 4f3d43a6bb1..c5bb1da2c8b 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -290,12 +290,11 @@ describe('Integration Tests for DBaaS Dashboard ', () => { .click(); // Select a time duration from the autocomplete input. - // Updated selector for MUI x-date-pickers v8 - click on the wrapper div - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('timeRangeTrigger'); + cy.get('@timeRangeTrigger').click(); - cy.get('@startDateInput').click(); - - cy.get('[data-qa-preset="Last day"]').click(); + // select a different preset but cancel + ui.button.findByTitle('Last day').click(); // Click the "Apply" button to confirm the end date and time cy.get('[data-qa-buttons="apply"]') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts index e933804e6c6..95e99e6f132 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -118,7 +118,7 @@ const notificationChannels = notificationChannelFactory.build({ channel_type: 'email', id: 1, label: 'Channel-1', - type: 'custom', + type: 'user', }); const mockProfile = profileFactory.build({ timezone: 'gmt', diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index e0b04829dfa..450fdda6a91 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -179,12 +179,11 @@ describe('Integration Tests for Linode Dashboard ', () => { .should('be.visible') .click(); // Select a time duration from the autocomplete input. - // Updated selector for MUI x-date-pickers v8 - click on the wrapper div - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('timeRangeTrigger'); + cy.get('@timeRangeTrigger').click(); - cy.get('@startDateInput').click(); - - cy.get('[data-qa-preset="Last day"]').click(); + // select a different preset but cancel + ui.button.findByTitle('Last day').click(); // Click the "Apply" button to confirm the end date and time cy.get('[data-qa-buttons="apply"]') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts index dd26d1d73e5..6a515339b71 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts @@ -182,12 +182,11 @@ describe('Integration Tests for Nodebalancer Dashboard ', () => { .click(); // Select a time duration from the autocomplete input. - // Updated selector for MUI x-date-pickers v8 - click on the wrapper div - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('timeRangeTrigger'); + cy.get('@timeRangeTrigger').click(); - cy.get('@startDateInput').click(); - - cy.get('[data-qa-preset="Last day"]').click(); + // select a different preset but cancel + ui.button.findByTitle('Last day').click(); cy.get('[data-qa-buttons="apply"]') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts index 3ce18db0626..416da6e8546 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -1,4 +1,5 @@ /* eslint-disable cypress/no-unnecessary-waiting */ + /** * @file Integration Tests for CloudPulse Custom and Preset Verification */ @@ -94,17 +95,22 @@ const databaseMock: Database = databaseFactory.build({ region: mockRegion.id, type: engine, }); +// Profile timezone is set to 'UTC' const mockProfile = profileFactory.build({ - timezone: 'GMT', + timezone: 'UTC', }); /** - * Generates a date in UTC based on a specified number of hours and minutes offset. The function also provides individual date components such as day, hour, + * Generates a date in Indian Standard Time (IST) based on a specified number of days offset, + * hour, and minute. The function also provides individual date components such as day, hour, * minute, month, and AM/PM. + * + * @param {number} daysOffset - The number of days to adjust from the current date. Positive + * values give a future date, negative values give a past date. * @param {number} hour - The hour to set for the resulting date (0-23). * @param {number} [minute=0] - The minute to set for the resulting date (0-59). Defaults to 0. * * @returns {Object} - Returns an object containing: - * - `actualDate`: The formatted date and time in UTC (YYYY-MM-DD HH:mm). + * - `actualDate`: The formatted date and time in GMT (YYYY-MM-DD HH:mm). * - `day`: The day of the month as a number. * - `hour`: The hour in the 24-hour format as a number. * - `minute`: The minute of the hour as a number. @@ -121,12 +127,18 @@ const getDateRangeInGMT = ( : now.set({ hour, minute }).setZone('GMT'); const actualDate = targetDate.setZone('GMT').toFormat('yyyy-LL-dd HH:mm'); + const previousMonthDate = targetDate.minus({ months: 1 }); + return { actualDate, day: targetDate.day, hour: targetDate.hour, minute: targetDate.minute, - month: targetDate.month, + month: targetDate.toFormat('LLLL'), + year: targetDate.year, + daysInMonth: targetDate.daysInMonth, + previousMonth: previousMonthDate.toFormat('LLLL'), + previousYear: previousMonthDate.year, }; }; @@ -175,8 +187,22 @@ const formatToUtcDateTime = (dateStr: string): string => { .toFormat('yyyy-MM-dd HH:mm'); }; -// It is going to be modified -describe('Integration tests for verifying Cloudpulse custom and preset configurations', () => { +/* + * TODO Fix or migrate the tests in `timerange-verification.spec.ts`. + * + * The tests in this spec frequently fail during specific dates and time periods + * throughout the day and year. Because there are so many tests in this spec, the + * timeouts and subsequent failures can delay test runs by several (45+) minutes + * which frequently interferes with unrelated test runs. + * + * Other considerations: + * + * - Would unit tests or component tests be a better fit for this? + * + * - Are these tests adding any value? They fail frequently and the failures do + * not get reviewed. They do not seem to be protecting us from regressions. + */ +describe.skip('Integration tests for verifying Cloudpulse custom and preset configurations', () => { /* * - Mocks user preferences for dashboard details (dashboard, engine, resources, and region). * - Simulates loading test data without real API calls. @@ -226,9 +252,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura '@fetchPreferences', '@fetchDatabases', ]); - - // Scroll to the top of the page to ensure consistent test behavior - cy.scrollTo('top'); }); it('should implement and validate custom date/time picker for a specific date and time range', () => { @@ -238,6 +261,11 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura day: startDay, hour: startHour, minute: startMinute, + month: startMonth, + year: startYear, + previousMonth, + previousYear, + daysInMonth, } = getDateRangeInGMT(12, 15, true); const { @@ -249,43 +277,52 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.wait(1000); // --- Select start date --- - // Updated selector for MUI x-date-pickers v8 - click on the wrapper div - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('startDateInput'); + + cy.get('@startDateInput').scrollIntoView(); + cy.get('@startDateInput').click(); + cy.get('[role="dialog"]').within(() => { cy.findAllByText(startDay).first().click(); cy.findAllByText(endDay).first().click(); }); - // Updated selector for MUI x-date-pickers v8 time picker button - cy.get('button[aria-label*="time"]') + ui.button + .findByAttribute('aria-label^', 'Choose time') .first() .should('be.visible', { timeout: 10000 }) // waits up to 10 seconds .as('timePickerButton'); - cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - cy.get('@timePickerButton', { timeout: 15000 }) - .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) - .click(); + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); // Selects the start hour, minute, and meridiem (AM/PM) in the time picker. + + cy.get(`[aria-label="${startHour} hours"]`).click(); + cy.wait(1000); - cy.findByLabelText('Select hours') - .as('selectHours') - .scrollIntoView({ easing: 'linear' }); + ui.button + .findByAttribute('aria-label^', 'Choose time') + .first() + .should('be.visible', { timeout: 10000 }) + .as('timePickerButton'); - cy.get('@selectHours').within(() => { - cy.get(`[aria-label="${startHour} hours"]`).click(); - }); + cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - cy.findByLabelText('Select minutes') - .as('selectMinutes') - .scrollIntoView({ duration: 500, easing: 'linear' }); + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); - cy.get('@selectMinutes').within(() => { - cy.get(`[aria-label="${startMinute} minutes"]`).click(); - }); + cy.get(`[aria-label="${startMinute} minutes"]`).click(); + + ui.button + .findByAttribute('aria-label^', 'Choose time') + .first() + .should('be.visible', { timeout: 10000 }) + .as('timePickerButton'); + + cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); + + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); cy.findByLabelText('Select meridiem') .as('startMeridiemSelect') @@ -293,8 +330,8 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get('@startMeridiemSelect').find('[aria-label="PM"]').click(); // --- Select end time --- - // Updated selector for MUI x-date-pickers v8 time picker button - cy.get('button[aria-label*="time"]') + ui.button + .findByAttribute('aria-label^', 'Choose time') .last() .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); @@ -306,13 +343,23 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura duration: 500, easing: 'linear', }); - cy.get('@selectHours').within(() => { - cy.get(`[aria-label="${endHour} hours"]`).click(); - }); + cy.get(`[aria-label="${endHour} hours"]`).click(); - cy.get('@selectMinutes').within(() => { - cy.get(`[aria-label="${endMinute} minutes"]`).click(); - }); + cy.get('[aria-label^="Choose time"]') + .last() + .should('be.visible') + .as('timePickerButton'); + + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); + + cy.get(`[aria-label="${endMinute} minutes"]`).click(); + + cy.get('[aria-label^="Choose time"]') + .last() + .should('be.visible', { timeout: 10000 }) + .as('timePickerButton'); + + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); cy.findByLabelText('Select meridiem') .as('endMeridiemSelect') @@ -320,11 +367,8 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get('@endMeridiemSelect').find('[aria-label="PM"]').click(); // --- Set timezone --- - cy.findByPlaceholderText('Choose a Timezone').as('timezoneInput').click(); - cy.get('@timezoneInput').clear(); - cy.get('@timezoneInput') - .should('not.be.disabled') - .type('(GMT +0:00) Greenwich Mean Time{enter}'); + cy.findByPlaceholderText('Choose a Timezone').as('timezoneInput').clear(); + cy.get('@timezoneInput').type('(GMT +0:00) Greenwich Mean Time{enter}'); // --- Apply date/time range --- cy.get('[data-qa-buttons="apply"]') @@ -333,7 +377,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura .click(); // --- Re-validate after apply --- - cy.get('[aria-labelledby="start-date"]').should( 'have.value', `${startActualDate} PM` @@ -343,6 +386,8 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura `${endActualDate} PM` ); + ui.button.findByTitle('Cancel').and('be.enabled').click(); + // --- Select Node Type --- ui.autocomplete.findByLabel('Node Type').type('Primary{enter}'); @@ -354,17 +399,12 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura const { request: { body }, } = xhr as Interception; - expect(formatToUtcDateTime(body.absolute_time_duration.start)).to.equal( convertToGmt(startActualDate) ); expect(formatToUtcDateTime(body.absolute_time_duration.end)).to.equal( convertToGmt(endActualDate) ); - - // Keep a minimal structural assertion so the request shape is still validated - expect(body).to.have.nested.property('absolute_time_duration.start'); - expect(body).to.have.nested.property('absolute_time_duration.end'); }); // --- Test Time Range Presets --- @@ -372,14 +412,70 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura 'getPresets' ); + // Open the date range picker to apply the "Last 30 Days" preset + + cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); cy.get('@startDateInput').click(); - cy.get('[data-qa-preset="Last 30 days"]').click(); + ui.button.findByTitle('Last 30 days').should('be.visible').click(); + + cy.get('[data-qa-preset="Last 30 days"]').should( + 'have.attr', + 'aria-selected', + 'true' + ); + + cy.contains(`${previousMonth} ${previousYear}`) + .closest('div') + .next() + .find('[aria-selected="true"]') + .then(($els) => { + const selectedDays = Array.from($els).map((el) => + Number(el.textContent?.trim()) + ); + + expect(daysInMonth, 'daysInMonth should be defined').to.be.a('number'); + + const totalDays = daysInMonth as number; + const expectedCount = totalDays - endDay; + + expect( + selectedDays.length, + 'number of selected days from the previous month for the last-30-days range' + ).to.eq(expectedCount); + expect( + totalDays - selectedDays.length, + 'start day of Last 30 days' + ).to.eq(endDay); + }); + + cy.contains(`${startMonth} ${startYear}`) + .closest('div') + .next() + .find('[aria-selected="true"]') + .then(($els) => { + const selectedDays = Array.from($els).map((el) => + Number(el.textContent?.trim()) + ); + + expect( + selectedDays.length, + 'number of selected days in the current month for the last-30-days range' + ).to.eq(endDay); + expect(Math.max(...selectedDays), 'end day of Last 30 days').to.eq( + endDay + ); + }); cy.get('[data-qa-buttons="apply"]') .should('be.visible') - .and('be.enabled') + .should('be.enabled') .click(); + ui.button + .findByTitle('Last 30 days') + .should('be.visible') + .should('be.enabled'); + cy.get('@getPresets.all') .should('have.length', 4) .each((xhr: unknown) => { @@ -399,9 +495,19 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura timeRanges.forEach((range) => { it(`Select and validate the functionality of the "${range.label}" preset from the "Time Range" dropdown`, () => { - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('startDateInput'); + cy.get('@startDateInput').scrollIntoView(); + cy.get('@startDateInput').click(); - cy.get(`[data-qa-preset="${range.label}"]`).click(); + + ui.button.findByTitle(range.label).click(); + + cy.get(`[data-qa-preset="${range.label}"]`).should( + 'have.attr', + 'aria-selected', + 'true' + ); + cy.get('[data-qa-buttons="apply"]') .should('be.visible') .should('be.enabled') @@ -432,9 +538,17 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura it('Select the "Last Month" preset from the "Time Range" dropdown and verify its functionality.', () => { const { end, start } = getLastMonthRange(); - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('startDateInput'); + cy.get('@startDateInput').scrollIntoView(); + cy.get('@startDateInput').click(); - cy.get('[data-qa-preset="Last month"]').click(); + + ui.button.findByTitle('Last month').click(); + + cy.get('[data-qa-preset="Last month"]') + .should('exist') + .and('have.attr', 'aria-selected', 'true'); + cy.get('[data-qa-buttons="apply"]') .should('be.visible') .should('be.enabled') @@ -460,9 +574,16 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura it('Select the "This Month" preset from the "Time Range" dropdown and verify its functionality.', () => { const { end, start } = getThisMonthRange(); - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('startDateInput'); + cy.get('@startDateInput').scrollIntoView(); + cy.get('@startDateInput').click(); - cy.get('[data-qa-preset="This month"]').click(); + + ui.button.findByTitle('This month').click(); + + cy.get('[data-qa-preset="This month"]') + .should('exist') + .and('have.attr', 'aria-selected', 'true'); cy.get('[data-qa-buttons="apply"]') .should('be.visible') .should('be.enabled') @@ -488,4 +609,31 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura ).to.equal(formatDate(end, { format: 'yyyy-MM-dd hh:mm' })); }); }); + + it('should not change the selected preset when a new preset selection is cancelled', () => { + // open the time range picker + ui.button.findByTitle('Last hour').as('timeRangeTrigger'); + cy.get('@timeRangeTrigger').click(); + + // verify initial preset + cy.get('[data-qa-preset="Last hour"]').should( + 'have.attr', + 'aria-selected', + 'true' + ); + + // select a different preset but cancel + ui.button.findByTitle('Last month').click(); + ui.button.findByTitle('Cancel').should('be.visible').click(); + + // reopen picker + cy.get('@timeRangeTrigger').click(); + + // original preset should remain selected + cy.get('[data-qa-preset="Last hour"]').should( + 'have.attr', + 'aria-selected', + 'true' + ); + }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/widget-level-dimension-filter.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/widget-level-dimension-filter.spec.ts new file mode 100644 index 00000000000..e6392822796 --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/widget-level-dimension-filter.spec.ts @@ -0,0 +1,683 @@ +/** + * @file Integration Tests for Widget level dimension filter functionality + */ +import { linodeFactory, regionFactory } from '@linode/utilities'; +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockCreateCloudPulseJWEToken, + mockCreateCloudPulseMetrics, + mockGetCloudPulseDashboard, + mockGetCloudPulseDashboards, + mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServices, +} from 'support/intercepts/cloudpulse'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetVolumes } from 'support/intercepts/volumes'; +import { ui } from 'support/ui'; +import { generateRandomMetricsData } from 'support/util/cloudpulse'; + +import { + accountFactory, + cloudPulseMetricsResponseFactory, + dashboardFactory, + dashboardMetricFactory, + flagsFactory, + volumeFactory, + widgetFactory, +} from 'src/factories'; + +// Test data constants +const timeDurationToSelect = 'Last 24 Hours'; +const { dashboardName, id, metrics } = widgetDetails.blockstorage; +const serviceType = 'blockstorage'; + +// Dashboard definition +const dashboard = dashboardFactory.build({ + label: dashboardName, + service_type: serviceType, + id, + widgets: metrics.map(({ name, title, unit, yLabel }) => + widgetFactory.build({ + filters: [], + label: title, + metric: name, + unit, + y_label: yLabel, + namespace_id: id, + service_type: serviceType, + }) + ), +}); + +/** + * Converts widget metric filters to dashboard filter format + * @param metricName - Name of the metric to get filters for + * @returns Array of dashboard-compatible filter objects + */ + +const getFiltersForMetric = (metricName: string) => { + const metric = metrics.find((m) => m.name === metricName); + if (!metric) return []; + + return metric.filters.map((filter) => ({ + dimension_label: filter.dimension_label, + label: filter.dimension_label, + values: filter.value + ? Array.isArray(filter.value) + ? filter.value + : [filter.value] + : undefined, + })); +}; + +// Metric definitions +const metricDefinitions = metrics.map(({ name, title, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + dimensions: [...getFiltersForMetric(name)], + }) +); + +const mockRegions = [ + regionFactory.build({ + capabilities: ['Block Storage'], + id: 'us-ord', + label: 'Chicago, IL', + monitors: { + metrics: ['Block Storage'], + alerts: [], + }, + }), +]; + +const metricsAPIResponsePayload = cloudPulseMetricsResponseFactory.build({ + data: generateRandomMetricsData(timeDurationToSelect, '5 min'), +}); + +const mockVolumesEncrypted = [ + volumeFactory.build({ + encryption: 'enabled', + label: 'test-volume-ord', + region: 'us-ord', // Chicago + }), +]; + +const linodes = [ + linodeFactory.build({ + id: 123, + label: 'Test-linode-1', + region: 'us-ord', + tags: ['tag-1'], + }), + linodeFactory.build({ + id: 456, + label: 'Test-linode-2', + region: 'us-ord', + tags: ['tag-2'], + }), + linodeFactory.build({ + id: 789, + label: 'Test-linode-3', + region: 'us-ord', + }), +]; + +describe('Widget level dimension filter ', () => { + /** + * Verifies widget-level dimension filter functionality, including + * creating and applying multiple filters with different operators and value types, + * editing existing filters (update operator/value, add/delete filters), + * validating UI behavior (drawer state, badge count, limits), + * and ensuring the applied filters are correctly reflected in the API request payload. + */ + beforeEach(() => { + mockGetFeatureFlagClientstream(); + mockAppendFeatureFlags(flagsFactory.build()).as('featureFlags'); + mockGetAccount(accountFactory.build()); + mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + mockGetCloudPulseServices([serviceType]).as('fetchServices'); + mockGetCloudPulseDashboard(id, dashboard).as('fetchDashboard'); + mockCreateCloudPulseJWEToken(serviceType); + mockCreateCloudPulseMetrics(serviceType, metricsAPIResponsePayload).as( + 'getMetrics' + ); + mockGetRegions(mockRegions); + mockGetLinodes(linodes).as('getLinodes'); + mockGetVolumes(mockVolumesEncrypted); + mockGetUserPreferences({}); + // Navigate to the metrics page + cy.visitWithLogin('/metrics'); + + // Wait for services and dashboard data to load + cy.wait(['@fetchServices']); + cy.wait('@fetchDashboard'); + + // Select dashboard and resources from dropdown + ui.autocomplete + .findByLabel('Dashboard') + .should('be.visible') + .type(dashboardName); + ui.autocompletePopper + .findByTitle(dashboardName) + .should('be.visible') + .click(); + + // Select Chicago region from region selector + ui.regionSelect.find().clear(); + ui.regionSelect.find().click(); + ui.regionSelect.find().click().type(`${mockRegions[0].label}{enter}`); + + ui.autocomplete + .findByLabel('Volumes') + .should('be.visible') + .type(mockVolumesEncrypted[0].label); + ui.autocompletePopper + .findByTitle(mockVolumesEncrypted[0].label) + .should('be.visible') + .click(); + ui.autocomplete.findByLabel('Volumes').type('{esc}'); + + cy.wait(Array(6).fill('@getMetrics')); + }); + + it('should verify the widget level dimension filter and validate the API response', () => { + // Verify tooltip message on hover of filter icon + ui.tooltip.findByText('Dimension Filters').should('be.visible'); + ui.drawer.find().should('not.exist'); + + // Open the filter drawer + ui.button + .findByAttribute( + 'aria-label', + `Widget Dimension Filter ${dashboard.widgets[0].label}` + ) + .should('be.visible') + .click(); + cy.get('[data-testid="drawer"]').should('be.visible'); + + // Verify drawer content + ui.drawer + .find() + .should('be.visible') + .within(() => { + cy.get('[data-testid="drawer-title"]') + .should('be.visible') + .and('have.text', 'Dimension Filters'); + cy.get('[data-qa-id="filter-drawer-subtitle"]') + .should('be.visible') + .and('have.text', dashboard.widgets[0].label); + cy.get('[data-qa-id="filter-drawer-selection-title"]') + .should('be.visible') + .and('have.text', 'Select up to 5 filters.'); + ui.button + .findByTitle('Add Filter') + .should('be.visible') + .and('be.enabled'); + ui.button + .findByAttribute('label', 'Apply') + .should('be.visible') + .and('be.enabled') + .and('not.have.attr', 'aria-disabled', 'true'); + ui.button + .findByAttribute('label', 'Cancel') + .should('be.visible') + .and('be.enabled'); + ui.drawerCloseButton.find().should('be.visible').and('be.enabled'); + cy.get('[data-qa-dimension-filter="dimension_filters.0-id"]').should( + 'not.exist' + ); + }); + + // Define filters to add + const filtersWithOperators = [ + { + dimension: 'entity_id', + operator: 'Not Equal', + valueToSelect: '345', + apiOperator: 'neq', + apiValue: '345', + valuePlaceHolder: 'Enter a Value', + isAutocomplete: false, + }, + { + dimension: 'response_type', + operator: 'Starts with', + valueToSelect: '2x', + apiOperator: 'startswith', + apiValue: '2x', + valuePlaceHolder: 'Enter a Value', + isAutocomplete: false, + }, + { + dimension: 'response_type', + operator: 'Ends with', + valueToSelect: '5xx', + apiOperator: 'endswith', + apiValue: '5xx', + valuePlaceHolder: 'Enter a Value', + isAutocomplete: false, + }, + { + dimension: 'Protocol', + operator: 'In', + valueToSelect: 'Select All', + apiOperator: 'in', + apiValue: 'TCP,UDP', + valuePlaceHolder: 'Select Values', + isAutocomplete: true, + }, + { + dimension: 'linode_id', + operator: 'Equal', + valueToSelect: 'Test-linode-3', + apiOperator: 'eq', + apiValue: '789', + valuePlaceHolder: 'Select a Value', + isAutocomplete: true, + }, + ]; + + // Add filters one by one + filtersWithOperators.forEach( + ( + { + dimension, + operator, + valueToSelect, + valuePlaceHolder, + isAutocomplete, + }, + index + ) => { + // Click Add Filter button + ui.button.findByTitle('Add Filter').click(); + const valueSelector = `[data-qa-dimension-filter="dimension_filters.${index}-value"]`; + + cy.get(`[data-testid="dimension_filters.${index}-id"]`).within(() => { + // Select dimension + ui.autocomplete.findByLabel('Dimension').should('be.visible').click(); + ui.autocomplete.findByLabel('Dimension').type(dimension); + + ui.autocompletePopper + .findByTitle(dimension) + .should('be.visible') + .click(); + + // Select operator + ui.autocomplete + .findByLabel('Operator') + .should('be.visible') + .type(operator); + + ui.autocompletePopper + .findByTitle(operator) + .should('be.visible') + .click(); + + cy.get(valueSelector) + .findByPlaceholderText(valuePlaceHolder) + .should('be.visible'); + cy.get(valueSelector).findByPlaceholderText(valuePlaceHolder).click(); + }); + + // Handle value input based on isAutocomplete flag + isAutocomplete + ? ui.autocompletePopper + .findByTitle(valueToSelect as string) + .should('be.visible') + .click() + : cy + .get(valueSelector) + .findByPlaceholderText(valuePlaceHolder) + .type(valueToSelect as string); + } + ); + + // Verify Add Filter button is disabled after 5 filters + ui.button.findByTitle('Add Filter').should('be.visible').and('be.disabled'); + + // Verify Apply button is enabled and click it + ui.button + .findByAttribute('label', 'Apply') + .should('be.visible') + .and('be.enabled') + .click(); + + // Verify drawer is closed + ui.drawer.find().should('not.exist'); + + // Verify badge count shows 5 + ui.button + .findByAttribute( + 'aria-label', + `Widget Dimension Filter ${dashboard.widgets[0].label}` + ) + .within(() => { + cy.get('[data-qa-badge="dimension-filter-badge-content"]') + .should('be.visible') + .and('have.text', '5'); + }); + + // Validate API response contains all 5 filters + cy.wait('@getMetrics').then((interception) => { + expect(interception) + .to.have.property('response') + .with.property('statusCode', 200); + + expect(interception.request.body.filters).to.have.length(5); + + filtersWithOperators.forEach( + ({ dimension, apiOperator, apiValue }, index) => { + const appliedFilter = interception.request.body.filters[index]; + expect(appliedFilter.dimension_label).to.equal(dimension); + expect(appliedFilter.operator).to.equal(apiOperator); + expect(appliedFilter.value).to.equal(apiValue); + } + ); + }); + }); + + it('should verify edit filter case - add, delete, update filter and validate API response', () => { + // Open the filter drawer + ui.button + .findByAttribute( + 'aria-label', + `Widget Dimension Filter ${dashboard.widgets[0].label}` + ) + .should('be.visible') + .click(); + cy.get('[data-testid="drawer"]').should('be.visible'); + + // Initial filters to add + const initialFilters = [ + { + dimension: 'entity_id', + operator: 'Equal', + valueToSelect: '123', + apiOperator: 'eq', + apiValue: '123', + valuePlaceHolder: 'Enter a Value', + isAutocomplete: false, + }, + { + dimension: 'Protocol', + operator: 'In', + valueToSelect: 'Select All', + apiOperator: 'in', + apiValue: 'TCP,UDP', + valuePlaceHolder: 'Select Values', + isAutocomplete: true, + }, + { + dimension: 'response_type', + operator: 'Starts with', + valueToSelect: '2x', + apiOperator: 'startswith', + apiValue: '2x', + valuePlaceHolder: 'Enter a Value', + isAutocomplete: false, + }, + ]; + + // Add 3 initial filters + initialFilters.forEach( + ( + { + dimension, + operator, + valueToSelect, + valuePlaceHolder, + isAutocomplete, + }, + index + ) => { + ui.button.findByTitle('Add Filter').click(); + const valueSelector = `[data-qa-dimension-filter="dimension_filters.${index}-value"]`; + + cy.get(`[data-testid="dimension_filters.${index}-id"]`).within(() => { + ui.autocomplete + .findByLabel('Dimension') + .should('be.visible') + .click() + .type(dimension); + ui.autocompletePopper + .findByTitle(dimension) + .should('be.visible') + .click(); + + ui.autocomplete + .findByLabel('Operator') + .should('be.visible') + .type(operator); + ui.autocompletePopper + .findByTitle(operator) + .should('be.visible') + .click(); + + cy.get(valueSelector) + .findByPlaceholderText(valuePlaceHolder) + .should('be.visible'); + cy.get(valueSelector).findByPlaceholderText(valuePlaceHolder).click(); + }); + + // Handle value input based on isAutocomplete flag + isAutocomplete + ? ui.autocompletePopper + .findByTitle(valueToSelect as string) + .should('be.visible') + .click() + : cy + .get(valueSelector) + .findByPlaceholderText(valuePlaceHolder) + .type(valueToSelect as string); + } + ); + + // Apply the initial filters + ui.button.findByAttribute('label', 'Apply').click(); + ui.drawer.find().should('not.exist'); + + // Verify badge count shows 3 + ui.button + .findByAttribute( + 'aria-label', + `Widget Dimension Filter ${dashboard.widgets[0].label}` + ) + .within(() => { + cy.get('[data-qa-badge="dimension-filter-badge-content"]') + .should('be.visible') + .and('have.text', '3'); + }); + + // Wait for API call with 3 filters + cy.wait('@getMetrics').then((interception) => { + expect(interception.request.body.filters).to.have.length(3); + }); + + // Re-open drawer to edit filters + ui.button + .findByAttribute( + 'aria-label', + `Widget Dimension Filter ${dashboard.widgets[0].label}` + ) + .should('be.visible') + .click(); + cy.get('[data-testid="drawer"]').should('be.visible'); + + cy.get('[data-testid="dimension_filters.0-id"]').within(() => { + // Update first filter value to '999' + cy.get('[data-qa-dimension-filter="dimension_filters.0-value"]') + .findByPlaceholderText('Enter a Value') + .should('be.visible') + .clear(); + + cy.get('[data-qa-dimension-filter="dimension_filters.0-value"]') + .findByPlaceholderText('Enter a Value') + .type('999'); + }); + + // Update Protocol filter operator from 'Starts with' to 'Ends with' + cy.get('[data-testid="dimension_filters.1-id"]').within(() => { + ui.autocomplete + .findByLabel('Operator') + .should('be.visible') + .clear() + .type('Ends with'); + ui.autocompletePopper + .findByTitle('Ends with') + .should('be.visible') + .click(); + }); + + // Update second filter value to 'UDP' + cy.get('[data-testid="dimension_filters.1-id"]').within(() => { + cy.get('[data-qa-dimension-filter="dimension_filters.1-value"]') + .findByPlaceholderText('Enter a Value') + .should('be.visible') + .clear(); + + cy.get('[data-qa-dimension-filter="dimension_filters.1-value"]') + .findByPlaceholderText('Enter a Value') + .type('UDP'); + }); + + // Delete the third filter (Protocol) + cy.get('[data-testid="dimension_filters.2-id"]').within(() => { + ui.button + .findByAttribute('data-testid', 'clear-icon') + .should('be.visible') + .click(); + }); + + // Add 2 new filters + const newFilters = [ + { + dimension: 'entity_id', + operator: 'Not Equal', + valueToSelect: '456', + apiOperator: 'neq', + apiValue: '456', + valuePlaceHolder: 'Enter a Value', + isAutocomplete: false, + }, + { + dimension: 'linode_id', + operator: 'Equal', + valueToSelect: 'Test-linode-1', + apiOperator: 'eq', + apiValue: '123', + valuePlaceHolder: 'Select a Value', + isAutocomplete: true, + }, + ]; + + newFilters.forEach( + ( + { + dimension, + operator, + valueToSelect, + valuePlaceHolder, + isAutocomplete, + }, + index + ) => { + ui.button.findByTitle('Add Filter').click(); + const valueSelector = `[data-qa-dimension-filter="dimension_filters.${index + 2}-value"]`; + + cy.get(`[data-testid="dimension_filters.${index + 2}-id"]`).within( + () => { + ui.autocomplete + .findByLabel('Dimension') + .should('be.visible') + .click() + .type(dimension); + ui.autocompletePopper + .findByTitle(dimension) + .should('be.visible') + .click(); + + ui.autocomplete + .findByLabel('Operator') + .should('be.visible') + .type(operator); + ui.autocompletePopper + .findByTitle(operator) + .should('be.visible') + .click(); + + cy.get(valueSelector) + .findByPlaceholderText(valuePlaceHolder) + .should('be.visible'); + cy.get(valueSelector) + .findByPlaceholderText(valuePlaceHolder) + .click(); + } + ); + + // Handle value input based on isAutocomplete flag + isAutocomplete + ? ui.autocompletePopper + .findByTitle(valueToSelect as string) + .should('be.visible') + .click() + : cy + .get(valueSelector) + .findByPlaceholderText(valuePlaceHolder) + .type(valueToSelect as string); + } + ); + + // Verify Add Filter button is still enabled (only 4 filters now) + ui.button.findByTitle('Add Filter').should('be.visible').and('be.enabled'); + + // Apply the updated filters + ui.button.findByAttribute('label', 'Apply').click(); + ui.drawer.find().should('not.exist'); + + // Verify badge count shows 4 + ui.button + .findByAttribute( + 'aria-label', + `Widget Dimension Filter ${dashboard.widgets[0].label}` + ) + .within(() => { + cy.get('[data-qa-badge="dimension-filter-badge-content"]') + .should('be.visible') + .and('have.text', '4'); + }); + + // Validate API response contains all 4 updated filters + cy.wait('@getMetrics').then((interception) => { + expect(interception) + .to.have.property('response') + .with.property('statusCode', 200); + + expect(interception.request.body.filters).to.have.length(4); + + const expectedFilters = [ + { dimension: 'entity_id', operator: 'eq', value: '999' }, + { dimension: 'Protocol', operator: 'endswith', value: 'UDP' }, + { dimension: 'entity_id', operator: 'neq', value: '456' }, + { dimension: 'linode_id', operator: 'eq', value: '123' }, + ]; + + expectedFilters.forEach(({ dimension, operator, value }, index) => { + const appliedFilter = interception.request.body.filters[index]; + expect(appliedFilter.dimension_label).to.equal(dimension); + expect(appliedFilter.operator).to.equal(operator); + expect(appliedFilter.value).to.equal(value); + }); + }); + }); +}); 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 f82641e1065..0e274db8967 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -187,8 +187,15 @@ const modifyMaintenanceWindow = (label: string, windowValue: string) => { .should('be.visible') .should('be.disabled'); - ui.autocomplete.findByLabel(label).should('be.visible').type(windowValue); - cy.contains(windowValue).should('be.visible').click(); + // Open the dropdown via shadow DOM + ui.cdsAutoComplete.findByLabel(label, 'input[role="combobox"]').click(); + + // Select the value from the dropdown + ui.cdsAutoComplete + .findByLabel(label, '[role="listbox"]') + .contains(windowValue) + .click(); + ui.cdsButton.findButtonByTitle('Save Changes').then((btn) => { btn[0].click(); // Native DOM click }); @@ -342,6 +349,7 @@ const validateActionItems = (state: string, label: string) => { cy.get('body').click(0, 0); }; +// eslint-disable-next-line sonarjs/no-skipped-tests describe('Update database clusters', () => { beforeEach(() => { mockAppendFeatureFlags({ 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 3c07b7aaffb..5f2b2ef1b3d 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -446,10 +446,13 @@ describe('LKE Cluster Creation with APL enabled', () => { cy.wait('@getRegionAvailability'); - cy.findByTestId('apl-label').should('have.text', 'Akamai App Platform'); - cy.findByTestId('newFeatureChip') - .should('be.visible') - .should('have.text', 'new'); + cy.findByTestId('application-platform-form').within(() => { + cy.findByTestId('apl-label').should('have.text', 'Akamai App Platform'); + cy.findByTestId('newFeatureChip') + .should('be.visible') + .should('have.text', 'new'); + }); + cy.findByTestId('apl-radio-button-yes').should('be.visible').click(); cy.findByTestId('ha-radio-button-yes').should('be.disabled'); cy.get( @@ -1926,7 +1929,7 @@ describe('smoketest for Nvidia Blackwell GPUs in kubernetes/create page', () => ui.tabList.findTabByTitle('GPU').should('be.visible').click(); cy.findByRole('table', { - name: 'List of NVIDIA RTX PRO 6000 Blackwell Server Edition Plans', + name: 'List of Linode Plans', }).within(() => { cy.get('tbody tr') .should('have.length', 4) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts index ef433c36440..93e7ef5fc69 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts @@ -73,11 +73,8 @@ describe('smoketest for Nvidia blackwell GPUs in linodes/create page', () => { cy.get('[data-qa-error="true"]').should('be.visible'); cy.findByRole('table', { - name: 'List of NVIDIA RTX PRO 6000 Blackwell Server Edition Plans', + name: 'List of Linode Plans', }).within(() => { - cy.findByText('NVIDIA RTX PRO 6000 Blackwell Server Edition').should( - 'be.visible' - ); cy.get('tbody tr') .should('have.length', 4) .each((_, index) => { @@ -107,11 +104,8 @@ describe('smoketest for Nvidia blackwell GPUs in linodes/create page', () => { }); cy.findByRole('table', { - name: 'List of NVIDIA RTX PRO 6000 Blackwell Server Edition Plans', + name: 'List of Linode Plans', }).within(() => { - cy.findByText('NVIDIA RTX PRO 6000 Blackwell Server Edition').should( - 'be.visible' - ); cy.get('tbody tr') .should('have.length', 4) .each((_, index) => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts index fd96e194f24..1b4a1c62f3e 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -319,9 +319,14 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Open VLAN accordion and select existing VLAN. - cy.get('[data-qa-select-card-heading="VLAN"]').should('be.visible').click(); - cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + // select existing VLAN. + linodeCreatePage.selectInterface('vlan'); + // Confirm that mocked VLAN is shown in the Autocomplete, and then select it. + cy.get('[data-qa-autocomplete="VLAN"]').within(() => { + cy.findByLabelText('VLAN').should('be.visible'); + cy.get('[data-testid="textfield-input"]').click(); + cy.focused().type(`${mockVlan.label}`); + }); ui.autocompletePopper .findByTitle(mockVlan.label) .should('be.visible') @@ -399,10 +404,14 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { linodeCreatePage.selectLinodeInterfacesType(); // Select VLAN card - linodeCreatePage.selectInterfaceCard('VLAN'); + linodeCreatePage.selectInterface('vlan'); - // Open VLAN accordion and select existing VLAN. - cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + // select existing VLAN. + cy.get('[data-qa-autocomplete="VLAN"]').within(() => { + cy.findByLabelText('VLAN').should('be.visible'); + cy.get('[data-testid="textfield-input"]').click(); + cy.focused().type(`${mockVlan.label}`); + }); ui.autocompletePopper .findByTitle(mockVlan.label) .should('be.visible') @@ -477,10 +486,14 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { assertNewLinodeInterfacesIsAvailable(); // Select VLAN card - linodeCreatePage.selectInterfaceCard('VLAN'); + linodeCreatePage.selectInterface('vlan'); - // Open VLAN accordion and specify new VLAN. - cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + // select new VLAN. + cy.get('[data-qa-autocomplete="VLAN"]').within(() => { + cy.findByLabelText('VLAN').should('be.visible'); + cy.get('[data-testid="textfield-input"]').click(); + cy.focused().type(`${mockVlan.label}`); + }); ui.autocompletePopper .findByTitle(`Create "${mockVlan.label}"`) .should('be.visible') @@ -558,10 +571,14 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { linodeCreatePage.selectLinodeInterfacesType(); // Select VLAN card - linodeCreatePage.selectInterfaceCard('VLAN'); + linodeCreatePage.selectInterface('vlan'); - // Open VLAN accordion and specify new VLAN. - cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + // select new VLAN. + cy.get('[data-qa-autocomplete="VLAN"]').within(() => { + cy.findByLabelText('VLAN').should('be.visible'); + cy.get('[data-testid="textfield-input"]').click(); + cy.focused().type(`${mockVlan.label}`); + }); ui.autocompletePopper .findByTitle(`Create "${mockVlan.label}"`) .should('be.visible') @@ -620,9 +637,9 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { assertNewLinodeInterfacesIsAvailable(); // Select VLAN card - linodeCreatePage.selectInterfaceCard('VLAN'); + linodeCreatePage.selectInterface('vlan'); - // Expand VLAN accordion, confirm VLAN availability notice is displayed and + // confirm VLAN availability notice is displayed and // that VLAN fields are disabled while no region is selected. cy.findByText('VLAN is not available in the selected region.', { exact: false, diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts index ae8f63ed9b4..ea1688c42d8 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -476,8 +476,8 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Select VPC card - linodeCreatePage.selectInterfaceCard('VPC'); + // Select VPC + linodeCreatePage.selectInterface('vpc'); // Confirm that mocked VPC is shown in the Autocomplete, and then select it. cy.get('[data-qa-autocomplete="VPC"]').within(() => { @@ -615,8 +615,8 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { // Switch to Linode Interfaces linodeCreatePage.selectLinodeInterfacesType(); - // Select VPC card - linodeCreatePage.selectInterfaceCard('VPC'); + // Select VPC option + linodeCreatePage.selectInterface('vpc'); // Confirm that mocked VPC is shown in the Autocomplete, and then select it. cy.get('[data-qa-autocomplete="VPC"]').within(() => { @@ -751,7 +751,7 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { assertNewLinodeInterfacesIsAvailable(); // Select VPC card - linodeCreatePage.selectInterfaceCard('VPC'); + linodeCreatePage.selectInterface('vpc'); cy.findByText('Create VPC').should('be.visible').click(); @@ -937,7 +937,7 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { linodeCreatePage.selectLinodeInterfacesType(); // Select VPC card - linodeCreatePage.selectInterfaceCard('VPC'); + linodeCreatePage.selectInterface('vpc'); cy.findByText('Create VPC').should('be.visible').click(); @@ -1068,7 +1068,7 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { assertNewLinodeInterfacesIsAvailable(); // Select VPC card. - linodeCreatePage.selectInterfaceCard('VPC'); + linodeCreatePage.selectInterface('vpc'); // Confirm that VPC selection is disabled. cy.get('[data-qa-autocomplete="VPC"]').within(() => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index aa88a244587..ef7213c5fae 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -30,7 +30,6 @@ import { linodeCreatePage } from 'support/ui/pages'; import { cleanUp } from 'support/util/cleanup'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { skip } from 'support/util/skip'; import { accountFactory, accountUserFactory } from 'src/factories'; @@ -45,6 +44,7 @@ describe('Create Linode', () => { beforeEach(() => { mockAppendFeatureFlags({ linodeInterfaces: { enabled: false }, + iam: { enabled: false }, }); }); @@ -156,81 +156,16 @@ describe('Create Linode', () => { }); /* - * - Confirms Premium Plan Linode can be created end-to-end. - * - Confirms creation flow, that Linode boots, and that UI reflects status. + * - Confirms Premium Plan Tab is disabled in Linodes Create */ - it(`creates a Premium CPU Linode`, () => { - cy.tag('env:premiumPlans'); - - // TODO Allow `chooseRegion` to be configured not to throw. - const linodeRegion = (() => { - try { - return chooseRegion({ - capabilities: ['Linodes', 'Premium Plans', 'Vlans'], - }); - } catch { - skip(); - } - return; - })()!; - - const linodeLabel = randomLabel(); - const planId = 'g7-premium-2'; - const planLabel = 'Premium 4 GB'; - const planType = 'Premium CPU'; - + it(`should feature a disabled Premium Tab in Linodes Create`, () => { interceptGetProfile().as('getProfile'); interceptCreateLinode().as('createLinode'); cy.visitWithLogin('/linodes/create'); - // Set Linode label, OS, plan type, password, etc. - linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 12'); - linodeCreatePage.selectRegionById(linodeRegion.id); - linodeCreatePage.selectPlan(planType, planLabel); - linodeCreatePage.setRootPassword(randomString(32)); - - // Confirm information in summary is shown as expected. - cy.get('[data-qa-linode-create-summary]').scrollIntoView(); - cy.get('[data-qa-linode-create-summary]').within(() => { - cy.findByText('Debian 12').should('be.visible'); - cy.findByText(linodeRegion.label).should('be.visible'); - cy.findByText(planLabel).should('be.visible'); - }); - - // Create Linode and confirm it's provisioned as expected. - ui.button - .findByTitle('Create Linode') + cy.findByRole('tab', { name: 'Premium CPU' }) .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait('@createLinode').then((xhr) => { - const requestPayload = xhr.request.body; - const responsePayload = xhr.response?.body; - - // Confirm that API request and response contain expected data - expect(requestPayload['label']).to.equal(linodeLabel); - expect(requestPayload['region']).to.equal(linodeRegion.id); - expect(requestPayload['type']).to.equal(planId); - - expect(responsePayload['label']).to.equal(linodeLabel); - expect(responsePayload['region']).to.equal(linodeRegion.id); - expect(responsePayload['type']).to.equal(planId); - - // Confirm that Cloud redirects to details page - cy.url().should('endWith', `/linodes/${responsePayload['id']}`); - }); - - cy.wait('@getProfile').then((xhr) => { - username = xhr.response?.body.username; - }); - - // Confirm toast notification should appear on Linode create. - ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); - cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( - 'be.visible' - ); + .should('be.disabled'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index b6c680627cc..bd92c821f9c 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -390,7 +390,7 @@ describe('displays specific linode plans for GPU', () => { }).as('getFeatureFlags'); }); - it('Should render divided tables when GPU divider enabled', () => { + it('Should render GPU plans in Linodes Create', () => { cy.visitWithLogin('/linodes/create'); cy.wait(['@getRegions', '@getLinodeTypes', '@getFeatureFlags']); ui.regionSelect.find().click(); @@ -406,19 +406,11 @@ describe('displays specific linode plans for GPU', () => { cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { - name: 'List of NVIDIA RTX 4000 Ada Plans', + name: 'List of Linode Plans', }).within(() => { - cy.findByText('NVIDIA RTX 4000 Ada').should('be.visible'); - cy.findAllByRole('row').should('have.length', 2); - cy.get('[id="gpu-2"]').should('be.disabled'); - }); - - cy.findByRole('table', { - name: 'List of NVIDIA Quadro RTX 6000 Plans', - }).within(() => { - cy.findByText('NVIDIA Quadro RTX 6000').should('be.visible'); - cy.findAllByRole('row').should('have.length', 2); + cy.findAllByRole('row').should('have.length', 3); cy.get('[id="gpu-1"]').should('be.disabled'); + cy.get('[id="gpu-2"]').should('be.disabled'); }); }); }); @@ -439,7 +431,7 @@ describe('displays specific kubernetes plans for GPU', () => { }).as('getFeatureFlags'); }); - it('Should render divided tables when GPU divider enabled', () => { + it('Should render GPU plans in Kubernetes Create', () => { cy.visitWithLogin('/kubernetes/create'); cy.wait(['@getRegions', '@getLinodeTypes', '@getFeatureFlags']); ui.regionSelect.find().click(); @@ -455,23 +447,15 @@ describe('displays specific kubernetes plans for GPU', () => { cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { - name: 'List of NVIDIA RTX 4000 Ada Plans', + name: 'List of Linode Plans', }).within(() => { - cy.findByText('NVIDIA RTX 4000 Ada').should('be.visible'); - cy.findAllByRole('row').should('have.length', 2); + cy.findAllByRole('row').should('have.length', 3); + cy.get('[data-qa-plan-row="gpu-1"]').should('have.attr', 'disabled'); cy.get('[data-qa-plan-row="gpu-2 Ada"]').should( 'have.attr', 'disabled' ); }); - - cy.findByRole('table', { - name: 'List of NVIDIA Quadro RTX 6000 Plans', - }).within(() => { - cy.findByText('NVIDIA Quadro RTX 6000').should('be.visible'); - cy.findAllByRole('row').should('have.length', 2); - cy.get('[data-qa-plan-row="gpu-1"]').should('have.attr', 'disabled'); - }); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index dbab679f216..86072c549f6 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -106,6 +106,7 @@ const assertPasswordComplexity = ( desiredPassword: string, passwordStrength: 'Fair' | 'Good' | 'Weak' ) => { + cy.findByLabelText('Root Password').scrollIntoView(); cy.findByLabelText('Root Password').should('be.visible').clear(); cy.focused().type(desiredPassword); @@ -165,6 +166,14 @@ describe('rebuild linode', () => { let almaLinuxImageLabel: string = 'AlmaLinux'; const rootPassword = randomString(16); + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + before(() => { cleanUp(['lke-clusters', 'linodes', 'stackscripts', 'images']); @@ -220,6 +229,7 @@ describe('rebuild linode', () => { .click(); // Type to confirm. + cy.findByLabelText('Linode Label').scrollIntoView(); cy.findByLabelText('Linode Label').type(linode.label); // Verify the password complexity functionality. @@ -296,6 +306,8 @@ describe('rebuild linode', () => { .should('be.visible') .click(); + cy.findByLabelText('Linode Label').scrollIntoView(); + cy.findByLabelText('Linode Label') .should('be.visible') .type(linode.label); @@ -372,6 +384,8 @@ describe('rebuild linode', () => { .should('be.visible') .click(); + cy.findByLabelText('Linode Label').scrollIntoView(); + cy.findByLabelText('Linode Label') .should('be.visible') .type(linode.label); @@ -415,6 +429,7 @@ describe('rebuild linode', () => { assertPasswordComplexity(rootPassword, 'Good'); + cy.findByLabelText('Linode Label').scrollIntoView(); cy.findByLabelText('Linode Label').should('be.visible').click(); cy.focused().type(mockLinode.label); @@ -475,6 +490,7 @@ describe('rebuild linode', () => { ).should('be.checked'); // Type to confirm + cy.findByLabelText('Linode Label').scrollIntoView(); cy.findByLabelText('Linode Label').should('be.visible').click(); cy.focused().type(linode.label); @@ -543,7 +559,10 @@ describe('rebuild linode', () => { .click(); // Type to confirm. - cy.findByLabelText('Linode Label').type(linode.label); + cy.findByLabelText('Linode Label').scrollIntoView(); + cy.findByLabelText('Linode Label') + .should('be.visible') + .type(linode.label); assertPasswordComplexity(rootPassword, 'Good'); submitRebuildWithRetry(); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index d02b019ee9a..3cacea66f7f 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -72,6 +72,9 @@ describe('linode landing checks', () => { // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. mockAppendFeatureFlags({ iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }); const mockAccountSettings = accountSettingsFactory.build({ managed: false, diff --git a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts index 6c6cab74d15..0f0108239e9 100644 --- a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts @@ -61,6 +61,9 @@ describe('Host & VM maintenance notification banner', () => { vmHostMaintenance: { enabled: true, }, + iam: { + enabled: false, + }, }).as('getFeatureFlags'); mockGetLinodes(mockLinodes).as('getLinodes'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 02e3d30623b..708b19561b8 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -51,6 +51,14 @@ const objNotes = { }; describe('Object Storage enrollment', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Confirms that Object Storage can be enabled using mock API data. * - Confirms that pricing information link is present in enrollment dialog. diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage-errors.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage-errors.spec.ts index c0089040708..fb2d73e4b69 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage-errors.spec.ts @@ -5,11 +5,14 @@ import 'cypress-file-upload'; import { mockGetBucketObjects, + mockGetBuckets, mockUploadBucketObject, } from 'support/intercepts/object-storage'; import { makeError } from 'support/util/errors'; import { randomItem, randomLabel, randomString } from 'support/util/random'; +import { objectStorageBucketFactory } from 'src/factories'; + describe('object storage failure paths', () => { /* * - Tests error UI when an object upload fails. @@ -19,6 +22,12 @@ describe('object storage failure paths', () => { it('shows error upon object upload failure', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + label: bucketLabel, + objects: 0, + }); const bucketFile = randomItem([ 'object-storage-files/1.txt', 'object-storage-files/2.jpg', @@ -29,6 +38,7 @@ describe('object storage failure paths', () => { const bucketFilename = bucketFile.split('/')[1]; // Mock empty object list and failed object-url upload request. + mockGetBuckets([bucketMock]).as('getBuckets'); mockGetBucketObjects(bucketLabel, bucketCluster, []).as('getBucketObjects'); mockUploadBucketObject( bucketLabel, @@ -73,7 +83,14 @@ describe('object storage failure paths', () => { it('shows error upon object list retrieval failure', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + label: bucketLabel, + objects: 0, + }); + mockGetBuckets([bucketMock]).as('getBuckets'); mockGetBucketObjects( bucketLabel, bucketCluster, diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 99b2bee66dc..9d36a170faf 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -88,6 +88,13 @@ describe('object storage smoke tests', () => { it('can upload, view, and delete bucket objects - smoke', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + label: bucketLabel, + objects: 0, + }); + const bucketContents = [ 'object-storage-files/1.txt', 'object-storage-files/2.jpg', @@ -95,11 +102,13 @@ describe('object storage smoke tests', () => { 'object-storage-files/4.zip', ]; + mockGetBuckets([bucketMock]).as('getBuckets'); mockGetBucketObjects(bucketLabel, bucketCluster, []).as('getBucketObjects'); cy.visitWithLogin( `/object-storage/buckets/${bucketCluster}/${bucketLabel}` ); + cy.wait('@getBuckets'); cy.wait('@getBucketObjects'); cy.log('Upload bucket objects'); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts deleted file mode 100644 index 137f3458ead..00000000000 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { regionFactory } from '@linode/utilities'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { - mockGetBucket, - mockGetBucketObjects, - mockGetBuckets, -} from 'support/intercepts/object-storage'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { ui } from 'support/ui'; -import { randomLabel } from 'support/util/random'; - -import { accountFactory, objectStorageBucketFactory } from 'src/factories'; - -describe('Object Storage Multicluster Bucket Details Tabs', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: false }, - }).as('getFeatureFlags'); - mockGetAccount( - accountFactory.build({ - capabilities: ['Object Storage', 'Object Storage Access Key Regions'], - }) - ).as('getAccount'); - }); - - const mockRegion = regionFactory.build({ - capabilities: ['Object Storage'], - }); - - const mockBucket = objectStorageBucketFactory.build({ - label: randomLabel(), - region: mockRegion.id, - }); - - describe('Properties tab without required capabilities', () => { - /* - * - Confirms that Gen 2-specific "Properties" tab is absent when OBJ Multicluster is enabled. - */ - it(`confirms the Properties tab does not exist for users without 'Object Storage Endpoint Types' capability`, () => { - const { label } = mockBucket; - - mockGetBucket(label, mockRegion.id); - mockGetBuckets([mockBucket]); - mockGetBucketObjects(label, mockRegion.id, []); - mockGetRegions([mockRegion]); - - cy.visitWithLogin( - `/object-storage/buckets/${mockRegion.id}/${label}/properties` - ); - - cy.wait(['@getFeatureFlags', '@getAccount']); - - // Confirm that expected tabs are visible. - ui.tabList.findTabByTitle('Objects').should('be.visible'); - ui.tabList.findTabByTitle('Access').should('be.visible'); - ui.tabList.findTabByTitle('SSL/TLS').should('be.visible'); - - // Confirm that "Properties" tab is absent. - cy.findByText('Properties').should('not.exist'); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts index 11792d1ee35..d7fca561e0e 100644 --- a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts @@ -106,7 +106,6 @@ describe('volume delete flow', () => { // Confirm that volume is deleted. cy.wait('@deleteVolume').its('response.statusCode').should('eq', 200); cy.findByText(volume.label).should('not.exist'); - ui.toast.assertMessage(`Volume ${volume.label} has been deleted.`); } ); }); diff --git a/packages/manager/cypress/support/constants/delivery.ts b/packages/manager/cypress/support/constants/delivery.ts index a45d0f343eb..290974a6a2f 100644 --- a/packages/manager/cypress/support/constants/delivery.ts +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -14,8 +14,8 @@ export const mockDestinationPayload: CreateDestinationPayload = { label: randomLabel(), type: destinationType.AkamaiObjectStorage, details: { - host: randomString(), - bucket_name: randomLabel(), + host: 'test-bucket-name.host.com', + bucket_name: 'test-bucket-name', access_key_id: randomString(), access_key_secret: randomString(), path: '/', diff --git a/packages/manager/cypress/support/constants/linode-interfaces.ts b/packages/manager/cypress/support/constants/linode-interfaces.ts index b734307f030..d78f38c6ed9 100644 --- a/packages/manager/cypress/support/constants/linode-interfaces.ts +++ b/packages/manager/cypress/support/constants/linode-interfaces.ts @@ -44,7 +44,7 @@ export const linodeInterfacesLabelText = 'Linode Interfaces'; export const betaLabelText = 'beta'; export const linodeInterfacesDescriptionText1 = - 'Linode Interfaces are the preferred option for VPCs and are managed directly through a Linode’s Network settings.'; + "Managed directly through a Linode's Network settings. This is the recommended option."; export const linodeInterfacesDescriptionText2 = 'Cloud Firewalls are assigned to individual VPC and public interfaces.'; @@ -53,7 +53,7 @@ export const legacyInterfacesLabelText = 'Configuration Profile Interfaces (Legacy)'; export const legacyInterfacesDescriptionText1 = - 'Interfaces in the Configuration Profile are part of a Linode’s configuration.'; + 'Interfaces are part of the Linode’s Configuration Profile.'; export const legacyInterfacesDescriptionText2 = 'Cloud Firewalls are applied at the Linode level and automatically cover all non-VLAN interfaces in the Configuration Profile.'; diff --git a/packages/manager/cypress/support/constants/widgets.ts b/packages/manager/cypress/support/constants/widgets.ts index 9eaf74ff6bd..7f880b337e2 100644 --- a/packages/manager/cypress/support/constants/widgets.ts +++ b/packages/manager/cypress/support/constants/widgets.ts @@ -205,4 +205,126 @@ export const widgetDetails = { serviceType: 'firewall', region: 'Newark', }, + blockstorage: { + dashboardName: 'Block Storage Dashboard', + id: 7, + metrics: [ + { + expectedAggregation: 'max', + expectedAggregationArray: ['avg', 'max', 'min'], + expectedGranularity: '1 hr', + name: 'volume_read_ops', + title: 'Volume Read Operations', + unit: 'Count', + yLabel: 'volume_read_ops', + filters: [ + { dimension_label: 'linode_id', operator: 'eq', value: null }, + { dimension_label: 'entity_id', operator: 'eq', value: null }, + { dimension_label: 'response_type', operator: 'eq', value: null }, + { + dimension_label: 'Protocol', + operator: 'eq', + value: ['TCP', 'UDP'], + }, + ], + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['avg', 'max', 'min'], + expectedGranularity: '1 hr', + name: 'volume_write_ops', + title: 'Volume Write Operations', + unit: 'Count', + yLabel: 'volume_write_ops', + filters: [ + { dimension_label: 'linode_id', operator: 'eq', value: null }, + { dimension_label: 'entity_id', operator: 'eq', value: null }, + { dimension_label: 'response_type', operator: 'eq', value: null }, + { + dimension_label: 'Protocol', + operator: 'eq', + value: ['TCP', 'UDP'], + }, + ], + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['avg', 'max', 'min'], + expectedGranularity: '1 hr', + name: 'volume_read_bytes', + title: 'Volume Read Bytes', + unit: 'B', + yLabel: 'volume_read_bytes', + filters: [ + { dimension_label: 'linode_id', operator: 'eq', value: null }, + { dimension_label: 'entity_id', operator: 'eq', value: null }, + { dimension_label: 'response_type', operator: 'eq', value: null }, + { + dimension_label: 'Protocol', + operator: 'eq', + value: ['TCP', 'UDP'], + }, + ], + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['avg', 'max', 'min'], + expectedGranularity: '1 hr', + name: 'volume_write_bytes', + title: 'Volume Write Bytes', + unit: 'B', + yLabel: 'volume_write_bytes', + filters: [ + { dimension_label: 'linode_id', operator: 'eq', value: null }, + { dimension_label: 'entity_id', operator: 'eq', value: null }, + { dimension_label: 'response_type', operator: 'eq', value: null }, + { + dimension_label: 'Protocol', + operator: 'eq', + value: ['TCP', 'UDP'], + }, + ], + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['avg', 'max', 'min'], + expectedGranularity: '1 hr', + name: 'volume_read_latency', + title: 'Volume Read Latency', + unit: 'ms', + yLabel: 'volume_read_latency', + filters: [ + { dimension_label: 'linode_id', operator: 'eq', value: null }, + { dimension_label: 'percentile', operator: 'eq', value: null }, + { dimension_label: 'entity_id', operator: 'eq', value: null }, + { + dimension_label: 'Protocol', + operator: 'eq', + value: ['TCP', 'UDP'], + }, + ], + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['avg', 'max', 'min'], + expectedGranularity: '1 hr', + name: 'volume_write_latency', + title: 'Volume Write Latency', + unit: 'ms', + yLabel: 'volume_write_latency', + filters: [ + { dimension_label: 'linode_id', operator: 'eq', value: null }, + { dimension_label: 'percentile', operator: 'eq', value: null }, + { dimension_label: 'entity_id', operator: 'eq', value: null }, + { + dimension_label: 'Protocol', + operator: 'eq', + value: ['TCP', 'UDP'], + }, + ], + }, + ], + region: 'Chicago', + serviceType: 'blockstorage', + }, }; diff --git a/packages/manager/cypress/support/ui/autocomplete.ts b/packages/manager/cypress/support/ui/autocomplete.ts index 0b84262dd13..7313754d48c 100644 --- a/packages/manager/cypress/support/ui/autocomplete.ts +++ b/packages/manager/cypress/support/ui/autocomplete.ts @@ -43,6 +43,24 @@ export const autocomplete = { }, }; +export const cdsAutoComplete = { + /** + * Finds a cds select component within shadow DOM by its title and returns the Cypress chainable. + * + * @param cdsSelectLabel - Title of cds button to find + * @param role - Role of the element to find within the shadow DOM (e.g., 'combobox', 'listbox') + * + * @returns Cypress chainable. + */ + findByLabel: (label: string, role: string): Cypress.Chainable => { + return cy + .get(`[data-qa-autocomplete="${label}"] cds-select`) + .shadow() + .find(`${role}`) + .should('be.visible'); + }, +}; + /** * Autocomplete Popper UI element. * diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts index 2d4f986e197..c272642c6e4 100644 --- a/packages/manager/cypress/support/ui/pages/linode-create-page.ts +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -130,7 +130,7 @@ export const linodeCreatePage = { * Select the Linode Interfaces Type. */ selectLinodeInterfacesType: () => { - cy.findByText('Linode Interfaces').click(); + cy.get('[data-qa-interfaces-option="linode"]').click(); }, /** @@ -141,13 +141,11 @@ export const linodeCreatePage = { }, /** - * Select the interfaces' card. + * Select the interfaces' type. * - * @param title - Interfaces' card title to select. + * @param type - Interfaces' type title to select. */ - selectInterfaceCard: (title: string) => { - cy.get(`[data-qa-select-card-heading="${title}"]`) - .should('be.visible') - .click(); + selectInterface: (type: 'public' | 'vlan' | 'vpc') => { + cy.get(`[data-qa-interface-type-option="${type}"]`).click(); }, }; diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 98ee8d6af74..ba990a3c94d 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -5,13 +5,7 @@ import { findOrCreateDependencyVlan } from 'support/api/vlans'; import { pageSize } from 'support/constants/api'; import { dryRunButtonText, - legacyInterfacesDescriptionText1, - legacyInterfacesDescriptionText2, legacyInterfacesLabelText, - linodeInterfacesDescriptionText1, - linodeInterfacesDescriptionText2, - linodeInterfacesLabelText, - networkConnectionDescriptionText, networkConnectionSectionText, networkInterfaceTypeSectionText, promptDialogDescription1, @@ -330,14 +324,9 @@ export const assertNewLinodeInterfacesIsAvailable = ( ): void => { const expectedBehavior = linodeInterfacesEnabled ? 'be.visible' : 'not.exist'; cy.findByText(networkInterfaceTypeSectionText).should(expectedBehavior); - cy.findByText(linodeInterfacesLabelText).should(expectedBehavior); - cy.findByText(linodeInterfacesDescriptionText1).should(expectedBehavior); - cy.findByText(linodeInterfacesDescriptionText2).should(expectedBehavior); + cy.get('[data-qa-interfaces-option="linode"]').should(expectedBehavior); cy.findByText(legacyInterfacesLabelText).should(expectedBehavior); - cy.findByText(legacyInterfacesDescriptionText1).should(expectedBehavior); - cy.findByText(legacyInterfacesDescriptionText2).should(expectedBehavior); cy.findByText(networkConnectionSectionText).should(expectedBehavior); - cy.findByText(networkConnectionDescriptionText).should(expectedBehavior); }; /** diff --git a/packages/manager/package.json b/packages/manager/package.json index 57a7a6f5844..3c1bd6e0a91 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.156.1", + "version": "1.157.0", "private": true, "type": "module", "bugs": { @@ -45,7 +45,7 @@ "@tanstack/react-query-devtools": "5.51.24", "@tanstack/react-router": "^1.111.11", "@xterm/xterm": "^5.5.0", - "akamai-cds-react-components": "0.0.1-alpha.18", + "akamai-cds-react-components": "0.0.1-alpha.19", "algoliasearch": "^4.14.3", "axios": "~1.12.0", "braintree-web": "^3.92.2", @@ -124,9 +124,9 @@ }, "devDependencies": { "@4tw/cypress-drag-drop": "^2.3.0", - "@storybook/addon-a11y": "^9.0.12", - "@storybook/addon-docs": "^9.0.12", - "@storybook/react-vite": "^9.0.12", + "@storybook/addon-a11y": "^9.1.17", + "@storybook/addon-docs": "^9.1.17", + "@storybook/react-vite": "^9.1.17", "@swc/core": "^1.10.9", "@testing-library/cypress": "^10.1.0", "@testing-library/dom": "^10.1.0", @@ -179,7 +179,7 @@ "msw": "^2.2.3", "pdfreader": "^3.0.7", "redux-mock-store": "^1.5.3", - "storybook": "^9.0.12", + "storybook": "^9.1.17", "vite": "^7.2.2", "vite-plugin-svgr": "^4.5.0" }, diff --git a/packages/manager/public/assets/elasticstack.svg b/packages/manager/public/assets/elasticstack.svg new file mode 100644 index 00000000000..935abb4ec00 --- /dev/null +++ b/packages/manager/public/assets/elasticstack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/weaviate.svg b/packages/manager/public/assets/weaviate.svg new file mode 100644 index 00000000000..10477e24d80 --- /dev/null +++ b/packages/manager/public/assets/weaviate.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/white/elasticstack.svg b/packages/manager/public/assets/white/elasticstack.svg new file mode 100644 index 00000000000..82b44e0d3e2 --- /dev/null +++ b/packages/manager/public/assets/white/elasticstack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/weaviate.svg b/packages/manager/public/assets/white/weaviate.svg new file mode 100644 index 00000000000..16b5f807bb0 --- /dev/null +++ b/packages/manager/public/assets/white/weaviate.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index e64ef845be7..93a0bddf0b7 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { usePermissions } from './features/IAM/hooks/usePermissions'; +import { useIsMarketplaceV2Enabled } from './features/Marketplace/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useFlags } from './hooks/useFlags'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; @@ -24,6 +25,8 @@ export const GoTo = React.memo(() => { const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); + const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); + const { goToOpen, setGoToOpen } = useGlobalKeyboardListener(); const onClose = () => { @@ -99,9 +102,10 @@ export const GoTo = React.memo(() => { display: 'Longview', href: '/longview', }, - { - display: 'Marketplace', + display: !isMarketplaceV2FeatureEnabled + ? 'Marketplace' + : 'Quick Deploy Apps', href: '/linodes/create/marketplace', }, ...(iamRbacPrimaryNavChanges @@ -133,6 +137,7 @@ export const GoTo = React.memo(() => { permissions.is_account_admin, isDatabasesEnabled, isManagedAccount, + isMarketplaceV2FeatureEnabled, isPlacementGroupsEnabled, iamRbacPrimaryNavChanges, ] diff --git a/packages/manager/src/components/AreaChart/AreaChart.tsx b/packages/manager/src/components/AreaChart/AreaChart.tsx index 05862b61aea..a37fdbb1632 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.tsx @@ -134,6 +134,11 @@ export interface AreaChartProps { */ timezone: string; + /** + * formatter for the tooltip value + */ + tooltipCustomValueFormatter?: (value: number, unit: string) => string; + /** * unit to be displayed with data */ @@ -189,6 +194,7 @@ export const AreaChart = (props: AreaChartProps) => { xAxis, xAxisTickCount, yAxisProps, + tooltipCustomValueFormatter, } = props; const theme = useTheme(); @@ -227,7 +233,9 @@ export const AreaChart = (props: AreaChartProps) => { {item.dataKey} - {tooltipValueFormatter(item.value, unit)} + {tooltipCustomValueFormatter + ? tooltipCustomValueFormatter(item.value, unit) + : tooltipValueFormatter(item.value, unit)} ))} diff --git a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx index 6d57f5f21ca..6db536e4098 100644 --- a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx +++ b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx @@ -34,14 +34,13 @@ export interface CopyTooltipProps { * @default false */ masked?: boolean; - /** - * Callback to be executed when the icon is clicked. - */ - /** * Optionally specifies the length of the masked text to depending on data type (e.g. 'ipv4', 'ipv6', 'plaintext'); if not provided, will use a default length. */ maskedTextLength?: MaskableTextLength | number; + /** + * Callback to be executed when the icon is clicked. + */ onClickCallback?: () => void; /** * The placement of the tooltip. diff --git a/packages/manager/src/components/Markdown/Markdown.tsx b/packages/manager/src/components/Markdown/Markdown.tsx index 749711532e1..aa50332d8a6 100644 --- a/packages/manager/src/components/Markdown/Markdown.tsx +++ b/packages/manager/src/components/Markdown/Markdown.tsx @@ -43,6 +43,7 @@ export const Markdown = (props: HighlightedMarkdownProps) => { }); } }, + breaks: true, html: true, linkify: true, }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 774bcdc49c1..7c078ccc248 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -228,6 +228,7 @@ describe('PrimaryNav', () => { aclp: { beta: true, enabled: true, + new: true, }, aclpAlerting: { accountAlertLimit: 10, @@ -239,7 +240,7 @@ describe('PrimaryNav', () => { }, }; - const { findAllByTestId, findByText } = renderWithTheme( + const { findAllByTestId, findByText, queryByTestId } = renderWithTheme( , { flags, @@ -249,12 +250,57 @@ describe('PrimaryNav', () => { const monitorMetricsDisplayItem = await findByText('Metrics'); const monitorAlertsDisplayItem = await findByText('Alerts'); const betaChip = await findAllByTestId('betaChip'); + const newFeatureChip = queryByTestId('newFeatureChip'); + expect(newFeatureChip).toBeNull(); // when beta is true, only beta chip is shown not new chip expect(monitorMetricsDisplayItem).toBeVisible(); expect(monitorAlertsDisplayItem).toBeVisible(); expect(betaChip).toHaveLength(2); }); + it('shoud show beta chip next to Metrics menu item if the user has the account capability and aclp feature flag has new true', async () => { + const account = accountFactory.build({ + capabilities: ['Akamai Cloud Pulse'], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + const flags = { + aclp: { + beta: false, + enabled: true, + new: true, + }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + notificationChannels: false, + recentActivity: false, + }, + }; + + const { findByText, findByTestId } = renderWithTheme( + , + { + flags, + } + ); + + const monitorMetricsDisplayItem = await findByText('Metrics'); + const monitorAlertsDisplayItem = await findByText('Alerts'); + const newFeatureChip = await findByTestId('newFeatureChip'); + + expect(monitorMetricsDisplayItem).toBeVisible(); + expect(monitorAlertsDisplayItem).toBeVisible(); + expect(newFeatureChip).toBeVisible(); + }); + it('should not show Metrics and Alerts menu items if the user has the account capability but the aclp feature flag is not enabled', async () => { const account = accountFactory.build({ capabilities: ['Akamai Cloud Pulse'], @@ -584,10 +630,26 @@ describe('PrimaryNav', () => { flags, }); - const databaseNavItem = await findByTestId( + const networkLoadbalancerNavItem = await findByTestId( 'menu-item-Network Load Balancer' ); - expect(databaseNavItem).toBeVisible(); + expect(networkLoadbalancerNavItem).toBeVisible(); + }); + + it('should show Partner Referral menu item if the user has the account capability and the flag is enabled', async () => { + const flags: Partial = { + marketplaceV2: true, + }; + + const { findByTestId } = renderWithTheme(, { + flags, + }); + + const partnerReferralNavItem = await findByTestId( + 'menu-item-Partner Referrals' + ); + + expect(partnerReferralNavItem).toBeVisible(); }); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index d42cfe40962..1a4d0499baa 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -22,6 +22,7 @@ import { useIsACLPEnabled } from 'src/features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils'; import { useIsIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; +import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/utils'; import { useIsNetworkLoadBalancerEnabled } from 'src/features/NetworkLoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -54,13 +55,15 @@ export type NavEntity = | 'Longview' | 'Maintenance' | 'Managed' - | 'Marketplace' + | 'Marketplace' // TODO: Cloud Manager Marketplace - Remove marketplace references once 'Quick Deploy Apps' is fully rolled out | 'Metrics' | 'Monitor' | 'Network Load Balancer' | 'NodeBalancers' | 'Object Storage' + | 'Partner Referrals' | 'Placement Groups' + | 'Quick Deploy Apps' | 'Quotas' | 'Service Transfers' | 'StackScripts' @@ -121,6 +124,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isNetworkLoadBalancerEnabled } = useIsNetworkLoadBalancerEnabled(); + const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); + const { data: preferences, error: preferencesError, @@ -176,9 +181,21 @@ export const PrimaryNav = (props: PrimaryNavProps) => { }, { attr: { 'data-qa-one-click-nav-btn': true }, - display: 'Marketplace', + display: !isMarketplaceV2FeatureEnabled + ? 'Marketplace' + : 'Quick Deploy Apps', to: '/linodes/create/marketplace', }, + { + attr: { + 'data-qa-one-click-nav-btn': true, + 'data-pendo-id': 'menu-item-Cloud Marketplace', + }, + display: 'Partner Referrals', + hide: !isMarketplaceV2FeatureEnabled, + isBeta: isMarketplaceV2FeatureEnabled, + to: '/cloud-marketplace/catalog', + }, ], name: 'Compute', }, @@ -243,6 +260,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !isACLPEnabled, to: '/metrics', isBeta: flags.aclp?.beta, + isNew: !flags.aclp?.beta && flags.aclp?.new, }, { display: 'Alerts', @@ -352,6 +370,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isIAMBeta, isIAMEnabled, iamRbacPrimaryNavChanges, + isMarketplaceV2FeatureEnabled, isNetworkLoadBalancerEnabled, limitsEvolution, ] diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx index 9a4c36b1985..fc3f9e1337f 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx @@ -27,6 +27,7 @@ interface EntityInfo { | 'Alert' | 'Bucket' | 'Database' + | 'Database Connection Pool' | 'Domain' | 'Image' | 'Kubernetes' diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index e017a81f19c..a3b79b4945d 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -40,6 +40,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'linodeInterfaces', label: 'Linode Interfaces' }, { flag: 'lkeEnterprise2', label: 'LKE-Enterprise' }, + { flag: 'marketplaceV2', label: 'MarketplaceV2' }, { flag: 'networkLoadBalancer', label: 'Network Load Balancer' }, { flag: 'nodebalancerIpv6', label: 'NodeBalancer Dual Stack (IPv6)' }, { flag: 'nodebalancerVpc', label: 'NodeBalancer-VPC Integration' }, diff --git a/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx b/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx index 6ac26ce24fb..8982d6ea2db 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx @@ -287,6 +287,65 @@ const eventTemplates = { status: 'finished', }), + 'Linode Migration In Progress': () => + eventFactory.build({ + action: 'linode_migrate', + entity: { + id: 1, + label: 'linode-1', + type: 'linode', + url: '/v4/linode/instances/1', + }, + message: 'Linode migration in progress.', + percent_complete: 45, + status: 'started', + }), + + 'Linode Migration - Emergency': () => + eventFactory.build({ + action: 'linode_migrate', + description: 'emergency', + entity: { + id: 1, + label: 'linode-1', + type: 'linode', + url: '/v4/linode/instances/1', + }, + message: 'Emergency linode migration in progress.', + percent_complete: 30, + status: 'started', + }), + + 'Linode Migration - Scheduled Started': () => + eventFactory.build({ + action: 'linode_migrate', + description: 'scheduled', + entity: { + id: 1, + label: 'linode-1', + type: 'linode', + url: '/v4/linode/instances/1', + }, + message: 'Scheduled linode migration in progress.', + percent_complete: 10, + status: 'started', + }), + + 'Linode Migration - Scheduled': () => + eventFactory.build({ + action: 'linode_migrate', + description: 'scheduled', + entity: { + id: 1, + label: 'linode-1', + type: 'linode', + url: '/v4/linode/instances/1', + }, + message: 'Scheduled linode migration in progress.', + percent_complete: 0, + status: 'scheduled', + }), + 'Completed Event': () => eventFactory.build({ action: 'account_update', diff --git a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx index c257d22db49..d1a4eae094b 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx @@ -210,7 +210,42 @@ const maintenanceTemplates = { Canceled: () => accountMaintenanceFactory.build({ status: 'canceled' }), Completed: () => accountMaintenanceFactory.build({ status: 'completed' }), 'In Progress': () => - accountMaintenanceFactory.build({ status: 'in_progress' }), + accountMaintenanceFactory.build({ + status: 'in_progress', + entity: { + type: 'linode', + id: 1, + label: 'linode-1', + url: '/v4/linode/instances/1', + }, + type: 'migrate', + }), + 'In Progress - Emergency Migration': () => + accountMaintenanceFactory.build({ + status: 'in_progress', + entity: { + type: 'linode', + id: 1, + label: 'linode-1', + url: '/v4/linode/instances/1', + }, + type: 'migrate', + description: 'emergency', + reason: 'Emergency maintenance migration', + }), + 'In Progress - Scheduled Migration': () => + accountMaintenanceFactory.build({ + status: 'in_progress', + entity: { + type: 'linode', + id: 1, + label: 'linode-1', + url: '/v4/linode/instances/1', + }, + type: 'migrate', + description: 'scheduled', + reason: 'Scheduled maintenance migration', + }), Pending: () => accountMaintenanceFactory.build({ status: 'pending' }), Scheduled: () => accountMaintenanceFactory.build({ status: 'scheduled' }), Started: () => accountMaintenanceFactory.build({ status: 'started' }), diff --git a/packages/manager/src/dev-tools/components/JsonTextArea.tsx b/packages/manager/src/dev-tools/components/JsonTextArea.tsx index 6eb65224f9f..1bc713bb65e 100644 --- a/packages/manager/src/dev-tools/components/JsonTextArea.tsx +++ b/packages/manager/src/dev-tools/components/JsonTextArea.tsx @@ -24,6 +24,23 @@ export const JsonTextArea = ({ const debouncedUpdate = React.useMemo( () => debounce((text: string) => { + // Handle empty/whitespace text as null + if (!text.trim()) { + const event = { + currentTarget: { + name, + value: null, + }, + target: { + name, + value: null, + }, + } as unknown as React.ChangeEvent; + + onChange(event); + return; + } + try { const parsedJson = JSON.parse(text); const event = { @@ -35,7 +52,7 @@ export const JsonTextArea = ({ name, value: parsedJson, }, - } as React.ChangeEvent; + } as unknown as React.ChangeEvent; onChange(event); } catch (err) { diff --git a/packages/manager/src/factories/cloudpulse/channels.ts b/packages/manager/src/factories/cloudpulse/channels.ts index 9c96cd36e57..ee361063bb0 100644 --- a/packages/manager/src/factories/cloudpulse/channels.ts +++ b/packages/manager/src/factories/cloudpulse/channels.ts @@ -4,14 +4,11 @@ import type { NotificationChannel } from '@linode/api-v4'; export const notificationChannelFactory = Factory.Sync.makeFactory({ - alerts: [ - { - id: Number(Factory.each((i) => i)), - label: String(Factory.each((id) => `Alert-${id}`)), - type: 'alerts-definitions', - url: 'Sample', - }, - ], + alerts: { + type: 'alerts-definitions', + alert_count: 1, + url: 'monitor/alert-channels/{id}/alerts', + }, channel_type: 'email', content: { email: { @@ -20,12 +17,12 @@ export const notificationChannelFactory = subject: 'Sample Alert', }, }, - created_at: new Date().toISOString(), + created: new Date().toISOString(), created_by: 'user1', id: Factory.each((i) => i), label: Factory.each((id) => `Channel-${id}`), status: 'Enabled', - type: 'custom', - updated_at: new Date().toISOString(), + type: 'user', + updated: new Date().toISOString(), updated_by: 'user1', }); diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index ac10c20c467..438422d0905 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -292,7 +292,7 @@ export const databaseConnectionPoolFactory = Factory.Sync.makeFactory({ database: 'defaultdb', mode: 'transaction', - label: Factory.each((i) => `pool/${i}`), + label: Factory.each((i) => `test-pool-${i}`), size: 10, username: null, }); diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts index 94c422631cd..c3c19a3f472 100644 --- a/packages/manager/src/factories/delivery.ts +++ b/packages/manager/src/factories/delivery.ts @@ -7,7 +7,7 @@ export const destinationFactory = Factory.Sync.makeFactory({ details: { access_key_id: 'Access Id', bucket_name: 'destinations-bucket-name', - host: '3000', + host: 'destinations-bucket-name.host.com', path: 'file', }, id: Factory.each((id) => id), diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index d3a66e9c6ff..8688b363d69 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -17,14 +17,14 @@ export const productInformationBannerFactory = }); export const flagsFactory = Factory.Sync.makeFactory>({ - aclp: { beta: true, enabled: true }, + aclp: { beta: true, enabled: true, showWidgetDimensionFilters: true }, aclpAlerting: { accountAlertLimit: 10, accountMetricLimit: 10, alertDefinitions: true, beta: true, recentActivity: false, - notificationChannels: false, + notificationChannels: true, editDisabledStatuses: [ 'in progress', 'failed', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 3576eb95e94..c0bfac6e2fa 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -99,6 +99,16 @@ interface AclpFlag { */ enabled: boolean; + /** + * This property indicates for which unit, we need to humanize the values e.g., count, iops etc., + */ + humanizableUnits?: string[]; + + /** + * This property indicates whether the feature is new or not + */ + new?: boolean; + /** * This property indicates whether to show widget dimension filters or not */ @@ -151,8 +161,10 @@ interface AclpAlerting { alertDefinitions: boolean; beta: boolean; editDisabledStatuses?: AlertStatusType[]; + maxEmailChannelRecipients?: number; notificationChannels: boolean; recentActivity: boolean; + systemChannelSupportedServices?: CloudPulseServiceType[]; // linode, dbaas, etc. } interface LimitsEvolution { @@ -224,6 +236,7 @@ export interface Flags { lkeEnterprise2: LkeEnterpriseFlag; mainContentBanner: MainContentBanner; marketplaceAppOverrides: MarketplaceAppOverride[]; + marketplaceV2: boolean; metadata: boolean; mtc: MTC; networkLoadBalancer: boolean; @@ -344,12 +357,12 @@ export type ProductInformationBannerLocation = | 'Identity and Access' | 'Images' | 'Kubernetes' - | 'LinodeCreate' // Use for Marketplace banners | 'Linodes' | 'LoadBalancers' | 'Logs' | 'Longview' | 'Managed' + | 'Marketplace' | 'Network LoadBalancers' | 'NodeBalancers' | 'Object Storage' diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index 9645722c188..3fc62a6fb2c 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -47,6 +47,8 @@ const statusIconMap: Record = { scheduled: 'active', }; +const MAX_REASON_DISPLAY_LENGTH = 93; + interface MaintenanceTableRowProps { maintenance: AccountMaintenance; tableType: MaintenanceTableType; @@ -74,9 +76,11 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { const eventProgress = recentEvent && formatProgressEvent(recentEvent); - const truncatedReason = truncate(reason, 93); + const truncatedReason = reason + ? truncate(reason, MAX_REASON_DISPLAY_LENGTH) + : ''; - const isTruncated = reason !== truncatedReason; + const isTruncated = reason ? reason !== truncatedReason : false; const dateField = getMaintenanceDateField(tableType); const dateValue = props.maintenance[dateField]; diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index bb1c7cbe883..f202f33691d 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -40,7 +40,7 @@ export const maintenanceDateColumnMap: Record< > = { completed: ['complete_time', 'End Date'], 'in progress': ['start_time', 'Start Date'], - upcoming: ['start_time', 'Start Date'], + upcoming: ['when', 'Start Date'], pending: ['when', 'Date'], }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index a920ed1a2a3..a24da1b8cba 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -165,6 +165,7 @@ export const CreateAlertDefinition = () => { }); resetField('scope', { defaultValue: null }); resetField('entity_type', { defaultValue: 'linode' }); + resetField('channel_ids', { defaultValue: [] }); }, [resetField]); const handleEntityTypeChange = React.useCallback(() => { @@ -256,7 +257,10 @@ export const CreateAlertDefinition = () => { serviceMetadataError={serviceMetadataError} serviceMetadataLoading={serviceMetadataLoading} /> - + { it('should render the notification channels ', () => { const emailAddresses = mockNotificationData[0].channel_type === 'email' && - mockNotificationData[0].content.email + mockNotificationData[0].content?.email ? mockNotificationData[0].content.email.email_addresses : []; const { getByText } = renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), useFormOptions: { defaultValues: { channel_ids: [mockNotificationData[0].id], @@ -59,10 +61,30 @@ describe('Channel Listing component', () => { expect(getByText(emailAddresses[1])).toBeInTheDocument(); }); + it('should disable the add notification button when service type is null', () => { + const { getByText, getByRole } = + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + channel_ids: [], + }, + }, + }); + expect(getByText('4. Notification Channels')).toBeVisible(); + const addButton = getByRole('button', { + name: 'Add notification channel', + }); + + expect(addButton).toBeDisabled(); + }); + it('should remove the fields', async () => { const { getByTestId } = renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), useFormOptions: { defaultValues: { channel_ids: [mockNotificationData[0].id], @@ -82,7 +104,9 @@ describe('Channel Listing component', () => { const mockMaxLimit = 5; const { getByRole, findByText } = renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), useFormOptions: { defaultValues: { channel_ids: Array(mockMaxLimit).fill(mockNotificationData[0].id), // simulate 5 channels diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx index f21749cb2c2..49ca526bb03 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import type { FieldPathByValue } from 'react-hook-form'; +import { useFlags } from 'src/hooks/useFlags'; import { useAllAlertNotificationChannelsQuery } from 'src/queries/cloudpulse/alerts'; import { channelTypeOptions, MULTILINE_ERROR_SEPARATOR } from '../../constants'; @@ -14,13 +15,21 @@ import { AddNotificationChannelDrawer } from './AddNotificationChannelDrawer'; import { RenderChannelDetails } from './RenderChannelDetails'; import type { CreateAlertDefinitionForm } from '../types'; -import type { NotificationChannel } from '@linode/api-v4'; +import type { + CloudPulseServiceType, + NotificationChannel, +} from '@linode/api-v4'; interface AddChannelListingProps { /** * FieldPathByValue for the notification channel ids */ name: FieldPathByValue; + + /** + * Service type of the CloudPulse alert + */ + serviceType: CloudPulseServiceType | null; } interface NotificationChannelsProps { @@ -34,9 +43,10 @@ interface NotificationChannelsProps { notification: NotificationChannel; } export const AddChannelListing = (props: AddChannelListingProps) => { - const { name } = props; + const { name, serviceType } = props; const { control, setValue } = useFormContext(); const [openAddNotification, setOpenAddNotification] = React.useState(false); + const flags = useFlags(); const notificationChannelWatcher = useWatch({ control, @@ -49,12 +59,31 @@ export const AddChannelListing = (props: AddChannelListingProps) => { } = useAllAlertNotificationChannelsQuery(); const notifications = React.useMemo(() => { - return ( - notificationData?.filter( - ({ id }) => !notificationChannelWatcher.includes(id) - ) ?? [] - ); - }, [notificationChannelWatcher, notificationData]); + if (!notificationData) return []; + + return notificationData.filter(({ id, type }) => { + if (notificationChannelWatcher.includes(id)) return false; // id already selected + + const systemSupportedTypes = + flags.aclpAlerting?.systemChannelSupportedServices; + + if (serviceType && systemSupportedTypes?.includes(serviceType)) { + return true; // show all types of channels if serviceType is supported + } + + if (serviceType && systemSupportedTypes) { + return type === 'user'; // only show user-defined alert channels + } + + // if no flags, show all types + return true; + }); + }, [ + flags.aclpAlerting?.systemChannelSupportedServices, + notificationChannelWatcher, + notificationData, + serviceType, + ]); const selectedNotifications = React.useMemo(() => { return ( @@ -168,12 +197,14 @@ export const AddChannelListing = (props: AddChannelListingProps) => { + + scrollToElement(topRef.current)} + /> + + ); +}; 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 new file mode 100644 index 00000000000..2218fe4dbdb --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx @@ -0,0 +1,175 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { formatDate } from 'src/utilities/formatDate'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { NotificationChannelTableRow } from './NotificationChannelTableRow'; + +describe('NotificationChannelTableRow', () => { + const mockHandleDetails = vi.fn(); + const mockHandleEdit = vi.fn(); + const handlers = { + handleDetails: mockHandleDetails, + handleEdit: mockHandleEdit, + }; + + it('should render a notification channel row with all fields', () => { + const updated = new Date().toISOString(); + const channel = notificationChannelFactory.build({ + alerts: { + type: 'alerts-definitions', + alert_count: 2, + url: 'monitor/alert-channels/{id}/alerts', + }, + channel_type: 'email', + created_by: 'user1', + label: 'Test Channel', + updated_by: 'user2', + updated, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Test Channel')).toBeVisible(); + expect(screen.getByText('2')).toBeVisible(); // alerts count + expect(screen.getByText('Email')).toBeVisible(); + expect(screen.getByText('user1')).toBeVisible(); + expect(screen.getByText('user2')).toBeVisible(); + expect( + screen.getByText( + formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + }) + ) + ).toBeVisible(); + }); + + it('should render channel type as Email for email type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'email', + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Email')).toBeVisible(); + }); + + it('should render channel type as Slack for slack type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'slack', + content: { + slack: { + message: 'message', + 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', + content: { + 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', + content: { + 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 }, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('0')).toBeVisible(); + }); + + it('should render row with correct data-qa attribute', () => { + const channel = notificationChannelFactory.build({ id: 123 }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText(channel.label)).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx new file mode 100644 index 00000000000..bb396d6bafd --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx @@ -0,0 +1,77 @@ +import { useProfile } from '@linode/queries'; +import React from 'react'; + +import { Link } from 'src/components/Link'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { formatDate } from 'src/utilities/formatDate'; + +import { channelTypeMap } from '../../constants'; +import { NotificationChannelActionMenu } from './NotificationChannelActionMenu'; + +import type { NotificationChannelActionHandlers } from './NotificationChannelActionMenu'; +import type { NotificationChannel } from '@linode/api-v4'; + +interface NotificationChannelTableRowProps { + /** + * The callback handlers for clicking an action menu item + */ + handlers: NotificationChannelActionHandlers; + /** + * The notification channel details used by the component to fill the row details + */ + notificationChannel: NotificationChannel; +} + +export const NotificationChannelTableRow = ( + props: NotificationChannelTableRowProps +) => { + const { handlers, notificationChannel } = props; + const { data: profile } = useProfile(); + const { + id, + label, + channel_type, + created_by, + updated, + updated_by, + alerts, + type, + } = notificationChannel; + return ( + + + + {label} + + + {alerts.alert_count} + {channelTypeMap[channel_type]} + {created_by} + + {formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + timezone: profile?.timezone, + })} + + {updated_by} + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/cloudPulseAlertsNotificationChannelsListingLazyRoute.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/cloudPulseAlertsNotificationChannelsListingLazyRoute.ts new file mode 100644 index 00000000000..73dd163145b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/cloudPulseAlertsNotificationChannelsListingLazyRoute.ts @@ -0,0 +1,8 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { NotificationChannelListing } from './NotificationChannelListing'; + +export const cloudPulseAlertsNotificationChannelsListingLazyRoute = + createLazyRoute('/alerts/notification-channels')({ + component: NotificationChannelListing, + }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts new file mode 100644 index 00000000000..b9172283c5b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts @@ -0,0 +1,36 @@ +import type { NotificationChannel } from '@linode/api-v4'; + +type ChannelListingTableLabel = { + colName: string; + label: `${keyof NotificationChannel}:${string}` | keyof NotificationChannel; +}; + +export const ChannelListingTableLabelMap: ChannelListingTableLabel[] = [ + { + colName: 'Channel Name', + label: 'label', + }, + { + colName: 'Alerts', + label: 'alerts:alert_count', + }, + { + colName: 'Channel Type', + label: 'channel_type', + }, + { + colName: 'Created By', + label: 'created_by', + }, + { + colName: 'Last Modified', + label: 'updated', + }, + { + colName: 'Last Modified By', + label: 'updated_by', + }, +]; + +export const ChannelAlertsTooltipText = + 'The number of alert definitions associated with the notification channel.'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts new file mode 100644 index 00000000000..84e8357f4e9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts @@ -0,0 +1,28 @@ +import type { NotificationChannelActionHandlers } from '../NotificationsChannelsListing/NotificationChannelActionMenu'; +import type { AlertNotificationType } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +export const getNotificationChannelActionsList = ({ + handleDetails, + handleEdit, +}: NotificationChannelActionHandlers): Record< + AlertNotificationType, + Action[] +> => ({ + system: [ + { + onClick: handleDetails, + title: 'Show Details', + }, + ], + user: [ + { + onClick: handleDetails, + title: 'Show Details', + }, + { + onClick: handleEdit, + title: 'Edit', + }, + ], +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index 4cb7b76999c..e7a229816fc 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -1,7 +1,7 @@ import { regionFactory } from '@linode/utilities'; import { act, renderHook } from '@testing-library/react'; -import { alertFactory, serviceTypesFactory } from 'src/factories'; +import { alertFactory, notificationChannelFactory, serviceTypesFactory } from 'src/factories'; import { useContextualAlertsState } from '../../Utils/utils'; import { transformDimensionValue } from '../CreateAlert/Criteria/DimensionFilterValue/utils'; @@ -17,6 +17,7 @@ import { getSchemaWithEntityIdValidation, getServiceTypeLabel, handleMultipleError, + shouldUseContentsForEmail, } from './utils'; import type { AlertValidationSchemaProps } from './utils'; @@ -497,3 +498,60 @@ describe('transformDimensionValue', () => { ).toBe('Test_value'); }); }); + +describe('shouldUseContentsForEmail', () => { + it('should return false for email channel with valid usernames in details', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: { + email: { + usernames: ['user1', 'user2'], + }, + }, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(false); + }); + + it('should return true for email channel with undefined details', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: undefined, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); + }); + + it('should return true for email channel with undefined details.email', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: { + email: undefined, + }, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); + }); + + it('should return true for email channel with undefined usernames', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: { + email: { + usernames: undefined, + }, + }, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); + }); + + it('should return true for email channel with empty usernames array', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: { + email: { + usernames: [], + recipient_type: 'admin_users', + }, + }, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index 91808118179..2fa962fa5ed 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -231,6 +231,23 @@ export const getAlertChipBorderRadius = ( return '0'; }; +/** + * Determines whether to use details.email.usernames (newer API) or content.email.email_addresses (older API) + * for displaying email recipients in notification channels. + * + * @param channel The notification channel to check + * @returns true if we should use content.email.email_addresses, false if we should use details.email.usernames + */ +export const shouldUseContentsForEmail = ( + channel: NotificationChannel +): boolean => { + // Use content if: details is missing, details is empty, details.email is empty or details.email.usernames is empty + return !( + channel.channel_type === 'email' && // ensuring it's an email channel to avoid the type error with email property + channel.details?.email?.usernames?.length + ); +}; + /** * @param value The notification channel object for which we need to display the chips * @returns The label and the values that needs to be displayed based on channel type @@ -239,24 +256,31 @@ export const getChipLabels = ( value: NotificationChannel ): AlertDimensionsProp => { if (value.channel_type === 'email') { + const contentEmail = value.content?.email; + const useContent = shouldUseContentsForEmail(value); + + const recipients = useContent + ? (contentEmail?.email_addresses ?? []) + : (value.details?.email?.usernames ?? []); + return { label: 'To', - values: value.content.email.email_addresses, + values: recipients, }; } else if (value.channel_type === 'slack') { return { label: 'Slack Webhook URL', - values: [value.content.slack.slack_webhook_url], + values: [value.content?.slack.slack_webhook_url ?? ''], }; } else if (value.channel_type === 'pagerduty') { return { label: 'Service API Key', - values: [value.content.pagerduty.service_api_key], + values: [value.content?.pagerduty.service_api_key ?? ''], }; } else { return { label: 'Webhook URL', - values: [value.content.webhook.webhook_url], + values: [value.content?.webhook.webhook_url ?? ''], }; } }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 51becf87d3c..e5a2525a576 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -146,6 +146,13 @@ export const channelTypeOptions: Item[] = [ }, ]; +export const channelTypeMap: Record = { + email: 'Email', + pagerduty: 'PagerDuty', + slack: 'Slack', + webhook: 'Webhook', +}; + export const metricOperatorTypeMap: Record = { eq: '=', gt: '>', @@ -294,3 +301,15 @@ export const entityLabelMap = { export const entityTypeTooltipText = 'Select a firewall entity type to filter the list in the Entities section. The metrics and dimensions in the Criteria section will update automatically based on your selection.'; + +export const CREATE_CHANNEL_SUCCESS_MESSAGE = + 'Notification channel created successfully. You can now use it to deliver alert notifications.'; + +export const CREATE_CHANNEL_FAILED_MESSAGE = + 'Failed to create the notification channel. Verify the configuration details and try again.'; + +export const UPDATE_CHANNEL_SUCCESS_MESSAGE = + 'Notification channel updated successfully. All changes have been saved.'; + +export const UPDATE_CHANNEL_FAILED_MESSAGE = + 'Failed to update the notification channel. Verify the details and try again.'; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx index 75b0a314f59..515923c3fbc 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx @@ -50,7 +50,15 @@ vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ }); describe('CloudPulseDashboardFilterBuilder component tests', () => { it('should render error placeholder if dashboard not selected', () => { - renderWithTheme(); + renderWithTheme(, { + flags: { + aclp: { + new: true, + beta: false, + enabled: true, + }, + }, + }); const text = screen.getByText('metrics'); expect(text).toBeInTheDocument(); @@ -61,6 +69,9 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { const messageComponent = screen.getByText(message); expect(messageComponent).toBeDefined(); + + const newFeatureChip = screen.getByTestId('newFeatureChip'); + expect(newFeatureChip).toBeVisible(); }); it('should render error placeholder if some dashboard is selected and filter config is not present', async () => { diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index 62f8769bdba..a6bb489a610 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -1,13 +1,17 @@ -import { Box, Paper } from '@linode/ui'; +import { useProfile } from '@linode/queries'; +import { Box, NewFeatureChip, Paper } from '@linode/ui'; import { GridLegacy } from '@mui/material'; +import { DateTime } from 'luxon'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useFlags } from 'src/hooks/useFlags'; import { GlobalFilters } from '../Overview/GlobalFilters'; import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; +import { defaultTimeDuration } from '../Utils/CloudPulseDateTimePickerUtils'; import { CloudPulseDashboardRenderer } from './CloudPulseDashboardRenderer'; import type { Dashboard, DateTimeWithPreset } from '@linode/api-v4'; @@ -29,6 +33,8 @@ export interface DashboardProp { } export const CloudPulseDashboardLanding = () => { + const { data: profile } = useProfile(); + const flags = useFlags(); const [filterData, setFilterData] = React.useState({ id: {}, label: {}, @@ -45,6 +51,11 @@ export const CloudPulseDashboardLanding = () => { const [showAppliedFilters, setShowAppliedFilters] = React.useState(false); + const timezone = + profile?.timezone === 'GMT' + ? 'Etc/GMT' // this is present in timezone list for GMT + : (profile?.timezone ?? DateTime.local().zoneName); + const toggleAppliedFilter = (isVisible: boolean) => { setShowAppliedFilters(isVisible); }; @@ -71,13 +82,17 @@ export const CloudPulseDashboardLanding = () => { [] ); - const onDashboardChange = React.useCallback((dashboardObj: Dashboard) => { - setDashboard(dashboardObj); - setFilterData({ - id: {}, - label: {}, - }); // clear the filter values on dashboard change - }, []); + const onDashboardChange = React.useCallback( + (dashboardObj: Dashboard) => { + setDashboard(dashboardObj); + setFilterData({ + id: {}, + label: {}, + }); // clear the filter values on dashboard change + setTimeDuration(defaultTimeDuration(timezone)); // clear time duration on dashboard change + }, + [timezone] + ); const onTimeDurationChange = React.useCallback( (timeDurationObj: DateTimeWithPreset) => { setTimeDuration(timeDurationObj); @@ -88,7 +103,12 @@ export const CloudPulseDashboardLanding = () => { }> : undefined, + }, + }} docsLabel="Docs" docsLink="https://techdocs.akamai.com/cloud-computing/docs/akamai-cloud-pulse" /> diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx index 238f49432d9..5009ecfbf02 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx @@ -39,6 +39,7 @@ vi.mock('../GroupBy/utils', async () => { }; }); const mockDashboard = dashboardFactory.build(); +const PRESET_BUTTON_ID = 'preset-button'; describe('CloudPulseDashboardWithFilters component tests', () => { it('renders a CloudPulseDashboardWithFilters component with error placeholder', () => { @@ -90,9 +91,9 @@ describe('CloudPulseDashboardWithFilters component tests', () => { const groupByIcon = screen.getByTestId('group-by'); expect(groupByIcon).toBeEnabled(); - const startDate = screen.getByText('Start Date'); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); const nodeTypeSelect = screen.getByTestId('node-type-select'); - expect(startDate).toBeInTheDocument(); + expect(presetButton).toBeInTheDocument(); expect(nodeTypeSelect).toBeInTheDocument(); }); @@ -141,9 +142,9 @@ describe('CloudPulseDashboardWithFilters component tests', () => { renderWithTheme( ); - const startDate = screen.getByText('Start Date'); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); const portsSelect = screen.getByPlaceholderText('e.g., 80,443,3000'); - expect(startDate).toBeInTheDocument(); + expect(presetButton).toBeInTheDocument(); expect(portsSelect).toBeInTheDocument(); }); @@ -159,8 +160,8 @@ describe('CloudPulseDashboardWithFilters component tests', () => { renderWithTheme( ); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); + expect(presetButton).toBeInTheDocument(); expect(screen.getByPlaceholderText('Select a Linode Region')).toBeVisible(); expect(screen.getByPlaceholderText('Select Interface Types')).toBeVisible(); expect(screen.getByPlaceholderText('e.g., 1234,5678')).toBeVisible(); @@ -181,8 +182,8 @@ describe('CloudPulseDashboardWithFilters component tests', () => { /> ); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); + expect(presetButton).toBeInTheDocument(); }); it('renders a CloudPulseDashboardWithFilters component with mandatory filter error for objectstorage if region is not provided', () => { @@ -212,8 +213,8 @@ describe('CloudPulseDashboardWithFilters component tests', () => { ); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); + expect(presetButton).toBeInTheDocument(); }); it('renders a CloudPulseDashboardWithFilters component successfully for firewall nodebalancer', async () => { @@ -240,8 +241,8 @@ describe('CloudPulseDashboardWithFilters component tests', () => { ); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); + expect(presetButton).toBeInTheDocument(); await userEvent.click(screen.getByPlaceholderText('Select a Dashboard')); await userEvent.click(screen.getByText('nodebalancer_firewall_dashbaord')); expect( diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index 8d139630e7a..088f5fbfcc7 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -1,5 +1,7 @@ +import { useProfile } from '@linode/queries'; import { Box, CircleProgress, Divider, ErrorState, Paper } from '@linode/ui'; import { GridLegacy } from '@mui/material'; +import { DateTime } from 'luxon'; import React from 'react'; import { @@ -13,7 +15,10 @@ import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardF import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; import { CloudPulseDateTimeRangePicker } from '../shared/CloudPulseDateTimeRangePicker'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; -import { convertToGmt } from '../Utils/CloudPulseDateTimePickerUtils'; +import { + convertToGmt, + defaultTimeDuration, +} from '../Utils/CloudPulseDateTimePickerUtils'; import { PARENT_ENTITY_REGION } from '../Utils/constants'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { @@ -62,6 +67,8 @@ export const CloudPulseDashboardWithFilters = React.memo( serviceType ? [serviceType] : [] ); + const { data: profile } = useProfile(); + const [filterData, setFilterData] = React.useState({ id: {}, label: {}, @@ -86,6 +93,11 @@ export const CloudPulseDashboardWithFilters = React.memo( const [showAppliedFilters, setShowAppliedFilters] = React.useState(false); + const timezone = + profile?.timezone === 'GMT' + ? 'Etc/GMT' // this is present in timezone list for GMT + : (profile?.timezone ?? DateTime.local().zoneName); + const toggleAppliedFilter = (isVisible: boolean) => { setShowAppliedFilters(isVisible); }; @@ -116,8 +128,9 @@ export const CloudPulseDashboardWithFilters = React.memo( (dashboard: Dashboard | undefined) => { setFilterData({ id: {}, label: {} }); setDashboard(dashboard); + setTimeDuration(defaultTimeDuration(timezone)); // clear time duration on dashboard change }, - [] + [timezone] ); const handleTimeRangeChange = React.useCallback( @@ -197,6 +210,7 @@ export const CloudPulseDashboardWithFilters = React.memo( gap={2} > diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx index b3539af8a8f..351b918ea32 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx @@ -52,7 +52,7 @@ describe('Global filters component test', () => { it('Should have time range select with default value', () => { setup(); - const timeRangeSelect = screen.getByText('Start Date'); + const timeRangeSelect = screen.getByTestId('preset-button'); expect(timeRangeSelect).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts index c94d37f5d19..a51232bf0b7 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts @@ -11,6 +11,7 @@ import { getTimeDurationFromPreset, mapResourceIdToName, } from './CloudPulseWidgetUtils'; +import * as utilities from './utils'; import type { DimensionNameProperties, @@ -100,52 +101,90 @@ describe('getLabelName method', () => { }); }); -it('test generateGraphData with metrics data', () => { - const mockMetricsResponse: CloudPulseMetricsResponse = { - data: { - result: [ - { - metric: { entity_id: '1' }, - values: [[1234567890, '50']], - }, - ], - result_type: 'matrix', - }, - isPartial: false, - stats: { - series_fetched: 1, - }, - status: 'success', - }; +describe('generateGraphData method', () => { + it('test generateGraphData with metrics data', () => { + const mockMetricsResponse: CloudPulseMetricsResponse = { + data: { + result: [ + { + metric: { entity_id: '1' }, + values: [[1234567890, '50']], + }, + ], + result_type: 'matrix', + }, + isPartial: false, + stats: { + series_fetched: 1, + }, + status: 'success', + }; - const result = generateGraphData({ - label: 'Graph', - metricsList: mockMetricsResponse, - resources: [{ id: '1', label: 'linode-1' }], - status: 'success', - unit: '%', - serviceType: 'linode', - groupBy: ['entity_id'], + const result = generateGraphData({ + label: 'Graph', + metricsList: mockMetricsResponse, + resources: [{ id: '1', label: 'linode-1' }], + status: 'success', + unit: '%', + serviceType: 'linode', + groupBy: ['entity_id'], + humanizableUnits: [], + }); + + expect(result.areas[0].dataKey).toBe('linode-1'); + expect(result.dimensions).toEqual([ + { + 'linode-1': 50, + timestamp: 1234567890000, + }, + ]); + + expect(result.legendRowsData[0].data).toEqual({ + average: 50, + last: 50, + length: 1, + max: 50, + total: 50, + }); + expect(result.legendRowsData[0].format).toBeDefined(); + expect(result.legendRowsData[0].legendTitle).toBe('linode-1'); + expect(result.unit).toBe('%'); }); - expect(result.areas[0].dataKey).toBe('linode-1'); - expect(result.dimensions).toEqual([ - { - 'linode-1': 50, - timestamp: 1234567890000, - }, - ]); - - expect(result.legendRowsData[0].data).toEqual({ - average: 50, - last: 50, - length: 1, - max: 50, - total: 50, + it('test makes legend rows humanizable when unit is in humanizableUnits', () => { + const spy = vi.spyOn(utilities, 'humanizeLargeData'); + const mockMetricsResponse: CloudPulseMetricsResponse = { + data: { + result: [ + { + metric: { entity_id: '1' }, + values: [[1234567890, '50000']], + }, + ], + result_type: 'matrix', + }, + isPartial: false, + stats: { + series_fetched: 1, + }, + status: 'success', + }; + + const result = generateGraphData({ + label: 'Graph', + metricsList: mockMetricsResponse, + resources: [{ id: '1', label: 'linode-1' }], + status: 'success', + unit: 'Count', + serviceType: 'linode', + groupBy: ['entity_id'], + humanizableUnits: ['Count'], + }); + + expect(result.legendRowsData[0].format).toBeDefined(); + result.legendRowsData[0].format(50000); + expect(spy).toHaveBeenCalledWith(50000); }); - expect(result.legendRowsData[0].format).toBeDefined(); - expect(result.legendRowsData[0].legendTitle).toBe('linode-1'); - expect(result.unit).toBe('%'); }); describe('getDimensionName method', () => { diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index fb0816b6c8d..75bea2c322c 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -11,6 +11,7 @@ import { } from './unitConversion'; import { convertTimeDurationToStartAndEndTimeRange, + humanizeLargeData, seriesDataFormatter, } from './utils'; @@ -75,6 +76,10 @@ interface GraphDataOptionsProps { * array of group by fields */ groupBy?: string[]; + /** + * The units for which to apply humanization + */ + humanizableUnits?: string[]; /** * label for the graph title */ @@ -215,11 +220,15 @@ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { unit, groupBy, metricLabel, + humanizableUnits: humanizedUnits, } = props; const legendRowsData: MetricsDisplayRow[] = []; const dimension: { [timestamp: number]: { [label: string]: number } } = {}; const areas: AreaProps[] = []; const colors = Object.values(Alias.Chart.Categorical); + const isHumanizableUnit = humanizedUnits?.some( + (unitElement) => unitElement.toLowerCase() === unit.toLowerCase() + ); // check whether to hide metric name or not based on the number of unique metric names const hideMetricName = @@ -277,7 +286,9 @@ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { // construct a legend row with the dimension const legendRow: MetricsDisplayRow = { data: getMetrics(data as number[][]), - format: (value: number) => formatToolTip(value, unit), + format: isHumanizableUnit + ? (value: number) => `${humanizeLargeData(value)} ${unit}` // we need to humanize count values in legend + : (value: number) => formatToolTip(value, unit), legendColor: color, legendTitle: labelName, }; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index b6173898d61..b18b3a8bf0e 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -1,5 +1,5 @@ import { useAccount, useRegionsQuery } from '@linode/queries'; -import { isFeatureEnabledV2 } from '@linode/utilities'; +import { isFeatureEnabledV2, roundTo } from '@linode/utilities'; import React from 'react'; import { convertData } from 'src/features/Longview/shared/formatters'; @@ -676,3 +676,26 @@ export const arraysEqual = ( [...b].sort((x, y) => x - y) ); }; + +/** + * @param value The numeric value to humanize + * @returns The humanized string representation of the value + */ +export const humanizeLargeData = (value: number) => { + if (value >= 1000000000000) { + return +(value / 1000000000000).toFixed(1) + 'T'; + } + if (value >= 1000000000) { + return +(value / 1000000000).toFixed(1) + 'B'; + } + if (value >= 1000000) { + return +(value / 1000000).toFixed(1) + 'M'; + } + if (value >= 100000) { + return +(value / 1000).toFixed(0) + 'K'; + } + if (value >= 1000) { + return +(value / 1000).toFixed(1) + 'K'; + } + return `${roundTo(value, 1)}`; +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 2220f3d45e4..de7a0b3ef16 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -446,6 +446,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { serviceType, groupBy: [...globalFilterGroupBy, ...(groupBy ?? [])], metricLabel: availableMetrics?.label, + humanizableUnits: flags.aclp?.humanizableUnits ?? [], }); data = generatedData.dimensions; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index 51959f9a63e..4fe33e746e9 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -4,6 +4,9 @@ import { Box, useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; +import { useFlags } from 'src/hooks/useFlags'; + +import { humanizeLargeData } from '../../Utils/utils'; import type { AreaChartProps } from 'src/components/AreaChart/AreaChart'; @@ -13,7 +16,8 @@ export interface CloudPulseLineGraph extends AreaChartProps { } export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { - const { error, loading, ...rest } = props; + const { error, loading, unit, ...rest } = props; + const flags = useFlags(); const theme = useTheme(); @@ -29,6 +33,10 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { } const noDataMessage = 'No data to display'; + const isHumanizableUnit = + flags.aclp?.humanizableUnits?.some( + (unitElement) => unitElement.toLowerCase() === unit.toLowerCase() + ) ?? false; return ( { right: 30, top: 2, }} + tooltipCustomValueFormatter={ + isHumanizableUnit + ? (value, unit) => `${humanizeLargeData(value)} ${unit}` + : undefined + } + unit={unit} xAxisTickCount={ isSmallScreen ? undefined : Math.min(rest.data.length, 7) } - yAxisProps={{ - tickFormat: (value: number) => `${roundTo(value, 3)}`, - }} + yAxisProps={ + isHumanizableUnit + ? { + tickFormat: (value: number) => `${humanizeLargeData(value)}`, + } + : { + tickFormat: (value: number) => `${roundTo(value, 3)}`, + } + } /> )} {rest.data.length === 0 && ( diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx index f20c6da73fa..3a7c60b549b 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx @@ -79,7 +79,7 @@ export const CloudPulseDimensionFiltersSelect = React.memo( <> Number(id)), + : Array.isArray(dependentFilterReference.current[RESOURCE_ID]) + ? dependentFilterReference.current[RESOURCE_ID].map( + Number + ).filter((id) => !Number.isNaN(id)) + : [], shouldDisable: isError || isLoading, }, handleNodeTypeChange diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx index f63645f5688..caa1f259206 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx @@ -1,5 +1,6 @@ import { useProfile } from '@linode/queries'; -import { DateTimeRangePicker } from '@linode/ui'; +import { Box, Button, CalendarIcon, DateTimeRangePicker } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; import { DateTime } from 'luxon'; import React from 'react'; @@ -32,30 +33,44 @@ export const CloudPulseDateTimeRangePicker = React.memo( const { defaultValue, handleStatsChange, savePreferences } = props; const { data: profile } = useProfile(); let defaultSelected = defaultValue as DateTimeWithPreset; - + const RESET = 'Reset'; + const theme = useTheme(); const timezone = defaultSelected?.timeZone ?? - profile?.timezone ?? - DateTime.local().zoneName; + (profile?.timezone === 'GMT' + ? 'Etc/GMT' // this is present in timezone list for GMT + : (profile?.timezone ?? DateTime.local().zoneName)); if (!defaultSelected) { defaultSelected = defaultTimeDuration(timezone); } else { defaultSelected = getTimeFromPreset(defaultSelected, timezone); } + // Show button with preset value only if selected or default preset is not 'reset' + const [selectedPreset, setSelectedPreset] = React.useState< + string | undefined + >(defaultSelected.preset); + // Show calendar only if selected or default preset is 'reset' or button is clicked + const [openCalendar, setOpenCalendar] = React.useState(false); React.useEffect(() => { if (defaultSelected) { handleStatsChange(defaultSelected); } }, []); + const handleClose = (selectedPreset: string) => { + setOpenCalendar(false); + setSelectedPreset(selectedPreset); + }; + const handleDateChange = (params: DateChangeProps) => { const { endDate, selectedPreset, startDate, timeZone } = params; if (!endDate || !startDate || !selectedPreset || !timeZone) { return; } - + setOpenCalendar(selectedPreset !== RESET ? false : true); + setSelectedPreset(selectedPreset); handleStatsChange( { end: endDate, @@ -75,33 +90,66 @@ export const CloudPulseDateTimeRangePicker = React.memo( : end; return ( - + + {selectedPreset !== RESET && !openCalendar && ( + + )} + {(selectedPreset === RESET || openCalendar) && ( + + )} + ); } ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 6dd24061ec6..434c9220aae 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -10,6 +10,7 @@ import { filterRegionByServiceType } from '../Alerts/Utils/utils'; import { NO_REGION_MESSAGE, PARENT_ENTITY_REGION, + REGION, RESOURCE_FILTER_MAP, } from '../Utils/constants'; import { filterUsingDependentFilters } from '../Utils/FilterBuilder'; @@ -19,6 +20,7 @@ import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; import type { Dashboard, FilterValue, Region } from '@linode/api-v4'; +import type { Theme } from '@linode/ui'; export interface CloudPulseRegionSelectProps { defaultValue?: FilterValue; @@ -153,6 +155,9 @@ export const CloudPulseRegionSelect = React.memo( dependencyKey, // Reacts to region changes ]); + // Add spacing for region filter in LKE service to align with Clusters filter that has tooltip + const shouldAddSpacing = serviceType === 'lke' && filterKey === REGION; + return ( ({ + marginBottom: theme.spacingFunction(4), + }), + }, + }), + }} value={ supportedRegionsFromResources?.length ? (selectedRegion ?? null) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx index daec81168d5..c372790dece 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx @@ -40,7 +40,7 @@ describe('database monitor', () => { expect(loadingElement).toBeInTheDocument(); await waitForElementToBeRemoved(loadingElement); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId('preset-button'); + expect(presetButton).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolDeleteDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolDeleteDialog.tsx new file mode 100644 index 00000000000..29d3954f05e --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolDeleteDialog.tsx @@ -0,0 +1,65 @@ +import { useDeleteDatabaseConnectionPoolMutation } from '@linode/queries'; +import { ActionsPanel, Notice } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; + +interface Props { + databaseId: number; + onClose: () => void; + open: boolean; + poolLabel: string; +} + +export const DatabaseConnectionPoolDeleteDialog = (props: Props) => { + const { onClose, open, databaseId, poolLabel } = props; + const { enqueueSnackbar } = useSnackbar(); + const { + error, + isPending, + reset, + mutateAsync: deleteConnectionPool, + } = useDeleteDatabaseConnectionPoolMutation(databaseId, poolLabel); + + const onDelete = () => { + deleteConnectionPool().then(() => { + enqueueSnackbar(`Connection Pool ${poolLabel} deleted successfully.`, { + variant: 'success', + }); + onClose(); + }); + }; + + const clearErrorAndClose = () => { + reset(); + onClose(); + }; + + const actions = ( + + ); + + return ( + clearErrorAndClose()} + open={open} + title={`Delete Connection Pool ${poolLabel}?`} + > + + Warning: Deletion will break the service URI for any + clients using this pool. + + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx new file mode 100644 index 00000000000..714b18bb197 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx @@ -0,0 +1,63 @@ +import { Hidden } from '@linode/ui'; +import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { CONNECTION_POOL_LABEL_CELL_STYLES } from 'src/features/Databases/constants'; +import { StyledActionMenuWrapper } from 'src/features/Databases/shared.styles'; + +import type { ConnectionPool } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +interface Props { + /** + * Function called when the delete button in the Action Menu is pressed. + */ + onDelete: (pool: ConnectionPool) => void; + /** + * Payment method type and data. + */ + pool: ConnectionPool; +} + +export const DatabaseConnectionPoolRow = (props: Props) => { + const { pool, onDelete } = props; + + const connectionPoolActions: Action[] = [ + { + onClick: () => null, + title: 'Edit', // TODO: UIE-9395 Implement edit functionality + }, + { + onClick: () => onDelete(pool), + title: 'Delete', + }, + ]; + + return ( + + + {pool.label} + + + + {`${pool.mode.charAt(0).toUpperCase()}${pool.mode.slice(1)}`} + + + + {pool.size} + + + + {pool.username === null ? 'Reuse inbound user' : pool.username} + + + + + + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx new file mode 100644 index 00000000000..c9341cd3254 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx @@ -0,0 +1,133 @@ +import { screen } from '@testing-library/react'; +import * as React from 'react'; +import { describe, it } from 'vitest'; + +import { + databaseConnectionPoolFactory, + databaseFactory, +} from 'src/factories/databases'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatabaseConnectionPools } from './DatabaseConnectionPools'; + +const mockDatabase = databaseFactory.build({ + platform: 'rdbms-default', + private_network: null, + engine: 'postgresql', + id: 1, +}); + +const mockConnectionPool = databaseConnectionPoolFactory.build({ + database: 'defaultdb', + label: 'pool-1', + mode: 'transaction', + size: 10, + username: null, +}); + +// Hoist query mocks +const queryMocks = vi.hoisted(() => { + return { + useDatabaseConnectionPoolsQuery: vi.fn(), + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDatabaseConnectionPoolsQuery: queryMocks.useDatabaseConnectionPoolsQuery, + }; +}); + +describe('DatabaseManageNetworkingDrawer Component', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should render PgBouncer Connection Pools field', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([mockConnectionPool]), + isLoading: false, + }); + renderWithTheme(); + + const heading = screen.getByRole('heading'); + expect(heading.textContent).toBe('Manage PgBouncer Connection Pools'); + const addPoolBtnLabel = screen.getByText('Add Pool'); + expect(addPoolBtnLabel).toBeInTheDocument(); + }); + + it('should render loading state', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([mockConnectionPool]), + isLoading: true, + }); + const loadingTestId = 'circle-progress'; + renderWithTheme(); + + const loadingCircle = screen.getByTestId(loadingTestId); + expect(loadingCircle).toBeInTheDocument(); + }); + + it('should render table with connection pool data', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([mockConnectionPool]), + isLoading: false, + }); + + renderWithTheme(); + + const connectionPoolLabel = screen.getByText(mockConnectionPool.label); + expect(connectionPoolLabel).toBeInTheDocument(); + }); + + it('should render table empty state when no data is provided', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: false, + }); + + renderWithTheme(); + + const emptyStateText = screen.getByText( + "You don't have any connection pools added." + ); + expect(emptyStateText).toBeInTheDocument(); + }); + + it('should render error state state when backend responds with error', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + error: new Error('Failed to fetch VPC'), + }); + + renderWithTheme(); + const errorStateText = screen.getByText( + 'There was a problem retrieving your connection pools. Refresh the page or try again later.' + ); + expect(errorStateText).toBeInTheDocument(); + }); + + it('should render service URI component if there are connection pools', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([mockConnectionPool]), + isLoading: false, + }); + + renderWithTheme(); + const serviceURIText = screen.getByText('Service URI'); + expect(serviceURIText).toBeInTheDocument(); + }); + + it('should not render service URI component if there are no connection pools', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: false, + }); + + renderWithTheme(); + const serviceURIText = screen.queryByText('Service URI'); + expect(serviceURIText).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx new file mode 100644 index 00000000000..74a3c3c4270 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx @@ -0,0 +1,184 @@ +import { useDatabaseConnectionPoolsQuery } from '@linode/queries'; +import { + Button, + CircleProgress, + ErrorState, + Hidden, + Stack, + Typography, +} from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from 'akamai-cds-react-components/Table'; +import React from 'react'; + +import { + MIN_PAGE_SIZE, + PAGE_SIZES, +} from 'src/components/PaginationFooter/PaginationFooter.constants'; +import { CONNECTION_POOL_LABEL_CELL_STYLES } from 'src/features/Databases/constants'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; + +import { makeSettingsItemStyles } from '../../shared.styles'; +import { ServiceURI } from '../ServiceURI'; +import { DatabaseConnectionPoolDeleteDialog } from './DatabaseConnectionPoolDeleteDialog'; +import { DatabaseConnectionPoolRow } from './DatabaseConnectionPoolRow'; + +import type { Database } from '@linode/api-v4'; + +interface Props { + database: Database; + disabled?: boolean; +} + +export const DatabaseConnectionPools = ({ database }: Props) => { + const { classes } = makeSettingsItemStyles(); + const theme = useTheme(); + + const [deletePoolLabelSelection, setDeletePoolLabelSelection] = + React.useState(); + + const pagination = usePaginationV2({ + currentRoute: '/databases/$engine/$databaseId/networking', + initialPage: 1, + preferenceKey: `database-connection-pools-pagination`, + }); + + const { + data: connectionPools, + error: connectionPoolsError, + isLoading: connectionPoolsLoading, + } = useDatabaseConnectionPoolsQuery(database.id, true, { + page: pagination.page, + page_size: pagination.pageSize, + }); + + if (connectionPoolsLoading) { + return ; + } + + if (connectionPoolsError) { + return ( + + ); + } + + return ( + <> +
+ + + Manage PgBouncer Connection Pools + + + Manage PgBouncer connection pools to minimize the use of your server + resources. + + + +
+ {connectionPools && connectionPools.data.length > 0 && ( + + )} +
+ + + + + Pool Label + + + Pool Mode + + + Pool Size + + + Username + + + + + + {connectionPools?.data.length === 0 ? ( + + + You don't have any connection pools added. + + + ) : ( + connectionPools?.data.map((pool) => ( + setDeletePoolLabelSelection(pool.label)} + pool={pool} + /> + )) + )} + +
+
+ {(connectionPools?.results || 0) > MIN_PAGE_SIZE && ( + ) => + pagination.handlePageChange(Number(e.detail)) + } + onPageSizeChange={( + e: CustomEvent<{ page: number; pageSize: number }> + ) => pagination.handlePageSizeChange(Number(e.detail.pageSize))} + page={pagination.page} + pageSize={pagination.pageSize} + pageSizes={PAGE_SIZES} + style={{ + borderLeft: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderTop: 0, + marginTop: '0', + }} + /> + )} + setDeletePoolLabelSelection(null)} + open={Boolean(deletePoolLabelSelection)} + poolLabel={deletePoolLabelSelection ?? ''} + /> + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx index 3e35fa76bbb..a441e72e98c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx @@ -4,15 +4,16 @@ import { Button, CircleProgress, ErrorState, + Stack, Typography, } from '@linode/ui'; import React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { Link } from 'src/components/Link'; import { useFlags } from 'src/hooks/useFlags'; import { MANAGE_NETWORKING_LEARN_MORE_LINK } from '../../constants'; +import { makeSettingsItemStyles } from '../../shared.styles'; import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows'; import { ConnectionDetailsRow } from '../ConnectionDetailsRow'; import { StyledGridContainer } from '../DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; @@ -20,7 +21,6 @@ import DatabaseManageNetworkingDrawer from './DatabaseManageNetworkingDrawer'; import { DatabaseNetworkingUnassignVPCDialog } from './DatabaseNetworkingUnassignVPCDialog'; import type { Database } from '@linode/api-v4'; -import type { Theme } from '@mui/material'; interface Props { database: Database; @@ -28,42 +28,8 @@ interface Props { } export const DatabaseManageNetworking = ({ database }: Props) => { - const useStyles = makeStyles()((theme: Theme) => ({ - manageNetworkingBtn: { - minWidth: 225, - [theme.breakpoints.down('md')]: { - alignSelf: 'flex-start', - marginTop: '1rem', - marginBottom: '1rem', - }, - }, - sectionText: { - marginBottom: '1rem', - marginRight: 0, - [theme.breakpoints.down('sm')]: { - width: '100%', - }, - width: '65%', - }, - sectionTitle: { - marginBottom: '0.25rem', - display: 'flex', - }, - sectionTitleAndText: { - width: '100%', - }, - topSection: { - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between', - [theme.breakpoints.down('md')]: { - flexDirection: 'column', - }, - }, - })); - const flags = useFlags(); - const { classes } = useStyles(); + const { classes } = makeSettingsItemStyles(); const [isManageNetworkingDrawerOpen, setIsManageNetworkingDrawerOpen] = React.useState(false); const [isUnassignVPCDialogOpen, setIsUnassignVPCDialogOpen] = @@ -111,8 +77,8 @@ export const DatabaseManageNetworking = ({ database }: Props) => { return ( <>
-
-
+ +
Manage Networking {flags.databaseVpcBeta && }
@@ -128,10 +94,10 @@ export const DatabaseManageNetworking = ({ database }: Props) => { availability. Avoid writing data to the database while a change is in progress. -
+ + ) : hidePassword || (!credentialsError && !credentials) ? ( + + ) : ( + `${credentials?.username}:${credentials?.password}` + )} + @{database.hosts?.primary}: + {'{connection pool port}'}/ + {'{connection pool label}'}?sslmode=require + + {isCopying ? ( + + ) : ( + + + + )} + + + ); +}; + +export const StyledCode = styled(Code, { + label: 'StyledCode', +})(() => ({ + margin: 0, +})); + +export const StyledCopyTooltip = styled(CopyTooltip, { + label: 'StyledCopyTooltip', +})(({ theme }) => ({ + alignSelf: 'center', + '& svg': { + height: theme.spacingFunction(16), + width: theme.spacingFunction(16), + }, + '&:hover': { + backgroundColor: 'transparent', + }, + display: 'flex', + margin: `0 ${theme.spacingFunction(4)}`, +})); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index 6e68f13784c..decaf48b49a 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -3,7 +3,7 @@ import { useProfile, useRegionsQuery, } from '@linode/queries'; -import { Chip, Hidden, styled } from '@linode/ui'; +import { Chip, Hidden } from '@linode/ui'; import { formatStorageUnits } from '@linode/utilities'; import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; import * as React from 'react'; @@ -16,6 +16,8 @@ import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { isWithinDays, parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; +import { StyledActionMenuWrapper } from '../shared.styles'; + import type { Event } from '@linode/api-v4'; import type { DatabaseInstance, @@ -34,24 +36,6 @@ interface Props { isNewDatabase?: boolean; } -const DatabaseActionMenuStyledWrapper = styled(TableCell, { - label: 'DatabaseActionMenuStyledWrapper', -})(({ theme }) => ({ - justifyContent: 'flex-end', - display: 'flex', - alignItems: 'center', - maxWidth: 40, - '& button': { - padding: 0, - color: theme.tokens.alias.Content.Icon.Primary.Default, - backgroundColor: 'transparent', - }, - '& button:hover': { - backgroundColor: 'transparent', - color: theme.tokens.alias.Content.Icon.Primary.Hover, - }, -})); - export const DatabaseRow = ({ database, events, @@ -148,7 +132,7 @@ export const DatabaseRow = ({ {isDatabasesV2GA && isNewDatabase && ( - + - + )} ); diff --git a/packages/manager/src/features/Databases/constants.ts b/packages/manager/src/features/Databases/constants.ts index a1c3d7e3e77..3e3845fc9b1 100644 --- a/packages/manager/src/features/Databases/constants.ts +++ b/packages/manager/src/features/Databases/constants.ts @@ -71,3 +71,7 @@ export const ADVANCED_CONFIG_LEARN_MORE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/advanced-configuration-parameters'; export const MANAGE_NETWORKING_LEARN_MORE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/aiven-manage-database#manage-networking'; + +export const CONNECTION_POOL_LABEL_CELL_STYLES = { + flex: '.5 1 20.5%', +}; diff --git a/packages/manager/src/features/Databases/shared.styles.ts b/packages/manager/src/features/Databases/shared.styles.ts new file mode 100644 index 00000000000..fe33a83bd58 --- /dev/null +++ b/packages/manager/src/features/Databases/shared.styles.ts @@ -0,0 +1,50 @@ +import { styled } from '@linode/ui'; +import { TableCell } from 'akamai-cds-react-components/Table'; +import { makeStyles } from 'tss-react/mui'; + +import type { Theme } from '@mui/material'; + +export const StyledActionMenuWrapper = styled(TableCell, { + label: 'StyledActionMenuWrapper', +})(({ theme }) => ({ + justifyContent: 'flex-end', + display: 'flex', + alignItems: 'center', + maxWidth: 40, + '& button': { + padding: 0, + color: theme.tokens.alias.Content.Icon.Primary.Default, + backgroundColor: 'transparent', + }, + '& button:hover': { + backgroundColor: 'transparent', + color: theme.tokens.alias.Content.Icon.Primary.Hover, + }, +})); + +export const makeSettingsItemStyles = makeStyles()((theme: Theme) => ({ + actionBtn: { + minWidth: 225, + [theme.breakpoints.down('md')]: { + alignSelf: 'flex-start', + marginTop: '1rem', + marginBottom: '1rem', + }, + }, + sectionText: { + marginBottom: '1rem', + marginRight: 0, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + width: '65%', + }, + topSection: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + [theme.breakpoints.down('md')]: { + display: 'block', + }, + }, +})); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx index 04e577c91f9..0d6cf2b387b 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -51,7 +51,7 @@ describe('DestinationEdit', () => { await waitFor(() => { assertInputHasValue('Destination Name', 'Destination 123'); }); - assertInputHasValue('Host', '3000'); + assertInputHasValue('Host', 'destinations-bucket-name.host.com'); assertInputHasValue('Bucket', 'destinations-bucket-name'); assertInputHasValue('Access Key ID', 'Access Id'); assertInputHasValue('Secret Access Key', ''); 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 0b924f69c19..2d1eaacd74b 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -63,7 +63,7 @@ describe('StreamEdit', () => { assertInputHasValue('Destination Name', 'Destination 1'); // Host: - expect(screen.getByText('3000')).toBeVisible(); + expect(screen.getByText('destinations-bucket-name.host.com')).toBeVisible(); // Bucket: expect(screen.getByText('destinations-bucket-name')).toBeVisible(); // Access Key ID: diff --git a/packages/manager/src/features/Events/factories/firewall.tsx b/packages/manager/src/features/Events/factories/firewall.tsx index 14ec53f3264..c29700b102f 100644 --- a/packages/manager/src/features/Events/factories/firewall.tsx +++ b/packages/manager/src/features/Events/factories/firewall.tsx @@ -69,10 +69,8 @@ export const firewall: PartialEventMap<'firewall'> = { firewall_device_add: { notification: (e) => { if (e.secondary_entity?.type) { - // TODO - Linode Interfaces [M3-10447] - clean this up when API ticket [VPC-3359] is completed const secondaryEntityName = - formattedTypes[e.secondary_entity.type as FirewallDeviceEntityType] ?? - 'Linode Interface'; + formattedTypes[e.secondary_entity.type as FirewallDeviceEntityType]; return ( <> {secondaryEntityName} {' '} diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx index 2d80ed84d26..f65948586e0 100644 --- a/packages/manager/src/features/Events/factories/linode.tsx +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -5,6 +5,23 @@ import { Link } from 'src/components/Link'; import { EventLink } from '../EventLink'; import type { PartialEventMap } from '../types'; +import type { AccountMaintenance } from '@linode/api-v4'; + +/** + * Normalizes the event description to a valid maintenance type. + * Only accepts 'emergency' or 'scheduled' from AccountMaintenance.description, + * defaults to 'maintenance' for any other value or null/undefined. + */ +type MaintenanceDescription = 'maintenance' | AccountMaintenance['description']; + +const getMaintenanceDescription = ( + description: null | string | undefined +): MaintenanceDescription => { + if (description === 'emergency' || description === 'scheduled') { + return description; + } + return 'maintenance'; +}; export const linode: PartialEventMap<'linode'> = { linode_addip: { @@ -241,30 +258,106 @@ export const linode: PartialEventMap<'linode'> = { ), }, linode_migrate: { - failed: (e) => ( - <> - Migration failed for Linode{' '} - . - - ), - finished: (e) => ( - <> - Linode has been{' '} - migrated. - - ), - scheduled: (e) => ( - <> - Linode is scheduled to be{' '} - migrated. - - ), - started: (e) => ( - <> - Linode is being{' '} - migrated. - - ), + failed: (e) => { + const maintenanceType = getMaintenanceDescription(e.description); + return ( + <> + Migration failed for Linode{' '} + for{' '} + {maintenanceType === 'maintenance' ? ( + maintenance + ) : ( + <> + {maintenanceType} maintenance + + )} + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ); + }, + finished: (e) => { + const maintenanceType = getMaintenanceDescription(e.description); + return ( + <> + Linode has been{' '} + migrated for{' '} + {maintenanceType === 'maintenance' ? ( + maintenance + ) : ( + <> + {maintenanceType} maintenance + + )} + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ); + }, + scheduled: (e) => { + const maintenanceType = getMaintenanceDescription(e.description); + return ( + <> + Linode is scheduled to be{' '} + migrated for{' '} + {maintenanceType === 'maintenance' ? ( + maintenance + ) : ( + <> + {maintenanceType} maintenance + + )} + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ); + }, + started: (e) => { + const maintenanceType = getMaintenanceDescription(e.description); + return ( + <> + Linode is being{' '} + migrated for{' '} + {maintenanceType === 'maintenance' ? ( + maintenance + ) : ( + <> + {maintenanceType} maintenance + + )} + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ); + }, }, linode_migrate_datacenter: { failed: (e) => ( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx index ac68229bf9d..a24429049f7 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx @@ -181,9 +181,12 @@ export const FirewallDeviceTable = React.memo( disabled={disabled} handleRemoveDevice={handleRemoveDevice} isLinodeRelatedDevice={isLinodeRelatedDevice} - isLinodeUpdatable={updatableLinodes?.some( - (linode) => linode.id === thisDevice.entity.id - )} + isLinodeUpdatable={updatableLinodes?.some((linode) => { + if (thisDevice.entity.type === 'linode_interface') { + return linode.id === thisDevice.entity.parent_entity?.id; + } + return linode.id === thisDevice.entity.id; + })} isNodebalancerUpdatable={updatableNodebalancers?.some( (nodebalancer) => nodebalancer.id === thisDevice.entity.id )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx index 0bd18ab2a35..ce6bd39325e 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx @@ -47,35 +47,40 @@ const computeExpectedElements = ( let title = 'Prefix List details'; let button = 'Close'; let label = 'Name:'; + let hasBackNavigation = false; if (context?.type === 'ruleset' && context.modeViewedFrom === 'create') { title = `Add an ${capitalize(category)} Rule or Rule Set`; button = `Back to ${capitalize(category)} Rule Set`; label = 'Prefix List Name:'; + hasBackNavigation = true; } if (context?.type === 'rule' && context.modeViewedFrom === 'create') { title = `Add an ${capitalize(category)} Rule or Rule Set`; button = `Back to ${capitalize(category)} Rule`; label = 'Prefix List Name:'; + hasBackNavigation = true; } if (context?.type === 'ruleset' && context.modeViewedFrom === 'view') { title = `${capitalize(category)} Rule Set details`; button = 'Back to the Rule Set'; label = 'Prefix List Name:'; + hasBackNavigation = true; } if (context?.type === 'rule' && context.modeViewedFrom === 'edit') { title = 'Edit Rule'; button = 'Back to Rule'; label = 'Prefix List Name:'; + hasBackNavigation = true; } // Default values when there is no specific drawer context // (e.g., type === 'rule' and modeViewedFrom === undefined, // meaning the drawer is opened directly from the Firewall Table row) - return { title, button, label }; + return { title, button, label, hasBackNavigation }; }; describe('FirewallPrefixListDrawer', () => { @@ -158,7 +163,7 @@ describe('FirewallPrefixListDrawer', () => { mockData, ]); - const { getByText, getByRole } = renderWithTheme( + const { getByText, getByRole, queryByLabelText } = renderWithTheme( { ); // Compute expectations - const { title, button, label } = computeExpectedElements( - category, - context - ); + const { title, button, label, hasBackNavigation } = + computeExpectedElements(category, context); + + // Back Navigation (Expected only for second-level drawers) + const backIconButton = queryByLabelText('back navigation'); + if (hasBackNavigation) { + expect(backIconButton).toBeVisible(); + } else { + expect(backIconButton).not.toBeInTheDocument(); + } // Title expect(getByText(title)).toBeVisible(); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx index b846482f8b2..1c058bb6db6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx @@ -204,12 +204,15 @@ export const FirewallPrefixListDrawer = React.memo( column?: boolean; copy?: boolean; label: string; - value: React.ReactNode | string; + value: React.ReactNode; }[]; return ( onClose({ closeAll: false }) : undefined + } isFetching={isFetching} onClose={() => onClose({ closeAll: true })} open={isOpen} @@ -227,13 +230,10 @@ export const FirewallPrefixListDrawer = React.memo( <> {fields.map((item, idx) => ( {item.label && ( {item.label}: diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx index 49b8b354781..dff04e38cf8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -143,7 +143,7 @@ describe('AddRuleSetDrawer', () => { // Description expect( getByText( - 'RuleSets are reusable collections of Cloud Firewall rules that use the same fields as individual rules. They let you manage and update multiple rules as a group. You can then apply them across different firewalls by reference.' + 'Rule Sets are reusable collections of Cloud Firewall rules that use the same fields as individual rules. They let you manage and update multiple rules as a group. You can then apply them across different firewalls by reference.' ) ).toBeVisible(); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 5378e7a1386..fed47d634ea 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -107,13 +107,10 @@ export const FirewallRuleSetDetailsView = ( }, ].map((item, idx) => ( {item.label && ( {item.label}: diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.test.tsx new file mode 100644 index 00000000000..54155903ab1 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.test.tsx @@ -0,0 +1,64 @@ +import { firewallRuleSetFactory } from 'src/factories'; + +import { filterRuleSets } from './FirewallRuleSetForm'; + +import type { FirewallRuleSet } from '@linode/api-v4'; + +describe('filterRuleSets', () => { + const mockRuleSets: FirewallRuleSet[] = [ + firewallRuleSetFactory.build({ + id: 1, + type: 'inbound', + label: 'Inbound RS', + deleted: null, + }), + firewallRuleSetFactory.build({ + id: 2, + type: 'outbound', + label: 'Outbound RS', + deleted: null, + }), + firewallRuleSetFactory.build({ + id: 3, + type: 'inbound', + label: 'Deleted RS', + deleted: '2025-11-18T18:51:11', // Marked for Deletion Rule Set + }), + firewallRuleSetFactory.build({ + id: 4, + type: 'inbound', + label: 'Already Used RS', + deleted: null, + }), + ]; + + it('returns only ruleSets of the correct type', () => { + const result = filterRuleSets({ + ruleSets: mockRuleSets, + category: 'inbound', + inboundAndOutboundRules: [], + }); + + expect(result.map((r) => r.id)).toEqual([1, 4]); + }); + + it('excludes ruleSets already referenced by the firewall', () => { + const result = filterRuleSets({ + ruleSets: mockRuleSets, + category: 'inbound', + inboundAndOutboundRules: [{ ruleset: 4 }], + }); + + expect(result.map((r) => r.id)).toEqual([1]); // excludes id=4 + }); + + it('excludes ruleSets marked for deletion', () => { + const result = filterRuleSets({ + ruleSets: mockRuleSets, + category: 'inbound', + inboundAndOutboundRules: [], + }); + + expect(result.some((r) => r.id === 3)).toBe(false); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 1fec51c09c6..9faeec08867 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -22,6 +22,39 @@ import { import { StyledLabel, StyledListItem, useStyles } from './shared.styles'; import type { FirewallRuleSetFormProps } from './FirewallRuleDrawer.types'; +import type { Category } from './shared'; +import type { FirewallRuleSet, FirewallRuleType } from '@linode/api-v4'; + +interface FilterRuleSetsArgs { + category: Category; + inboundAndOutboundRules: FirewallRuleType[]; + ruleSets: FirewallRuleSet[]; +} + +/** + * Display only those Rule Sets that: + * - are applicable to the given category + * - are not already referenced by the firewall + * - are not marked for deletion + */ +export const filterRuleSets = ({ + ruleSets, + category, + inboundAndOutboundRules, +}: FilterRuleSetsArgs) => { + return ruleSets.filter((ruleSet) => { + // TODO: Firewall RuleSets: Remove this client-side filter once the API supports filtering by the 'type' field + const isCorrectType = ruleSet.type === category; + + const isNotAlreadyReferenced = !inboundAndOutboundRules.some( + (rule) => rule.ruleset === ruleSet.id + ); + + const isNotMarkedForDeletion = ruleSet.deleted === null; + + return isCorrectType && isNotAlreadyReferenced && isNotMarkedForDeletion; + }); +}; export const FirewallRuleSetForm = React.memo( (props: FirewallRuleSetFormProps) => { @@ -58,19 +91,14 @@ export const FirewallRuleSetForm = React.memo( // Build dropdown options const ruleSetDropdownOptions = React.useMemo( () => - ruleSets - // TODO: Firewall RuleSets: Remove this client-side filter once the API supports filtering by the 'type' field - .filter( - (ruleSet) => - ruleSet.type === category && - !inboundAndOutboundRules.some( - (rule) => rule.ruleset === ruleSet.id - ) - ) // Display only rule sets applicable to the given category and filter out rule sets already referenced by the FW - .map((ruleSet) => ({ - label: ruleSet.label, - value: ruleSet.id, - })), + filterRuleSets({ + ruleSets, + category, + inboundAndOutboundRules, + }).map((ruleSet) => ({ + label: ruleSet.label, + value: ruleSet.id, + })), [ruleSets] ); @@ -83,7 +111,7 @@ export const FirewallRuleSetForm = React.memo( ({ marginTop: theme.spacingFunction(16) })} > - RuleSets are reusable collections of Cloud Firewall rules that use + Rule Sets are reusable collections of Cloud Firewall rules that use the same fields as individual rules. They let you manage and update multiple rules as a group. You can then apply them across different firewalls by reference. diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 69a4a59f3e6..2b628249009 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -397,8 +397,6 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { return ; } - const ruleSetCopyableId = `${rulesetDetails ? 'ID:' : 'Ruleset ID:'} ${ruleset}`; - return ( { )} - + {rulesetDetails ? 'ID: ' : 'Rule Set ID: '} + diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 517d7d90aeb..2ee368fe3d2 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -547,10 +547,7 @@ export const FirewallRulesLanding = React.memo((props: Props) => { modeViewedFrom: ruleDrawer.mode, }); }} - inboundAndOutboundRules={[ - ...(rules.inbound ?? []), - ...(rules.outbound ?? []), - ]} + inboundAndOutboundRules={[...inboundRules, ...outboundRules]} isOpen={ location.pathname.endsWith('add/inbound') || location.pathname.endsWith('add/outbound') || diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.test.tsx index 91c29ef660d..9022d372ecb 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.test.tsx @@ -54,6 +54,7 @@ describe('MultiplePrefixListSelect', () => { }, // supported (supports only ipv4) { name: 'pl:system:supports-both', ipv4: [], ipv6: [] }, // supported (supports both) { name: 'pl:system:not-supported', ipv4: null, ipv6: null }, // unsupported + { name: 'pl::vpcs:' }, // Special Prefix List (Doesn't have IPv4 & IPv6) ]; queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ @@ -177,7 +178,7 @@ describe('MultiplePrefixListSelect', () => { getByDisplayValue('pl:system:supports-only-ipv4'); }); - it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports both', async () => { + it('defaults to both IPv4 and IPv6 selected when choosing a PL that supports both', async () => { const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; const { findByText, getByRole } = renderWithTheme( @@ -196,7 +197,31 @@ describe('MultiplePrefixListSelect', () => { { address: 'pl::supports-both', inIPv4Rule: true, - inIPv6Rule: false, + inIPv6Rule: true, + }, + ]); + }); + + it('defaults to both IPv4 and IPv6 selected when choosing a Special PL', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the Special PL name to filter the dropdown + await userEvent.type(input, 'pl::vpcs:'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl::vpcs:'); + await userEvent.click(option); + + expect(baseProps.onChange).toHaveBeenCalledWith([ + { + address: 'pl::vpcs:', + inIPv4Rule: true, + inIPv6Rule: true, }, ]); }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx index 52609f4d052..096cbf88b33 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx @@ -84,17 +84,20 @@ const getDefaultPLReferenceState = ( ): { inIPv4Rule: boolean; inIPv6Rule: boolean } => { if (support === null) { // Special Prefix List case - return { inIPv4Rule: true, inIPv6Rule: false }; + return { inIPv4Rule: true, inIPv6Rule: true }; } const { isPLIPv4Unsupported, isPLIPv6Unsupported } = support; + // Supports both IPv4 & IPv6 if (!isPLIPv4Unsupported && !isPLIPv6Unsupported) - return { inIPv4Rule: true, inIPv6Rule: false }; + return { inIPv4Rule: true, inIPv6Rule: true }; + // Supports only IPv4 if (!isPLIPv4Unsupported && isPLIPv6Unsupported) return { inIPv4Rule: true, inIPv6Rule: false }; + // Supports only Ipv6 if (isPLIPv4Unsupported && !isPLIPv6Unsupported) return { inIPv4Rule: false, inIPv6Rule: true }; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts index 391bf1ace37..e3304b4f7a8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts @@ -12,16 +12,20 @@ import type { FirewallPolicyType } from '@linode/api-v4'; import type { Theme } from '@linode/ui'; interface StyledListItemProps { + fieldsMode?: boolean; paddingMultiplier?: number; // optional, default 1 } export const StyledListItem = styled(Typography, { label: 'StyledTypography', - shouldForwardProp: omittedProps(['paddingMultiplier']), -})(({ theme, paddingMultiplier = 1 }) => ({ - alignItems: 'center', + shouldForwardProp: omittedProps(['fieldsMode', 'paddingMultiplier']), +})(({ theme, fieldsMode, paddingMultiplier = 1 }) => ({ + alignItems: fieldsMode ? 'flex-start' : 'center', display: 'flex', padding: `${theme.spacingFunction(4 * paddingMultiplier)} 0`, + ...(fieldsMode && { + flexWrap: 'wrap', // Longer labels start on the next line + }), })); export const StyledLabel = styled(Box, { @@ -71,8 +75,7 @@ export const useStyles = makeStyles()((theme: Theme) => ({ width: '1em', }, color: theme.palette.primary.main, - display: 'inline-block', + display: 'flex', position: 'relative', - marginTop: theme.spacingFunction(4), }, })); diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index 588ef58e7f9..06ab02a1018 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -53,7 +53,7 @@ export const IdentityAccessLanding = React.memo(() => { }; if (location.pathname === '/iam') { - navigate({ to: '/iam/users' }); + navigate({ to: '/iam/users', replace: true }); } return ( diff --git a/packages/manager/src/features/IAM/Roles/Roles.test.tsx b/packages/manager/src/features/IAM/Roles/Roles.test.tsx index 99c0e2f21fb..680fb22cd6b 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.test.tsx @@ -59,6 +59,7 @@ describe('RolesLanding', () => { const mockPermissions = accountRolesFactory.build(); queryMocks.usePermissions.mockReturnValue({ data: { + view_account: true, is_account_admin: true, }, }); @@ -75,6 +76,7 @@ describe('RolesLanding', () => { it('should show an error message if user does not have permissions', () => { queryMocks.usePermissions.mockReturnValue({ data: { + view_account: false, is_account_admin: false, }, }); @@ -88,6 +90,7 @@ describe('RolesLanding', () => { it('should not show the default roles panel for non-child accounts', () => { queryMocks.usePermissions.mockReturnValue({ data: { + view_account: true, is_account_admin: true, }, }); @@ -106,6 +109,7 @@ describe('RolesLanding', () => { it('should show the default roles panel for child accounts', () => { queryMocks.usePermissions.mockReturnValue({ data: { + view_account: true, is_account_admin: true, }, }); diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 787b68ed03f..21a1cabbdc5 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -13,10 +13,10 @@ import { DefaultRolesPanel } from './Defaults/DefaultRolesPanel'; export const RolesLanding = () => { const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( 'account', - ['is_account_admin'] + ['view_account', 'is_account_admin'] ); const { data: accountRoles, isLoading } = useAccountRoles( - permissions?.is_account_admin + permissions?.view_account ); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { isChildAccount, isProfileLoading } = useDelegationRole(); @@ -33,7 +33,7 @@ export const RolesLanding = () => { return ; } - if (!permissions?.is_account_admin) { + if (!(permissions?.view_account || permissions?.is_account_admin)) { return ( You do not have permission to view roles. ); diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index b924f7ed18b..3b331b7a77d 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -224,10 +224,10 @@ export const RolesTable = ({ roles = [] }: Props) => { onClick={() => handleAssignSelectedRoles()} sx={{ height: 34 }} tooltipText={ - selectedRows.length === 0 - ? 'You must select some roles to assign them.' - : !isAccountAdmin - ? 'You do not have permission to assign roles.' + !isAccountAdmin + ? 'You do not have permission to assign roles.' + : selectedRows.length === 0 + ? 'You must select some roles to assign them.' : undefined } > @@ -305,6 +305,7 @@ export const RolesTable = ({ roles = [] }: Props) => { selected={selectedRows.includes(roleRow)} > { alignItems: 'center', justifyContent: 'flex-start', marginBottom: theme.tokens.spacing.S12, + minHeight: theme.spacingFunction(40), }} > { ) : ( { alignItems: 'center', justifyContent: 'space-between', marginBottom: theme.tokens.spacing.S12, + minHeight: theme.spacingFunction(40), }} > diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx index 548c4a0d63a..f78689acdd7 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx @@ -196,32 +196,4 @@ describe('ChangeRoleDrawer', () => { mockAccountAccessRole.name ); }); - - it('should not list roles that the user already has', async () => { - queryMocks.useUserRoles.mockReturnValue({ - data: { - account_access: ['account_linode_admin', 'account_viewer'], - entity_access: [], - }, - }); - - queryMocks.useAccountRoles.mockReturnValue({ - data: accountRolesFactory.build(), - }); - - renderWithTheme(); - - const autocomplete = screen.getByRole('combobox'); - - await userEvent.click(autocomplete); - - // expect select not to have the current role as one of the options - const options = screen.getAllByRole('option'); - expect(options.map((option) => option.textContent)).not.toContain( - 'account_linode_admin' - ); - expect(options.map((option) => option.textContent)).not.toContain( - 'account_viewer' - ); - }); }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx index b94b717a772..78836d51596 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx @@ -92,22 +92,15 @@ export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => { el.value !== role?.name; // Exclude account roles already assigned to the user if (isAccountRole(el)) { - return ( - !assignedRoles?.account_access.includes(el.value) && - matchesRoleContext - ); + return matchesRoleContext; } // Exclude entity roles already assigned to the user if (isEntityRole(el)) { - return ( - !assignedRoles?.entity_access.some((entity) => - entity.roles.includes(el.value) - ) && matchesRoleContext - ); + return matchesRoleContext; } return true; }); - }, [accountRoles, assignedRoles, role]); + }, [accountRoles, role]); const { control, diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx index aa9377a1490..0de888dd239 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx @@ -26,7 +26,7 @@ describe('UserEmailPanel', () => { const user = accountUserFactory.build(); const { getByLabelText } = renderWithTheme( - + ); const emailTextField = getByLabelText('Email'); @@ -45,7 +45,7 @@ describe('UserEmailPanel', () => { ); const { findByLabelText, getByLabelText, getByText } = renderWithTheme( - + ); const warning = await findByLabelText( @@ -70,7 +70,7 @@ describe('UserEmailPanel', () => { }); const { getByLabelText, getByText } = renderWithTheme( - + ); const warning = getByLabelText('This field can’t be modified.'); @@ -94,7 +94,7 @@ describe('UserEmailPanel', () => { username: 'user-1', }); - renderWithTheme(); + renderWithTheme(); const emailInput = screen.getByLabelText('Email'); @@ -114,7 +114,7 @@ describe('UserEmailPanel', () => { }); const { getByRole, findByDisplayValue } = renderWithTheme( - + ); await findByDisplayValue(user.email); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx index 8c603fb1283..613476c11a2 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx @@ -14,10 +14,9 @@ import type { User } from '@linode/api-v4'; interface Props { activeUser: User; - canUpdateUser: boolean; } -export const UserEmailPanel = ({ canUpdateUser, activeUser }: Props) => { +export const UserEmailPanel = ({ activeUser }: Props) => { const { enqueueSnackbar } = useSnackbar(); const { profileUserName } = useDelegationRole(); @@ -79,11 +78,11 @@ export const UserEmailPanel = ({ canUpdateUser, activeUser }: Props) => { /> diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.data.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.data.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.data.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.data.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.test.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.tsx similarity index 99% rename from packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.tsx index 043356c899e..596ea4c01cc 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.tsx @@ -20,7 +20,7 @@ import { } from 'src/queries/object-storage/queries'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -import { bucketACLOptions, objectACLOptions } from '../utilities'; +import { bucketACLOptions, objectACLOptions } from '../../utilities'; import { copy } from './AccessSelect.data'; import type { diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/BucketAccess.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/BucketAccess.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts deleted file mode 100644 index 3938a805792..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ActionsPanel, Paper, Typography } from '@linode/ui'; -import { styled } from '@mui/material/styles'; - -export const StyledText = styled(Typography, { - label: 'StyledText', -})(({ theme }) => ({ - lineHeight: 0.5, - paddingLeft: 8, - [theme.breakpoints.down('lg')]: { - marginLeft: 8, - }, - [theme.breakpoints.down('sm')]: { - lineHeight: 1, - }, -})); - -export const StyledRootContainer = styled(Paper, { - label: 'StyledRootContainer', -})(({ theme }) => ({ - marginTop: 25, - padding: theme.spacing(3), -})); - -export const StyledActionsPanel = styled(ActionsPanel, { - label: 'StyledActionsPanel', -})(() => ({ - display: 'flex', - justifyContent: 'right', - padding: 0, -})); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx deleted file mode 100644 index 36b8ccaa0a3..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useSearch } from '@tanstack/react-router'; -import * as React from 'react'; - -import { BucketRateLimitTable } from '../BucketLanding/BucketRateLimitTable'; -import { BucketBreadcrumb } from './BucketBreadcrumb'; -import { - StyledActionsPanel, - StyledRootContainer, - StyledText, -} from './BucketProperties.styles'; - -import type { ObjectStorageBucket } from '@linode/api-v4'; - -interface Props { - bucket: ObjectStorageBucket; -} - -export const BucketProperties = React.memo((props: Props) => { - const { bucket } = props; - const { endpoint_type, hostname, label } = bucket; - const { prefix = '' } = useSearch({ - from: '/object-storage/buckets/$clusterId/$bucketName', - }); - - return ( - <> - - {hostname} - - - - {/* TODO: OBJGen2 - This will be handled once we receive API for bucket rates */} - - - - ); -}); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/CertificatesTab/BucketSSL.test.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/CertificatesTab/BucketSSL.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/CertificatesTab/BucketSSL.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/CertificatesTab/BucketSSL.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx new file mode 100644 index 00000000000..312c33d8ab8 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { CloudPulseDashboardWithFilters } from 'src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters'; + +interface Props { + bucketName: string; + clusterId: string; +} + +export const MetricsTab = ({ bucketName, clusterId }: Props) => { + return ( + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.styles.ts b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.styles.ts similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.styles.ts rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.styles.ts diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.tsx similarity index 98% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.tsx index 9828f3474ef..7c2ad2ceed3 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; -import { prefixArrayToString } from '../utilities'; +import { prefixArrayToString } from '../../utilities'; import { StyledCopyTooltip, StyledLink, diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.styles.ts b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.styles.ts similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.styles.ts rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.styles.ts diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.tsx similarity index 98% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.tsx index fbb79c18d48..f843b4caa61 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.tsx @@ -30,14 +30,14 @@ import { import { fetchBucketAndUpdateCache } from 'src/queries/object-storage/utilities'; import { sendDownloadObjectEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { QuotasInfoNotice } from '../QuotasInfoNotice'; -import { deleteObject as _deleteObject } from '../requests'; +import { QuotasInfoNotice } from '../../QuotasInfoNotice'; +import { deleteObject as _deleteObject } from '../../requests'; import { displayName, generateObjectUrl, isEmptyObjectForFolder, tableUpdateAction, -} from '../utilities'; +} from '../../utilities'; import { BucketBreadcrumb } from './BucketBreadcrumb'; import { StyledCreateFolderButton, diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/CreateFolderDrawer.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/CreateFolderDrawer.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderActionMenu.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderActionMenu.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.test.tsx similarity index 85% rename from packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.test.tsx index ede3b0762af..d1433c90aa4 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.test.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { FolderTableRow } from 'src/features/ObjectStorage/BucketDetail/FolderTableRow'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; -import type { FolderTableRowProps } from 'src/features/ObjectStorage/BucketDetail/FolderTableRow'; +import { FolderTableRow } from './FolderTableRow'; + +import type { FolderTableRowProps } from './FolderTableRow'; vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectActionMenu.test.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectActionMenu.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectActionMenu.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectActionMenu.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx similarity index 98% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx index 33853d171d0..88d566f93ac 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx @@ -10,7 +10,7 @@ import { useIsObjectStorageGen2Enabled } from 'src/features/ObjectStorage/hooks/ import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { formatDate } from 'src/utilities/formatDate'; -import { AccessSelect } from './AccessSelect'; +import { AccessSelect } from '../AccessTab/AccessSelect'; export interface ObjectDetailsDrawerProps { bucketName: string; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableContent.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableContent.tsx similarity index 99% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableContent.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableContent.tsx index 618ffdf4562..d780fb63d34 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableContent.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableContent.tsx @@ -6,7 +6,7 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; -import { displayName, isEmptyObjectForFolder, isFolder } from '../utilities'; +import { displayName, isEmptyObjectForFolder, isFolder } from '../../utilities'; import { FolderTableRow } from './FolderTableRow'; import { ObjectTableRow } from './ObjectTableRow'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableRow.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableRow.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx index 7d9c427e6da..4c6775c1685 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx @@ -1,3 +1,4 @@ +import { BetaChip, CircleProgress, ErrorState } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; @@ -9,30 +10,50 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useIsObjectStorageGen2Enabled } from 'src/features/ObjectStorage/hooks/useIsObjectStorageGen2Enabled'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; -import { BucketAccess } from './BucketAccess'; - const ObjectList = React.lazy(() => - import('./BucketDetail').then((module) => ({ default: module.BucketDetail })) + import('./ObjectsTab/BucketDetail').then((module) => ({ + default: module.BucketDetail, + })) +); + +const BucketAccess = React.lazy(() => + import('./AccessTab/BucketAccess').then((module) => ({ + default: module.BucketAccess, + })) ); + const BucketSSL = React.lazy(() => - import('./BucketSSL').then((module) => ({ + import('./CertificatesTab/BucketSSL').then((module) => ({ default: module.BucketSSL, })) ); +const BucketMetrics = React.lazy(() => + import('./MetricsTab/MetricsTab').then((module) => ({ + default: module.MetricsTab, + })) +); + +const BUCKET_DETAILS_URL = '/object-storage/buckets/$clusterId/$bucketName'; + export const BucketDetailLanding = React.memo(() => { const { bucketName, clusterId } = useParams({ - from: '/object-storage/buckets/$clusterId/$bucketName', + from: BUCKET_DETAILS_URL, }); + const { aclpServices } = useFlags(); const { isObjectStorageGen2Enabled } = useIsObjectStorageGen2Enabled(); - const { data: bucketsData } = useObjectStorageBuckets( - isObjectStorageGen2Enabled - ); + const { + data: bucketsData, + isLoading, + error, + isPending, + } = useObjectStorageBuckets(isObjectStorageGen2Enabled); const bucket = bucketsData?.buckets.find(({ label }) => label === bucketName); @@ -40,23 +61,39 @@ export const BucketDetailLanding = React.memo(() => { const isGen2Endpoint = endpoint_type === 'E2' || endpoint_type === 'E3'; - const { handleTabChange, tabIndex, tabs } = useTabs([ + const { handleTabChange, tabIndex, tabs, getTabIndex } = useTabs([ { title: 'Objects', - to: `/object-storage/buckets/$clusterId/$bucketName/objects`, + to: `${BUCKET_DETAILS_URL}/objects`, }, { title: 'Access', - to: `/object-storage/buckets/$clusterId/$bucketName/access`, + to: `${BUCKET_DETAILS_URL}/access`, }, - { - hide: !bucketsData || isGen2Endpoint, title: 'SSL/TLS', - to: `/object-storage/buckets/$clusterId/$bucketName/ssl`, + to: `${BUCKET_DETAILS_URL}/ssl`, + hide: isGen2Endpoint, + }, + { + title: 'Metrics', + to: `${BUCKET_DETAILS_URL}/metrics`, + hide: !aclpServices?.objectstorage?.metrics?.enabled, + chip: aclpServices?.objectstorage?.metrics?.beta ? : null, }, ]); + if (isPending || isLoading) { + return ; + } + + if (!bucket || error) { + return ; + } + + const sslTabIndex = getTabIndex(`${BUCKET_DETAILS_URL}/ssl`); + const metricsTabIndex = getTabIndex(`${BUCKET_DETAILS_URL}/metrics`); + return ( <> @@ -85,6 +122,7 @@ export const BucketDetailLanding = React.memo(() => { + { endpointType={endpoint_type} /> - - - + + {!!sslTabIndex && ( + + + + )} + + {!!metricsTabIndex && ( + + + + )} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 95caecac563..6ffcf11205f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -10,7 +10,7 @@ import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { useObjectStorageClusters } from 'src/queries/object-storage/queries'; import { formatDate } from 'src/utilities/formatDate'; -import { AccessSelect } from '../BucketDetail/AccessSelect'; +import { AccessSelect } from '../BucketDetail/AccessTab/AccessSelect'; import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; import type { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 604338a6e3b..ebc7372582b 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -2152,4 +2152,50 @@ export const oneClickApps: Record = { summary: `All-in-one distributed tracing platform with integrated UI, collector, and storage for monitoring microservices.`, website: 'https://www.jaegertracing.io/', }, + 1966222: { + alt_description: + 'Unified observability platform that brings together search, data processing, and visualization through Elasticsearch, Logstash, and Kibana.', + alt_name: 'Log aggregation platform.', + categories: ['Monitoring', 'Security'], + colors: { + end: '0077cc', + start: 'f04e98', + }, + description: + 'The Elastic Stack (known as ELK) is well-suited for log aggregation, application monitoring, infrastructure observability, and security analytics. Its open architecture and extensive ecosystem make it adaptable to a wide range of use cases including distributed system debugging, SIEM workflows, API performance monitoring, and centralized logging across cloud and hybrid environments.', + isNew: true, + logo_url: 'elasticstack.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/elastic-stack/', + title: 'Deploy An Elastic Stack through the Linode Marketplace', + }, + ], + summary: + 'Log aggregation platform that brings together search, data processing, and visualization.', + website: 'https://www.elastic.co/', + }, + 1966231: { + alt_description: + 'Weaviate is an open-source vector database designed to store and index both data objects and their vector embeddings.', + alt_name: 'Open-source vector database.', + categories: ['Databases'], + colors: { + end: 'c4d132', + start: '53b83d', + }, + description: + 'Weaviate is an open-source AI-native vector database designed for building advanced AI applications. It stores and indexes both data objects and their vector embeddings, enabling semantic search, hybrid search, and Retrieval Augmented Generation (RAG) workflows. This deployment includes GPU acceleration for transformer models and comes pre-configured with the sentence-transformers model for high-performance semantic search capabilities.', + isNew: true, + logo_url: 'weaviate.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/weaviate/', + title: 'Deploy Weaviate through the Linode Marketplace', + }, + ], + summary: + 'AI-native vector database designed for building advanced AI applications.', + website: 'https://docs.weaviate.io/weaviate', + }, }; diff --git a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx index 25935ebaee0..cba276d3914 100644 --- a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx +++ b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx @@ -7,6 +7,7 @@ import DatabaseIcon from 'src/assets/icons/entityIcons/database.svg'; import NetworkIcon from 'src/assets/icons/entityIcons/networking.svg'; import StorageIcon from 'src/assets/icons/entityIcons/storage.svg'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; +import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { @@ -30,10 +31,11 @@ export type CreateEntity = | 'Image' | 'Kubernetes' | 'Linode' - | 'Marketplace' + | 'Marketplace' // TODO: Cloud Manager Marketplace - Remove marketplace references once 'Quick Deploy Apps' is fully rolled out | 'NodeBalancer' | 'Object Storage' | 'Placement Group' + | 'Quick Deploy Apps' | 'Volume' | 'VPC'; @@ -52,6 +54,7 @@ export const CreateMenu = () => { const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); + const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -90,7 +93,9 @@ export const CreateMenu = () => { { attr: { 'data-qa-one-click-add-new': true }, description: 'Deploy applications with ease', - display: 'Marketplace', + display: !isMarketplaceV2FeatureEnabled + ? 'Marketplace' + : 'Quick Deploy Apps', to: '/linodes/create/marketplace', }, ], diff --git a/packages/manager/src/features/VPCs/components/PublicAccess.tsx b/packages/manager/src/features/VPCs/components/PublicAccess.tsx index 3cac4a4ab50..c3727cbb20c 100644 --- a/packages/manager/src/features/VPCs/components/PublicAccess.tsx +++ b/packages/manager/src/features/VPCs/components/PublicAccess.tsx @@ -24,6 +24,8 @@ interface Props { handleAllowPublicIPv6AccessChange: ( e: React.ChangeEvent ) => void; + publicIPv4Error?: string; + publicIPv6Error?: string; showIPv6Content: boolean; sx?: SxProps; userCannotAssignLinodes: boolean; @@ -35,6 +37,8 @@ export const PublicAccess = (props: Props) => { allowPublicIPv6Access, handleAllowPublicIPv4AccessChange, handleAllowPublicIPv6AccessChange, + publicIPv4Error, + publicIPv6Error, showIPv6Content, sx, userCannotAssignLinodes, @@ -61,22 +65,42 @@ export const PublicAccess = (props: Props) => { } onChange={handleAllowPublicIPv4AccessChange} /> + {allowPublicIPv4Access && publicIPv4Error && ( + ({ + color: theme.color.red, + })} + > + {publicIPv4Error} + + )} {showIPv6Content && ( - } - disabled={userCannotAssignLinodes} - label={ - - Allow public IPv6 access - - - } - onChange={handleAllowPublicIPv6AccessChange} - /> + <> + } + disabled={userCannotAssignLinodes} + label={ + + Allow public IPv6 access + + + } + onChange={handleAllowPublicIPv6AccessChange} + /> + {allowPublicIPv6Access && publicIPv6Error && ( + ({ + color: theme.color.red, + })} + > + {publicIPv6Error} + + )} + )} ); diff --git a/packages/manager/src/hooks/useAdobeAnalytics.ts b/packages/manager/src/hooks/useAdobeAnalytics.ts index 30cbe4fc320..2ed49184f36 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -5,11 +5,15 @@ import React from 'react'; import { ADOBE_ANALYTICS_URL } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; +import { useEuuidFromHttpHeader } from './useEuuidFromHttpHeader'; + /** * Initializes our Adobe Analytics script on mount and subscribes to page view events. + * The EUUID is read from the profile data (injected by the injectEuuidToProfile interceptor). */ export const useAdobeAnalytics = () => { const location = useLocation(); + const { euuid } = useEuuidFromHttpHeader(); React.useEffect(() => { // Load Adobe Analytics Launch Script @@ -26,6 +30,7 @@ export const useAdobeAnalytics = () => { // Fire the first page view for the landing page window._satellite.track('page view', { url: window.location.pathname, + ...(euuid && { euuid }), }); }) .catch(() => { @@ -36,11 +41,13 @@ export const useAdobeAnalytics = () => { React.useEffect(() => { /** - * Send pageviews when location changes + * Send pageviews when location changes. + * Includes EUUID (Enterprise UUID) if available from the profile response. */ if (window._satellite) { window._satellite.track('page view', { url: location.pathname, + ...(euuid && { euuid }), }); } }, [location.pathname]); // Listen to location changes diff --git a/packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts b/packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts new file mode 100644 index 00000000000..eff361103e4 --- /dev/null +++ b/packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts @@ -0,0 +1,55 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; + +import { useEuuidFromHttpHeader } from './useEuuidFromHttpHeader'; + +describe('useEuuidFromHttpHeader', () => { + it('returns EUUID when the header is included', async () => { + const mockEuuid = 'test-euuid-12345'; + + server.use( + http.get('*/profile', () => { + return new HttpResponse(null, { + headers: { 'X-Customer-Uuid': mockEuuid }, + }); + }) + ); + + const { result } = renderHook(() => useEuuidFromHttpHeader(), { + wrapper: (ui) => wrapWithTheme(ui), + }); + + await waitFor(() => { + expect(result.current.euuid).toBe(mockEuuid); + }); + }); + + it('returns undefined when the header is not included', async () => { + server.use( + http.get('*/profile', () => { + return new HttpResponse(null, { + headers: {}, + }); + }) + ); + + const { result } = renderHook(() => useEuuidFromHttpHeader(), { + wrapper: (ui) => wrapWithTheme(ui), + }); + + await waitFor(() => { + expect(result.current.euuid).toBeUndefined(); + }); + }); + + it('returns undefined when profile is loading', () => { + const { result } = renderHook(() => useEuuidFromHttpHeader(), { + wrapper: (ui) => wrapWithTheme(ui), + }); + + // Before the profile loads, euuid should be undefined + expect(result.current.euuid).toBeUndefined(); + }); +}); diff --git a/packages/manager/src/hooks/useEuuidFromHttpHeader.ts b/packages/manager/src/hooks/useEuuidFromHttpHeader.ts new file mode 100644 index 00000000000..5d066dc31dd --- /dev/null +++ b/packages/manager/src/hooks/useEuuidFromHttpHeader.ts @@ -0,0 +1,16 @@ +import { useProfile } from '@linode/queries'; + +import type { UseQueryResult } from '@tanstack/react-query'; +import type { ProfileWithEuuid } from 'src/request'; + +/** + * Hook to get the customer EUUID (Enterprise UUID) from the profile data. + * The EUUID is injected by the injectEuuidToProfile interceptor from the + * X-Customer-Uuid header. + * + * NOTE: this won't work locally (only staging and prod return this header) + */ +export const useEuuidFromHttpHeader = () => ({ + euuid: (useProfile() as UseQueryResult).data + ?._euuidFromHttpHeader, +}); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 53b7dd66f8d..6399ab7d1e5 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -21,6 +21,10 @@ import { linodeStatsFactory, linodeTransferFactory, linodeTypeFactory, + marketplaceCategoryFactory, + marketplacePartnersFactory, + marketplaceProductFactory, + marketplaceTypeFactory, nodeBalancerConfigFactory, nodeBalancerConfigNodeFactory, nodeBalancerFactory, @@ -52,6 +56,7 @@ import { creditPaymentResponseFactory, dashboardFactory, databaseBackupFactory, + databaseConnectionPoolFactory, databaseEngineFactory, databaseFactory, databaseInstanceFactory, @@ -210,6 +215,11 @@ const makeMockDatabase = (params: PathParams): Database => { db.ssl_connection = true; } + + if (db.engine === 'postgresql') { + db.connection_pool_port = 100; + } + const database = databaseFactory.build(db); if (database.platform !== 'rdbms-default') { @@ -369,6 +379,11 @@ const databases = [ return HttpResponse.json(makeResourcePage(combinedList)); }), + http.get('*/databases/postgresql/instances/:id/connection-pools', () => { + const connectionPools = databaseConnectionPoolFactory.buildList(5); + return HttpResponse.json(makeResourcePage(connectionPools)); + }), + http.get('*/databases/:engine/instances/:id', ({ params }) => { const database = makeMockDatabase(params); return HttpResponse.json(database); @@ -607,6 +622,43 @@ const netLoadBalancers = [ ), ]; +const marketplace = [ + http.get('*/v4beta/marketplace/products', () => { + const marketplaceProduct = marketplaceProductFactory.buildList(10); + return HttpResponse.json(makeResourcePage([...marketplaceProduct])); + }), + http.get('*/v4beta/marketplace/products/:productId', () => { + const marketplaceProductDetail = marketplaceProductFactory.build({ + details: { + overview: { + description: + 'This is a detailed description of the marketplace product.', + }, + pricing: 'Pricing information goes here.', + documentation: 'Documentation link or information goes here.', + support: 'Support information goes here.', + }, + }); + return HttpResponse.json(marketplaceProductDetail); + }), + http.get('*/v4beta/marketplace/categories', () => { + const marketplaceCategory = marketplaceCategoryFactory.buildList(5); + return HttpResponse.json(makeResourcePage([...marketplaceCategory])); + }), + http.get('*/v4beta/marketplace/types', () => { + const marketplaceType = marketplaceTypeFactory.buildList(5); + return HttpResponse.json(makeResourcePage([...marketplaceType])); + }), + http.get('*/v4beta/marketplace/partners', () => { + const marketplaceType = marketplacePartnersFactory.buildList(5); + return HttpResponse.json(makeResourcePage([...marketplaceType])); + }), + http.post('*/v4beta/marketplace/referral', async () => { + await sleep(2000); + return HttpResponse.json({}); + }), +]; + const nanodeType = linodeTypeFactory.build({ id: 'g6-nanode-1' }); const standardTypes = linodeTypeFactory.buildList(7); const dedicatedTypes = dedicatedTypeFactory.buildList(7); @@ -671,7 +723,11 @@ export const handlers = [ // restricted: true, // user_type: 'default', }); - return HttpResponse.json(profile); + return HttpResponse.json(profile, { + headers: { + 'X-Customer-UUID': '51C68049-266E-451B-80ABFC92B5B9D576', + }, + }); }), http.put('*/profile', async ({ request }) => { @@ -3563,9 +3619,32 @@ export const handlers = [ return HttpResponse.json({}); }), http.get('*/monitor/alert-channels', () => { - return HttpResponse.json( - makeResourcePage(notificationChannelFactory.buildList(7)) + const notificationChannels = notificationChannelFactory.buildList(3); + notificationChannels.push( + notificationChannelFactory.build({ + label: 'Email test channel', + updated: '2023-11-05T04:00:00', + updated_by: 'user3', + created_by: 'admin', + details: { + email: { + usernames: ['user1', 'user2'], + recipient_type: 'user', + }, + }, + }) + ); + notificationChannels.push( + notificationChannelFactory.build({ + label: 'System channel', + updated: '2023-11-05T04:00:00', + updated_by: 'user5', + created_by: 'admin', + type: 'system', + }) ); + notificationChannels.push(...notificationChannelFactory.buildList(75)); + return HttpResponse.json(makeResourcePage(notificationChannels)); }), http.get('*/monitor/services', () => { const response: ServiceTypesList = { @@ -4407,9 +4486,34 @@ export const handlers = [ ...vpc, ...entities, ...netLoadBalancers, + ...marketplace, http.get('*/v4beta/maintenance/policies', () => { return HttpResponse.json( makeResourcePage(maintenancePolicyFactory.buildList(2)) ); }), + http.post('*/v4beta/monitor/alert-channels', () => { + return HttpResponse.json(notificationChannelFactory.build()); + }), + http.put('*/monitor/alert-channels/:id', () => { + return HttpResponse.json(notificationChannelFactory.build()); + }), + http.get('*/monitor/alert-channels/:id', () => { + return HttpResponse.json( + notificationChannelFactory.build({ + id: 5, + label: 'Email test channel', + updated: '2023-11-05T04:00:00', + updated_by: 'user3', + created_by: 'admin', + type: 'user', + channel_type: 'email', + details: { + email: { + usernames: ['ChildUser', 'NonAdminUser'], + }, + }, + }) + ); + }), ]; diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index c0ea1b86b13..71e5d75ba30 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -1,9 +1,11 @@ import { addEntityToAlert, createAlertDefinition, + createNotificationChannel, deleteAlertDefinition, deleteEntityFromAlert, editAlertDefinition, + updateNotificationChannel, updateServiceAlerts, } from '@linode/api-v4/lib/cloudpulse'; import { queryPresets } from '@linode/queries'; @@ -21,8 +23,10 @@ import type { Alert, CloudPulseAlertsPayload, CreateAlertDefinitionPayload, + CreateNotificationChannelPayload, DeleteAlertPayload, EditAlertPayloadWithService, + EditNotificationChannelPayloadWithId, EntityAlertUpdatePayload, NotificationChannel, } from '@linode/api-v4/lib/cloudpulse'; @@ -258,3 +262,81 @@ export const useServiceAlertsMutation = ( }, }); }; + +export const useCreateNotificationChannel = () => { + const queryClient = useQueryClient(); + return useMutation< + NotificationChannel, + APIError[], + CreateNotificationChannelPayload + >({ + mutationFn: (data) => createNotificationChannel(data), + onSuccess: async (newChannel) => { + const allChannelsKey = + queryFactory.notificationChannels._ctx.all().queryKey; + const oldChannels = + queryClient.getQueryData(allChannelsKey); + + // Use cached alerts list if available to avoid refetching from API. + if (oldChannels) { + queryClient.setQueryData(allChannelsKey, [ + ...oldChannels, + newChannel, + ]); + } + }, + }); +}; + +export const useUpdateNotificationChannel = () => { + const queryClient = useQueryClient(); + return useMutation< + NotificationChannel, + APIError[], + EditNotificationChannelPayloadWithId + >({ + mutationFn: async (payload: EditNotificationChannelPayloadWithId) => { + const { channelId, details, label } = payload; + return updateNotificationChannel(channelId, { + details, + label, + }); + }, + onSuccess: (updatedChannel) => { + const allChannelsKey = + queryFactory.notificationChannels._ctx.all().queryKey; + + queryClient.setQueryData( + allChannelsKey, + (prev) => { + // nothing cached yet + if (!prev) return prev; + + const idx = prev.findIndex( + (channel) => channel.id === updatedChannel.id + ); + if (idx === -1) return prev; + + // if no change keep referential equality + if (prev[idx] === updatedChannel) return prev; + + const next = prev.slice(); + next[idx] = updatedChannel; + return next; + } + ); + + queryClient.setQueryData( + queryFactory.notificationChannels._ctx.channelById(updatedChannel.id) + .queryKey, + updatedChannel + ); + }, + }); +}; + +export const useNotificationChannelQuery = (channelId: number) => { + return useQuery( + queryFactory.notificationChannels._ctx.channelById(channelId) + ); +}; diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index 024e680db08..2f993cffebe 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -6,6 +6,7 @@ import { getDashboards, getJWEToken, getMetricDefinitionsByServiceType, + getNotificationChannelById, } from '@linode/api-v4'; import { databaseQueries, @@ -109,6 +110,10 @@ export const queryFactory = createQueryKeys(key, { queryFn: () => getAllNotificationChannels(params, filter), queryKey: [params, filter], }), + channelById: (channelId: number) => ({ + queryFn: () => getNotificationChannelById(channelId), + queryKey: [channelId], + }), }, queryKey: null, }, diff --git a/packages/manager/src/request.test.tsx b/packages/manager/src/request.test.tsx index c92d8a0df8c..c3f847cbd60 100644 --- a/packages/manager/src/request.test.tsx +++ b/packages/manager/src/request.test.tsx @@ -2,7 +2,12 @@ import { profileFactory } from '@linode/utilities'; import { AxiosHeaders } from 'axios'; import { setAuthDataInLocalStorage } from './OAuth/oauth'; -import { getURL, handleError, injectAkamaiAccountHeader } from './request'; +import { + getURL, + handleError, + injectAkamaiAccountHeader, + injectEuuidToProfile, +} from './request'; import { storeFactory } from './store'; import { storage } from './utilities/storage'; @@ -106,3 +111,34 @@ describe('injectAkamaiAccountHeader', () => { ); }); }); + +describe('injectEuuidToProfile', () => { + const profile = profileFactory.build(); + const response: AxiosResponse = { + data: profile, + status: 200, + statusText: 'OK', + config: { headers: new AxiosHeaders(), url: '/profile', method: 'get' }, + headers: { 'x-customer-uuid': '1234' }, + }; + + it('injects the euuid on successful GET profile response ', () => { + const results = injectEuuidToProfile(response); + expect(results.data).toHaveProperty('_euuidFromHttpHeader', '1234'); + const { _euuidFromHttpHeader, ...originalData } = results.data; + expect(originalData).toEqual(profile); + }); + + it('returns the original profile data if no header is present', () => { + const responseWithNoHeaders: AxiosResponse = { ...response, headers: {} }; + expect(injectEuuidToProfile(responseWithNoHeaders).data).toEqual(profile); + }); + + it("doesn't inject the euuid on other endpoints", () => { + const accountResponse: AxiosResponse = { + ...response, + config: { ...response.config, url: '/account' }, + }; + expect(injectEuuidToProfile(accountResponse).data).toEqual(profile); + }); +}); diff --git a/packages/manager/src/request.tsx b/packages/manager/src/request.tsx index 54e65a3280d..247c3fc4f64 100644 --- a/packages/manager/src/request.tsx +++ b/packages/manager/src/request.tsx @@ -102,6 +102,14 @@ export type ProfileWithAkamaiAccountHeader = Profile & { _akamaiAccount: boolean; }; +// A user's external UUID can be found on the response to /account. +// Since that endpoint is not available to restricted users, the API also +// returns it as an HTTP header ("X-Customer-Uuid"). This header is injected +// in the response to `/profile` so that it's available in Redux. +export type ProfileWithEuuid = Profile & { + _euuidFromHttpHeader?: string; +}; + export const injectAkamaiAccountHeader = ( response: AxiosResponse ): AxiosResponse => { @@ -133,6 +141,34 @@ export const isSuccessfulGETProfileResponse = ( ); }; +/** + * A user's external UUID can be found on the response to /account. + * Since that endpoint is not available to restricted users, the API also + * returns it as an HTTP header ("X-Customer-Uuid"). This middleware injects + * the value of the header to the GET /profile response so it can be added to + * the Redux store and used throughout the app. + */ +export const injectEuuidToProfile = ( + response: AxiosResponse +): AxiosResponse => { + if (isSuccessfulGETProfileResponse(response)) { + const xCustomerUuidHeader = response.headers['x-customer-uuid']; + // NOTE: this won't work locally (only staging and prod allow this header) + if (xCustomerUuidHeader) { + const profileWithEuuid: ProfileWithEuuid = { + ...response.data, + _euuidFromHttpHeader: xCustomerUuidHeader, + }; + + return { + ...response, + data: profileWithEuuid, + }; + } + } + return response; +}; + export const setupInterceptors = (store: ApplicationStore) => { baseRequest.interceptors.request.use(async (config) => { if ( @@ -176,4 +212,7 @@ export const setupInterceptors = (store: ApplicationStore) => { ); baseRequest.interceptors.response.use(injectAkamaiAccountHeader); + + // Inject the EUUID from the X-Customer-Uuid header into the profile response + baseRequest.interceptors.response.use(injectEuuidToProfile); }; diff --git a/packages/manager/src/routes/IAM/index.ts b/packages/manager/src/routes/IAM/index.ts index 98360cd87eb..8f5cf25a315 100644 --- a/packages/manager/src/routes/IAM/index.ts +++ b/packages/manager/src/routes/IAM/index.ts @@ -30,7 +30,7 @@ const iamCatchAllRoute = createRoute({ getParentRoute: () => iamRoute, path: '/$invalidPath', beforeLoad: () => { - throw redirect({ to: '/iam/users' }); + throw redirect({ to: '/iam/users', replace: true }); }, }); @@ -56,7 +56,7 @@ const iamUsersCatchAllRoute = createRoute({ getParentRoute: () => iamUsersRoute, path: '/$invalidPath', beforeLoad: () => { - throw redirect({ to: '/iam/users' }); + throw redirect({ to: '/iam/users', replace: true }); }, }); @@ -73,6 +73,7 @@ const iamRolesRoute = createRoute({ if (!isIAMEnabled) { throw redirect({ to: '/account/users', + replace: true, }); } }, @@ -98,6 +99,7 @@ const iamDefaultsTabsRoute = createRoute({ if (userType !== 'child' || !isDelegationEnabled) { throw redirect({ to: '/iam/roles', + replace: true, }); } }, @@ -129,7 +131,7 @@ const iamRolesCatchAllRoute = createRoute({ getParentRoute: () => iamRolesRoute, path: '/$invalidPath', beforeLoad: () => { - throw redirect({ to: '/iam/roles' }); + throw redirect({ to: '/iam/roles', replace: true }); }, }); @@ -144,6 +146,7 @@ const iamDelegationsRoute = createRoute({ if (!isDelegationEnabled || isChildAccount) { throw redirect({ to: '/iam/users', + replace: true, }); } }, @@ -157,7 +160,7 @@ const iamDelegationsCatchAllRoute = createRoute({ getParentRoute: () => iamDelegationsRoute, path: '/$invalidPath', beforeLoad: () => { - throw redirect({ to: '/iam/delegations' }); + throw redirect({ to: '/iam/delegations', replace: true }); }, }); @@ -238,6 +241,7 @@ const iamUserNameIndexRoute = createRoute({ throw redirect({ to: '/iam/users/$username/details', params: { username: params.username }, + replace: true, }); }, }).lazy(() => @@ -260,6 +264,7 @@ const iamUserNameDetailsRoute = createRoute({ throw redirect({ to: '/account/users/$username/profile', params: { username }, + replace: true, }); } }, @@ -309,6 +314,7 @@ const iamUserNameEntitiesRoute = createRoute({ throw redirect({ to: '/account/users/$username', params: { username }, + replace: true, }); } }, @@ -331,6 +337,7 @@ const iamUserNameDelegationsRoute = createRoute({ throw redirect({ to: '/iam/users/$username/details', params: { username }, + replace: true, }); } }, @@ -349,6 +356,7 @@ const iamUserNameCatchAllRoute = createRoute({ throw redirect({ to: '/iam/users/$username', params: { username: params.username }, + replace: true, }); } }, @@ -361,6 +369,7 @@ const iamUserNameDetailsCatchAllRoute = createRoute({ throw redirect({ to: '/iam/users/$username/details', params: { username: params.username }, + replace: true, }); }, }); @@ -372,6 +381,7 @@ const iamUserNameRolesCatchAllRoute = createRoute({ throw redirect({ to: '/iam/users/$username/roles', params: { username: params.username }, + replace: true, }); }, }); @@ -383,6 +393,7 @@ const iamUserNameEntitiesCatchAllRoute = createRoute({ throw redirect({ to: '/iam/users/$username/entities', params: { username: params.username }, + replace: true, }); }, }); diff --git a/packages/manager/src/routes/alerts/CloudPulseAlertsRoute.tsx b/packages/manager/src/routes/alerts/CloudPulseAlertsRoute.tsx index 0e521c5dc49..039a952a560 100644 --- a/packages/manager/src/routes/alerts/CloudPulseAlertsRoute.tsx +++ b/packages/manager/src/routes/alerts/CloudPulseAlertsRoute.tsx @@ -23,8 +23,15 @@ export const CloudPulseAlertsRoute = () => { title: 'Definitions', disabled: !flags.aclpAlerting?.alertDefinitions, }, + { + to: '/alerts/notification-channels', + title: 'Notification Channels', + disabled: !flags.aclpAlerting?.notificationChannels, + }, ]); + const visibleTabs = tabs.filter((tab) => !tab.disabled); + if (!isACLPEnabled) { return ; } @@ -39,14 +46,16 @@ export const CloudPulseAlertsRoute = () => { spacingBottom={4} /> - + }> - - - - - + {visibleTabs.map((_, index) => ( + + + + + + ))} diff --git a/packages/manager/src/routes/alerts/index.ts b/packages/manager/src/routes/alerts/index.ts index 827da3af08e..12ebb19a48d 100644 --- a/packages/manager/src/routes/alerts/index.ts +++ b/packages/manager/src/routes/alerts/index.ts @@ -68,6 +68,47 @@ const cloudPulseAlertsDefinitionsCatchAllRoute = createRoute({ }, }); +const cloudPulseNotificationChannelsRoute = createRoute({ + getParentRoute: () => cloudPulseAlertsRoute, + path: 'notification-channels', +}).lazy(() => + import( + 'src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/cloudPulseAlertsNotificationChannelsListingLazyRoute' + ).then((m) => m.cloudPulseAlertsNotificationChannelsListingLazyRoute) +); + +const cloudPulseNotificationChannelDetailRoute = createRoute({ + getParentRoute: () => cloudPulseAlertsRoute, + path: 'notification-channels/detail/$channelId', +}).lazy(() => + import( + 'src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/cloudPulseAlertsNotificationChannelsDetailLazyRoute' + ).then((m) => m.cloudPulseAlertsNotificationChannelDetailLazyRoute) +); + +export const cloudPulseNotificationChannelsCreateRoute = createRoute({ + getParentRoute: () => cloudPulseAlertsRoute, + path: 'notification-channels/create', +}).lazy(() => + import( + 'src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/cloudPulseCreateNotificationChannelLazyRoute' + ).then((m) => m.cloudPulseCreateNotificationChannelLazyRoute) +); + +const cloudPulseNotificationChannelEditRoute = createRoute({ + getParentRoute: () => cloudPulseAlertsRoute, + path: 'notification-channels/edit/$channelId', + params: { + parse: (rawParams) => ({ + channelId: Number(rawParams.channelId), + }), + }, +}).lazy(() => + import( + 'src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/cloudPulseAlertsNotificationChannelsEditLazyRoute' + ).then((m) => m.cloudPulseAlertsNotificationChannelEditLazyRoute) +); + export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseAlertsIndexRoute, cloudPulseAlertsDefinitionsRoute.addChildren([ @@ -76,4 +117,9 @@ export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseAlertsDefinitionsEditRoute, ]), cloudPulseAlertsDefinitionsCatchAllRoute, + cloudPulseNotificationChannelsRoute.addChildren([ + cloudPulseNotificationChannelDetailRoute, + cloudPulseNotificationChannelsCreateRoute, + cloudPulseNotificationChannelEditRoute, + ]), ]); diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 3c2193f36fa..e24aed3b464 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -29,6 +29,7 @@ import { loginHistoryRouteTree } from './loginHistory/'; import { longviewRouteTree } from './longview'; import { maintenanceRouteTree } from './maintenance'; import { managedRouteTree } from './managed'; +import { marketplaceRouteTree } from './marketplace'; import { cloudPulseMetricsRouteTree } from './metrics'; import { networkLoadBalancersRouteTree } from './networkLoadBalancer'; import { nodeBalancersRouteTree } from './nodeBalancers'; @@ -80,6 +81,7 @@ export const routeTree = rootRoute.addChildren([ longviewRouteTree, maintenanceRouteTree, managedRouteTree, + marketplaceRouteTree, networkLoadBalancersRouteTree, nodeBalancersRouteTree, objectStorageRouteTree, diff --git a/packages/manager/src/routes/marketplace/MarketplaceRoute.tsx b/packages/manager/src/routes/marketplace/MarketplaceRoute.tsx new file mode 100644 index 00000000000..f394a0e2be6 --- /dev/null +++ b/packages/manager/src/routes/marketplace/MarketplaceRoute.tsx @@ -0,0 +1,23 @@ +import { NotFound } from '@linode/ui'; +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/utils'; + +export const MarketplaceRoute = () => { + const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); + + if (!isMarketplaceV2FeatureEnabled) { + return ; + } + return ( + }> + + + + + ); +}; diff --git a/packages/manager/src/routes/marketplace/index.ts b/packages/manager/src/routes/marketplace/index.ts new file mode 100644 index 00000000000..9ef9c903270 --- /dev/null +++ b/packages/manager/src/routes/marketplace/index.ts @@ -0,0 +1,32 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { MarketplaceRoute } from './MarketplaceRoute'; + +export const marketplaceRoute = createRoute({ + component: MarketplaceRoute, + getParentRoute: () => rootRoute, + path: 'cloud-marketplace', +}); + +export const marketplaceLandingRoute = createRoute({ + beforeLoad: async () => { + throw redirect({ to: '/cloud-marketplace/catalog' }); + }, + getParentRoute: () => marketplaceRoute, + path: '/', +}); + +export const marketplaceCatlogRoute = createRoute({ + getParentRoute: () => marketplaceRoute, + path: '/catalog', +}).lazy(() => + import('src/features/Marketplace/marketplaceLazyRoute').then( + (m) => m.marketplaceLazyRoute + ) +); + +export const marketplaceRouteTree = marketplaceRoute.addChildren([ + marketplaceLandingRoute, + marketplaceCatlogRoute, +]); diff --git a/packages/manager/src/routes/objectStorage/index.ts b/packages/manager/src/routes/objectStorage/index.ts index d73e6d8d513..ad2d05b180c 100644 --- a/packages/manager/src/routes/objectStorage/index.ts +++ b/packages/manager/src/routes/objectStorage/index.ts @@ -107,6 +107,15 @@ const objectStorageBucketSSLRoute = createRoute({ ).then((m) => m.bucketDetailLandingLazyRoute) ); +const objectStorageBucketMetricsRoute = createRoute({ + getParentRoute: () => objectStorageBucketDetailRoute, + path: 'metrics', +}).lazy(() => + import( + 'src/features/ObjectStorage/BucketDetail/bucketDetailLandingLazyRoute' + ).then((m) => m.bucketDetailLandingLazyRoute) +); + export const objectStorageRouteTree = objectStorageRoute.addChildren([ objectStorageIndexRoute.addChildren([ objectStorageSummaryLandingRoute, @@ -119,5 +128,6 @@ export const objectStorageRouteTree = objectStorageRoute.addChildren([ objectStorageBucketDetailObjectsRoute, objectStorageBucketDetailAccessRoute, objectStorageBucketSSLRoute, + objectStorageBucketMetricsRoute, ]), ]); diff --git a/packages/manager/src/utilities/analytics/types.ts b/packages/manager/src/utilities/analytics/types.ts index c4d635f122e..7aa13b56a47 100644 --- a/packages/manager/src/utilities/analytics/types.ts +++ b/packages/manager/src/utilities/analytics/types.ts @@ -15,6 +15,7 @@ type DTMSatellite = { }; interface PageViewPayload { + euuid?: string; url: string; } diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index ac10cd07909..152e20ed515 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2026-01-12] - v0.19.0 + + +### Changed: + +- IAM: Enable account_viewer to access users table ([#13189](https://github.com/linode/manager/pull/13189)) + +### Upcoming Features: + +- Add new API queries for CRUD of locks for Resource Locking feature(RESPROT2) ([#13187](https://github.com/linode/manager/pull/13187)) + ## [2025-12-09] - v0.18.0 diff --git a/packages/queries/package.json b/packages/queries/package.json index 495e18c688f..d784da3e270 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -1,6 +1,6 @@ { "name": "@linode/queries", - "version": "0.18.0", + "version": "0.19.0", "description": "Linode Utility functions library", "main": "src/index.js", "module": "src/index.ts", diff --git a/packages/queries/src/account/queries.ts b/packages/queries/src/account/queries.ts index b1fbbd8623d..08e7520bdd2 100644 --- a/packages/queries/src/account/queries.ts +++ b/packages/queries/src/account/queries.ts @@ -24,6 +24,7 @@ import { getAllMaintenancePolicies, getAllNotifications, getAllPaymentMethodsRequest, + getAllUsers, } from './requests'; import type { Filter, Params, RequestOptions } from '@linode/api-v4'; @@ -121,6 +122,10 @@ export const accountQueries = createQueryKeys('account', { }, users: { contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllUsers(params, filter), + queryKey: [params, filter], + }), paginated: (params: Params = {}, filter: Filter = {}) => ({ queryFn: () => getUsers(params, filter), queryKey: [params, filter], diff --git a/packages/queries/src/account/requests.ts b/packages/queries/src/account/requests.ts index 93413bfb9b1..4536d638fe3 100644 --- a/packages/queries/src/account/requests.ts +++ b/packages/queries/src/account/requests.ts @@ -6,6 +6,7 @@ import { getNotifications, getPaymentMethods, getPayments, + getUsers, } from '@linode/api-v4'; import { getAll } from '@linode/utilities'; @@ -19,6 +20,7 @@ import type { Params, Payment, PaymentMethod, + User, } from '@linode/api-v4'; export const getAllNotifications = () => @@ -67,3 +69,12 @@ export const getAllMaintenancePolicies = () => getAll((params, filters) => getMaintenancePolicies(params, filters), )().then((data) => data.data); + +export const getAllUsers = async ( + passedParams: Params = {}, + passedFilters: Filter = {}, +) => { + return getAll((params, filters) => + getUsers({ ...params, ...passedParams }, { ...filters, ...passedFilters }), + )().then((data) => data.data); +}; diff --git a/packages/queries/src/account/users.ts b/packages/queries/src/account/users.ts index f7aed11e148..b0acd3e9e88 100644 --- a/packages/queries/src/account/users.ts +++ b/packages/queries/src/account/users.ts @@ -29,11 +29,9 @@ export const useAccountUsers = ({ filters?: Filter; params?: Params; }) => { - const { data: profile } = useProfile(); - return useQuery, APIError[]>({ ...accountQueries.users._ctx.paginated(params, filters), - enabled: enabled && !profile?.restricted, + enabled, placeholderData: keepPreviousData, }); }; @@ -157,3 +155,14 @@ export const useCreateUserMutation = () => { }, }); }; + +export const useAllAccountUsersQuery = ( + enabled: boolean = true, + filters: Filter = {}, + params: Params = {}, +) => { + return useQuery({ + ...accountQueries.users._ctx.all(params, filters), + enabled, + }); +}; diff --git a/packages/queries/src/databases/databases.ts b/packages/queries/src/databases/databases.ts index 505955d11fd..cf15ce40e18 100644 --- a/packages/queries/src/databases/databases.ts +++ b/packages/queries/src/databases/databases.ts @@ -198,7 +198,7 @@ export const useDatabaseBackupsQuery = ( enabled, }); -export const useDatabaseConnectionPool = ( +export const useDatabaseConnectionPoolQuery = ( databaseId: number, poolName: string, enabled: boolean = false, @@ -210,13 +210,15 @@ export const useDatabaseConnectionPool = ( enabled, }); -export const useDatabaseConnectionPools = ( +export const useDatabaseConnectionPoolsQuery = ( databaseId: number, enabled: boolean = false, + params: Params, ) => useQuery, APIError[]>({ - ...databaseQueries.database('postgresql', databaseId)._ctx.connectionPools - ._ctx.pools, + ...databaseQueries + .database('postgresql', databaseId) + ._ctx.connectionPools._ctx.paginated(params), enabled, }); diff --git a/packages/queries/src/databases/keys.ts b/packages/queries/src/databases/keys.ts index a4dc7de6168..533abd2189a 100644 --- a/packages/queries/src/databases/keys.ts +++ b/packages/queries/src/databases/keys.ts @@ -42,6 +42,10 @@ export const databaseQueries = createQueryKeys('databases', { queryFn: () => getDatabaseConnectionPools(id), queryKey: null, }, + paginated: (params: Params) => ({ + queryFn: () => getDatabaseConnectionPools(id, params), + queryKey: [params], + }), }, queryKey: null, }, diff --git a/packages/queries/src/index.ts b/packages/queries/src/index.ts index 6c61dab7f07..041af378f50 100644 --- a/packages/queries/src/index.ts +++ b/packages/queries/src/index.ts @@ -10,6 +10,7 @@ export * from './firewalls'; export * from './iam'; export * from './images'; export * from './linodes'; +export * from './locks'; export * from './netloadbalancers'; export * from './networking'; export * from './networktransfer'; diff --git a/packages/queries/src/locks/index.ts b/packages/queries/src/locks/index.ts new file mode 100644 index 00000000000..65e637faeed --- /dev/null +++ b/packages/queries/src/locks/index.ts @@ -0,0 +1 @@ +export * from './locks'; diff --git a/packages/queries/src/locks/locks.ts b/packages/queries/src/locks/locks.ts new file mode 100644 index 00000000000..4994d47c40a --- /dev/null +++ b/packages/queries/src/locks/locks.ts @@ -0,0 +1,109 @@ +import { + createLock, + deleteLock, + getLock, + getLocks, +} from '@linode/api-v4/lib/locks'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { APIError, Filter, Params, ResourcePage } from '@linode/api-v4'; +import type { + CreateLockPayload, + ResourceLock, +} from '@linode/api-v4/lib/locks/types'; + +export const lockQueries = createQueryKeys('locks', { + lock: (id: number) => ({ + queryFn: () => getLock(id), + queryKey: [id], + }), + locks: { + contextQueries: { + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLocks(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); + +/** + * useLocksQuery + * + * Returns a paginated list of resource locks + * + * @example + * const { data, isLoading } = useLocksQuery(); + */ +export const useLocksQuery = (params: Params = {}, filter: Filter = {}) => { + return useQuery, APIError[]>({ + ...lockQueries.locks._ctx.paginated(params, filter), + }); +}; + +/** + * useLockQuery + * + * Returns a single resource lock by ID + * + * @example + * const { data: lock } = useLockQuery(123); + */ +export const useLockQuery = (id: number, enabled: boolean = true) => { + return useQuery({ + ...lockQueries.lock(id), + enabled, + }); +}; + +/** + * + * Creates a new resource lock + * POST /v4beta/locks + * + * @example + * const { mutate: createLock } = useCreateLockMutation(); + * createLock({ + * entity_type: 'linode', + * entity_id: 12345, + * lock_type: 'cannot_delete', + * }); + */ +export const useCreateLockMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload) => createLock(payload), + onSuccess: () => { + // Invalidate all lock queries + queryClient.invalidateQueries({ + queryKey: lockQueries.locks.queryKey, + }); + }, + }); +}; + +/** + * + * Deletes a resource lock + * DELETE /v4beta/locks/{lock_id} + * + * @example + * const { mutate: deleteLock } = useDeleteLockMutation(); + * deleteLock(123); + */ +export const useDeleteLockMutation = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, APIError[], number>({ + mutationFn: (lockId) => deleteLock(lockId), + onSuccess: () => { + // Invalidate all lock queries + queryClient.invalidateQueries({ + queryKey: lockQueries.locks.queryKey, + }); + }, + }); +}; diff --git a/packages/shared/package.json b/packages/shared/package.json index 8055af74a58..f5f72aa01cd 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -40,7 +40,7 @@ "devDependencies": { "@linode/tsconfig": "workspace:*", "@storybook/react-vite": "^9.0.12", - "storybook": "^9.0.12", + "storybook": "^9.1.17", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 8396ba40bf9..b01c3c079aa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,7 +46,7 @@ "devDependencies": { "@linode/tsconfig": "workspace:*", "@storybook/react-vite": "^9.0.12", - "storybook": "^9.0.12", + "storybook": "^9.1.17", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", diff --git a/packages/ui/src/components/DatePicker/DateRangePicker/Presets.tsx b/packages/ui/src/components/DatePicker/DateRangePicker/Presets.tsx index 0fa38dad15e..b6a5a5f65ad 100644 --- a/packages/ui/src/components/DatePicker/DateRangePicker/Presets.tsx +++ b/packages/ui/src/components/DatePicker/DateRangePicker/Presets.tsx @@ -120,6 +120,7 @@ export const Presets = ({ return ( { diff --git a/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.tsx index 4719d4f34d0..5b6b863c5d2 100644 --- a/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -52,6 +52,12 @@ export interface DateTimeRangePickerProps { timeZone: null | string; }) => void; + /** Callback when the popover is closed */ + onClose?: (selectedPreset: string) => void; + + /** Property to control whether the calendar popover is open */ + openCalendar?: boolean; + /** Additional settings for the presets dropdown */ presetsProps?: { /** Default value for the presets field */ @@ -108,6 +114,8 @@ export const DateTimeRangePicker = ({ startDateProps, sx, timeZoneProps, + openCalendar, + onClose, }: DateTimeRangePickerProps) => { const [startDate, setStartDate] = useState( startDateProps?.value ?? null, @@ -122,7 +130,7 @@ export const DateTimeRangePicker = ({ startDateProps?.errorMessage, ); const [endDateError, setEndDateError] = useState(endDateProps?.errorMessage); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(openCalendar ?? false); const [anchorEl, setAnchorEl] = useState(null); const [currentMonth, setCurrentMonth] = useState(DateTime.now()); const [focusedField, setFocusedField] = useState<'end' | 'start'>('start'); // Tracks focused input field @@ -170,6 +178,7 @@ export const DateTimeRangePicker = ({ setEndDateError(''); setOpen(false); setAnchorEl(null); + onClose?.(previousValues.current.selectedPreset ?? ''); }; const handleApply = () => { @@ -275,10 +284,18 @@ export const DateTimeRangePicker = ({ setEndDateError(''); }; + React.useEffect(() => { + if (!anchorEl && startDateInputRef.current) { + setAnchorEl( + startDateInputRef.current?.parentElement || startDateInputRef.current, + ); + } + }, []); + return ( - + { if (newTime) { + setSelectedPreset(PRESET_LABELS.RESET); // Reset preset on manual time change setStartDate((prev) => { const updatedValue = prev?.set({ @@ -404,6 +422,7 @@ export const DateTimeRangePicker = ({ label="End Time" onChange={(newTime: DateTime | null) => { if (newTime) { + setSelectedPreset(PRESET_LABELS.RESET); // Reset preset on manual time change setEndDate((prev) => { const updatedValue = prev?.set({ diff --git a/packages/ui/src/components/Drawer/Drawer.stories.tsx b/packages/ui/src/components/Drawer/Drawer.stories.tsx index 94e0b9646c8..fcb4c74960a 100644 --- a/packages/ui/src/components/Drawer/Drawer.stories.tsx +++ b/packages/ui/src/components/Drawer/Drawer.stories.tsx @@ -183,6 +183,70 @@ export const WithError: Story = { }, }; +export const WithBackNavigation: Story = { + args: { + isFetching: false, + open: false, + title: 'My Drawer', + }, + render: (args) => { + const DrawerExampleWrapper = () => { + const [open, setOpen] = React.useState(args.open); + + return ( + <> + + {}} + onClose={() => setOpen(true)} + open={open} + > + + I smirked at their Kale chips banh-mi fingerstache brunch in + Williamsburg. + + + Meanwhile in my closet-style flat in Red-Hook, my pour-over coffee + glitched on my vinyl record player while I styled the bottom left + corner of my beard. Those artisan tacos I ordered were infused + with turmeric and locally sourced honey, a true farm-to-table + vibe. Pabst Blue Ribbon in hand, I sat on my reclaimed wood bench + next to the macramé plant holder. + + + Narwhal selfies dominated my Instagram feed, hashtagged with "slow + living" and "normcore aesthetics". My kombucha brewing kit arrived + just in time for me to ferment my own chai-infused blend. As I + adjusted my vintage round glasses, a tiny house documentary + started playing softly in the background. The retro typewriter + clacked as I typed out my minimalist poetry on sustainably sourced + paper. The sun glowed through the window, shining light on the + delightful cracks of my Apple watch. + + It was Saturday. + setOpen(false), + }} + /> + + + ); + }; + + return DrawerExampleWrapper(); + }, +}; + export const WithTitleSuffix: Story = { args: { isFetching: false, diff --git a/packages/ui/src/components/Drawer/Drawer.test.tsx b/packages/ui/src/components/Drawer/Drawer.test.tsx index c5abbcf7033..fd234768467 100644 --- a/packages/ui/src/components/Drawer/Drawer.test.tsx +++ b/packages/ui/src/components/Drawer/Drawer.test.tsx @@ -83,4 +83,13 @@ describe('Drawer', () => { expect(getByText('beta')).toBeVisible(); }); + + it('should render a Dailog with back button if handleBackNavigation is provided', () => { + const { getByLabelText } = renderWithTheme( + {}} open={true} />, + ); + const iconButton = getByLabelText('back navigation'); + + expect(iconButton).toBeVisible(); + }); }); diff --git a/packages/ui/src/components/Drawer/Drawer.tsx b/packages/ui/src/components/Drawer/Drawer.tsx index eb9f423a13b..c1486bf620f 100644 --- a/packages/ui/src/components/Drawer/Drawer.tsx +++ b/packages/ui/src/components/Drawer/Drawer.tsx @@ -4,6 +4,7 @@ import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; +import ChevronLeftIcon from '../../assets/icons/chevron-left.svg'; import { getErrorText } from '../../utilities/error'; import { convertForAria } from '../../utilities/stringUtils'; import { Box } from '../Box'; @@ -23,6 +24,11 @@ export interface DrawerProps extends _DrawerProps { * It prevents the drawer from showing broken content. */ error?: APIError[] | null | string; + /** + * An optional prop that handles back navigation for second-level drawers. + * It can act as a visual indicator, similar to a back button or `onClose` handler. + */ + handleBackNavigation?: () => void; /** * Whether the drawer is fetching the entity's data. * @@ -62,12 +68,13 @@ export const Drawer = React.forwardRef( const { children, error, - titleSuffix, + handleBackNavigation, isFetching, onClose, open, sx, title, + titleSuffix, wide, ...rest } = props; @@ -159,6 +166,25 @@ export const Drawer = React.forwardRef( data-testid="drawer-title-container" display="flex" > + {handleBackNavigation && ( + ({ + color: theme.palette.text.primary, + padding: 0, + marginRight: theme.spacingFunction(8), + '& svg': { + width: 24, + height: 24, + }, + })} + > + + + )} ({ + id: Factory.each((id) => id), + name: Factory.each((id) => `marketplace-product-${id}`), + partner_id: Factory.each((id) => id), + type_id: Factory.each((id) => id), + category_ids: [1, 2], + short_description: + 'This is a short description of the marketplace product.', + title_tag: 'Marketplace Product Title Tag', + product_tags: ['tag1', 'tag2'], + }); + +export const marketplaceCategoryFactory = + Factory.Sync.makeFactory({ + id: Factory.each((id) => id), + category: Factory.each((id) => `marketplace-category-${id}`), + product_count: Factory.each((id) => id * 10), + }); + +export const marketplaceTypeFactory = Factory.Sync.makeFactory( + { + id: Factory.each((id) => id), + name: Factory.each((id) => `marketplace-type-${id}`), + product_count: Factory.each((id) => id * 5), + }, +); + +export const marketplacePartnersFactory = + Factory.Sync.makeFactory({ + id: Factory.each((id) => id), + name: Factory.each((id) => `marketplace-partner-${id}`), + url: 'https://www.example.com', + logo_url_light_mode: 'https://www.example.com/logo-light-mode.png', + logo_url_night_mode: 'https://www.example.com/logo-night-mode.png', + }); diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 5c9b653355e..0dd6fa108e2 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,11 @@ +## [2026-01-12] - v0.80.0 + + +### Changed: + +- Logs Destination Form - add matching host and bucket name validation ([#13176](https://github.com/linode/manager/pull/13176)) +- Use UpdateConfigProfileInterfacesSchema in UpdateLinodeConfigSchema for interfaces property ([#13209](https://github.com/linode/manager/pull/13209)) + ## [2025-12-09] - v0.79.0 diff --git a/packages/validation/package.json b/packages/validation/package.json index 6b44f758c62..2d7ac4342b1 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.79.0", + "version": "0.80.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index bc09c7b2b8d..89bca0b6d99 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -133,3 +133,56 @@ export const editAlertDefinitionSchema = object({ scope: string().oneOf(['entity', 'region', 'account']).nullable().optional(), regions: array().of(string().defined()).optional(), }); + +export const createNotificationChannelPayloadSchema = object({ + label: string() + .required(fieldErrorMessage) + .matches( + /^[^*#&+:<>"?@%{}\\/]+$/, + 'Name cannot contain special characters: * # & + : < > ? @ % { } \\ /.', + ) + .max(100, 'Name must be 100 characters or less.') + .test( + 'no-special-start-end', + 'Name cannot start or end with a special character.', + (value) => { + return !specialStartEndRegex.test(value ?? ''); + }, + ), + channel_type: string() + .oneOf(['email', 'webhook', 'pagerduty', 'slack']) + .required(fieldErrorMessage), + details: object({ + email: object({ + usernames: array() + .of(string()) + .min(1, fieldErrorMessage) + .required(fieldErrorMessage), + }).required(), + }).required(), +}); + +export const editNotificationChannelPayloadSchema = object({ + label: string() + .required(fieldErrorMessage) + .matches( + /^[^*#&+:<>"?@%{}\\/]+$/, + 'Name cannot contain special characters: * # & + : < > ? @ % { } \\ /.', + ) + .max(100, 'Name must be 100 characters or less.') + .test( + 'no-special-start-end', + 'Name cannot start or end with a special character.', + (value) => { + return !specialStartEndRegex.test(value ?? ''); + }, + ), + details: object({ + email: object({ + usernames: array() + .of(string()) + .min(1, fieldErrorMessage) + .required(fieldErrorMessage), + }).required(), + }).required(), +}); diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index b0a2fa52127..28a183b16f5 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -57,8 +57,26 @@ const customHTTPsDetailsSchema = object({ endpoint_url: string().max(maxLength, maxLengthMessage).required(), }); +const hostRgx = + // eslint-disable-next-line sonarjs/slow-regex + /(?[a-z0-9-.]+)\.(?:s3(?:-accesspoint)?\.[a-z0-9-]+\.amazonaws\.com|(?!devcloud\.)[a-z0-9-]+\.(?:devcloud\.)?linodeobjects\.com)/; + const akamaiObjectStorageDetailsBaseSchema = object({ - host: string().max(maxLength, maxLengthMessage).required('Host is required.'), + host: string() + .max(maxLength, maxLengthMessage) + .required('Host is required.') + .test( + 'host-must-match-with-bucket-name-if-provided', + 'Bucket name provided as a part of the host must be the same as the bucket.', + (value, ctx) => { + if (ctx.parent.bucket_name) { + const groups = hostRgx.exec(value)?.groups; + return groups ? groups.bucket === ctx.parent.bucket_name : true; + } + + return true; + }, + ), bucket_name: string() .required('Bucket name is required.') .min(3, 'Bucket name must be between 3 and 63 characters.') @@ -71,7 +89,19 @@ const akamaiObjectStorageDetailsBaseSchema = object({ /^(?!.*[.-]{2})[a-z0-9.-]+$/, 'Bucket name must contain only lowercase letters, numbers, periods (.), and hyphens (-). Adjacent periods and hyphens are not allowed.', ) - .max(63, 'Bucket name must be between 3 and 63 characters.'), + .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.', + (value, ctx) => { + if (ctx.parent.host) { + const groups = hostRgx.exec(ctx.parent.host)?.groups; + return groups ? groups.bucket === value : true; + } + + return true; + }, + ), path: string().max(maxLength, maxLengthMessage).defined(), access_key_id: string() .max(maxLength, maxLengthMessage) diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index d5bee34ba22..2db71303d66 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -11,6 +11,7 @@ export * from './kubernetes.schema'; export * from './linodes.schema'; export * from './longview.schema'; export * from './managed.schema'; +export * from './marketplace.schema'; export * from './networking.schema'; export * from './nodebalancers.schema'; export * from './objectStorageKeys.schema'; diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 4de83f20225..05e4c460a5b 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -141,7 +141,7 @@ const ipv4ConfigInterface = object().when('purpose', { const slaacSchema = object().shape({ range: string() - .required('VPC IPv6 is required.') + .optional() .test({ name: 'IPv6 prefix length', message: 'Must be a /64 IPv6 network CIDR', @@ -321,6 +321,29 @@ export const ConfigProfileInterfacesSchema = array() }, ); +// This was created specifically for use in UpdateLinodeConfigSchema. +// Altering `ConfigProfileInterfaceSchema` results in issues related to the `interfaces` property +// that bubble up to `CreateLinodeSchema` in LinodeCreate/schemas.ts +export const UpdateConfigProfileInterfacesSchema = array() + .of( + ConfigProfileInterfaceSchema.clone().shape({ + ipv6: ipv6Interface.notRequired().nullable(), + }), + ) + .test( + 'unique-public-interface', + 'Only one public interface per config is allowed.', + (list?: any[] | null) => { + if (!list) { + return true; + } + + return ( + list.filter((thisSlot) => thisSlot.purpose === 'public').length <= 1 + ); + }, + ); + export const UpdateConfigInterfaceOrderSchema = object({ ids: array().of(number()).required('The list of interface IDs is required.'), }); @@ -593,7 +616,7 @@ export const UpdateLinodeConfigSchema = object({ virt_mode: mixed().oneOf(['paravirt', 'fullvirt']), helpers, root_device: string(), - interfaces: ConfigProfileInterfacesSchema, + interfaces: UpdateConfigProfileInterfacesSchema, }); export const CreateLinodeDiskSchema = object({ diff --git a/packages/validation/src/marketplace.schema.ts b/packages/validation/src/marketplace.schema.ts new file mode 100644 index 00000000000..bb1daa7dad3 --- /dev/null +++ b/packages/validation/src/marketplace.schema.ts @@ -0,0 +1,32 @@ +import { array, boolean, number, object, string } from 'yup'; + +const AKAMAI_EMAIL_VALIDATION_REGEX = new RegExp( + /^[A-Za-z0-9._%+-]+@akamai\.com$/, +); + +export const createPartnerReferralSchema = object({ + partner_id: number().required('Partner ID is required.'), + name: string().required('Name is required.'), + email: string() + .email('Must be a valid email address.') + .required('Email is required.'), + additional_emails: array() + .of(string().email('Must be a valid email address')) + .max(2, 'You can only provide up to 2 emails') + .optional(), + country_code: string().required('Country code is required.'), + phone_country_code: string().required('Phone country code is required.'), + phone: string().required('Phone number is required.'), + company_name: string().nullable(), + account_executive_email: string() + .email('Must be a valid email address.') + .matches(AKAMAI_EMAIL_VALIDATION_REGEX, `Must be an akamai email address.`) + .optional(), + comments: string() + .nullable() + .trim() + .max(500, 'Comments must contain 500 characters or less.'), + tc_consent_given: boolean() + .oneOf([true], 'You must agree to the terms and conditions.') + .required('Terms and conditions consent is required.'), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e6b138ab76..fcd73f6e6a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,7 +117,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.7.3)(yaml@2.6.1) + version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) packages/manager: dependencies: @@ -215,8 +215,8 @@ importers: specifier: ^5.5.0 version: 5.5.0 akamai-cds-react-components: - specifier: 0.0.1-alpha.18 - version: 0.0.1-alpha.18(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 0.0.1-alpha.19 + version: 0.0.1-alpha.19(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) algoliasearch: specifier: ^4.14.3 version: 4.24.0 @@ -354,14 +354,14 @@ importers: specifier: ^2.3.0 version: 2.3.0(cypress@15.4.0) '@storybook/addon-a11y': - specifier: ^9.0.12 - version: 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) + specifier: ^9.1.17 + version: 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))) '@storybook/addon-docs': - specifier: ^9.0.12 - version: 9.0.12(@types/react@19.1.6)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) + specifier: ^9.1.17 + 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.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.3)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(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)) + specifier: ^9.1.17 + version: 9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@swc/core': specifier: ^1.10.9 version: 1.10.11 @@ -448,7 +448,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.10) + version: 3.2.4(vitest@4.0.16(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vueless/storybook-dark-mode': specifier: ^9.0.5 version: 9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -511,7 +511,7 @@ importers: version: 2.2.1(mocha@10.8.2) msw: specifier: ^2.2.3 - version: 2.6.5(@types/node@22.18.1)(typescript@5.7.3) + version: 2.6.5(@types/node@22.18.1)(typescript@5.9.3) pdfreader: specifier: ^3.0.7 version: 3.0.7 @@ -519,14 +519,14 @@ importers: specifier: ^1.5.3 version: 1.5.5(redux@4.2.1) storybook: - specifier: ^9.0.12 - version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + specifier: ^9.1.17 + version: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) vite: specifier: ^7.2.2 version: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.53.3)(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)) + version: 4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/queries: dependencies: @@ -610,7 +610,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.3)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(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)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -630,11 +630,11 @@ importers: specifier: ^19.1.6 version: 19.1.6(@types/react@19.1.6) storybook: - specifier: ^9.0.12 - version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + specifier: ^9.1.17 + version: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.53.3)(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)) + version: 4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/tsconfig: {} @@ -679,7 +679,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.3)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(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)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -702,11 +702,11 @@ importers: specifier: ^19.1.6 version: 19.1.6(@types/react@19.1.6) storybook: - specifier: ^9.0.12 - version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + specifier: ^9.1.17 + version: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.53.3)(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)) + version: 4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/utilities: dependencies: @@ -768,7 +768,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.7.3)(yaml@2.6.1) + version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) scripts: devDependencies: @@ -810,6 +810,9 @@ packages: '@adobe/css-tools@4.4.1': resolution: {integrity: sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==} + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@algolia/cache-browser-local-storage@4.24.0': resolution: {integrity: sha512-t63W9BnoXVrGy9iYHBgObNXqYXM3tYXCjDSHeNwnsc324r4o5UiVKUiAB4THQ5z9U5hTj6qUvwg/Ez43ZD85ww==} @@ -1074,6 +1077,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -1086,6 +1095,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -1098,6 +1113,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -1110,6 +1131,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -1122,6 +1149,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -1134,6 +1167,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -1146,6 +1185,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -1158,6 +1203,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -1170,6 +1221,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -1182,6 +1239,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -1194,6 +1257,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -1206,6 +1275,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -1218,6 +1293,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -1230,6 +1311,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -1242,6 +1329,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -1254,6 +1347,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -1266,6 +1365,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -1278,6 +1383,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -1290,6 +1401,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -1302,6 +1419,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -1314,12 +1437,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -1332,6 +1467,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -1344,6 +1485,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -1356,6 +1503,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -1368,6 +1521,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.4.1': resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1614,6 +1773,15 @@ packages: typescript: optional: true + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1': + resolution: {integrity: sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==} + peerDependencies: + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1955,6 +2123,11 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.53.5': + resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.40.1': resolution: {integrity: sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==} cpu: [arm64] @@ -1965,6 +2138,11 @@ packages: cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.53.5': + resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.40.1': resolution: {integrity: sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==} cpu: [arm64] @@ -1975,6 +2153,11 @@ packages: cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.53.5': + resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.40.1': resolution: {integrity: sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==} cpu: [x64] @@ -1985,6 +2168,11 @@ packages: cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.53.5': + resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.40.1': resolution: {integrity: sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==} cpu: [arm64] @@ -1995,6 +2183,11 @@ packages: cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.53.5': + resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.40.1': resolution: {integrity: sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==} cpu: [x64] @@ -2005,6 +2198,11 @@ packages: cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.53.5': + resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.40.1': resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==} cpu: [arm] @@ -2015,6 +2213,11 @@ packages: cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': + resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.40.1': resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==} cpu: [arm] @@ -2025,6 +2228,11 @@ packages: cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.53.5': + resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.40.1': resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==} cpu: [arm64] @@ -2035,6 +2243,11 @@ packages: cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.53.5': + resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.40.1': resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==} cpu: [arm64] @@ -2045,11 +2258,21 @@ packages: cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.53.5': + resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-loong64-gnu@4.53.3': resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] + '@rollup/rollup-linux-loong64-gnu@4.53.5': + resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-loongarch64-gnu@4.40.1': resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==} cpu: [loong64] @@ -2065,6 +2288,11 @@ packages: cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-ppc64-gnu@4.53.5': + resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.40.1': resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==} cpu: [riscv64] @@ -2075,6 +2303,11 @@ packages: cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.53.5': + resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-riscv64-musl@4.40.1': resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==} cpu: [riscv64] @@ -2085,6 +2318,11 @@ packages: cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-musl@4.53.5': + resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.40.1': resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==} cpu: [s390x] @@ -2095,6 +2333,11 @@ packages: cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.53.5': + resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.40.1': resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==} cpu: [x64] @@ -2105,6 +2348,11 @@ packages: cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.53.5': + resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.40.1': resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==} cpu: [x64] @@ -2115,11 +2363,21 @@ packages: cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.53.5': + resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} + cpu: [x64] + os: [linux] + '@rollup/rollup-openharmony-arm64@4.53.3': resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} cpu: [arm64] os: [openharmony] + '@rollup/rollup-openharmony-arm64@4.53.5': + resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} + cpu: [arm64] + os: [openharmony] + '@rollup/rollup-win32-arm64-msvc@4.40.1': resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==} cpu: [arm64] @@ -2130,6 +2388,11 @@ packages: cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.53.5': + resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.40.1': resolution: {integrity: sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==} cpu: [ia32] @@ -2140,11 +2403,21 @@ packages: cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.53.5': + resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-gnu@4.53.3': resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-gnu@4.53.5': + resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==} + cpu: [x64] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.40.1': resolution: {integrity: sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==} cpu: [x64] @@ -2155,6 +2428,11 @@ packages: cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.53.5': + resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==} + cpu: [x64] + os: [win32] + '@sentry-internal/browser-utils@9.19.0': resolution: {integrity: sha512-DlEHX4eIHe5yIuh/cFu9OiaFuk1CTnFK95zj61I7Q2fxmN43dIwC3xAAGJ/Hy+GDQi7kU+BiS2sudSHSTq81BA==} engines: {node: '>=18'} @@ -2209,15 +2487,18 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - '@storybook/addon-a11y@9.0.12': - resolution: {integrity: sha512-xdJPYNxYU6A3DA48h6y0o3XziCp4YDGXcFKkc5Ce1GPFCa7ebFFh2trHqzevoFSGdQxWc5M3W0A4dhQtkpT4Ww==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@storybook/addon-a11y@9.1.17': + resolution: {integrity: sha512-xP2Nb+idph2r0wE2Lc3z7LjtyXxTS+U+mJWmS8hw5w0oU2TkVdV7Ew/V7/iNl5jIWMXIp9HCRmcJuKSSGuertA==} peerDependencies: - storybook: ^9.0.12 + storybook: ^9.1.17 - '@storybook/addon-docs@9.0.12': - resolution: {integrity: sha512-bAuFy4BWGEBIC0EAS4N+V8mHj7NZiSdDnJUSr4Al3znEVzNHLpQAMRznkga2Ok8x+gwcyBG7W47dLbDXVqLZDg==} + '@storybook/addon-docs@9.1.17': + resolution: {integrity: sha512-yc4hlgkrwNi045qk210dRuIMijkgbLmo3ft6F4lOdpPRn4IUnPDj7FfZR8syGzUzKidxRfNtLx5m0yHIz83xtA==} peerDependencies: - storybook: ^9.0.12 + storybook: ^9.1.17 '@storybook/builder-vite@9.0.12': resolution: {integrity: sha512-Jh6CJKHJQ+N1BiPr6fY91EMV5X0xBuIAhLpaNSKrshkdnXd/fBbRgE8iPJdnr+SCqaFErBjAjBzKkotwKU138A==} @@ -2225,21 +2506,25 @@ packages: storybook: ^9.0.12 vite: ^5.0.0 || ^6.0.0 + '@storybook/builder-vite@9.1.17': + resolution: {integrity: sha512-OQCYaFWoTBvovN2IJmkAW+7FgHMJiih1WA/xqgpKIx0ImZjB4z5FrKgzQeXsrYcLEsynyaj+xN3JFUKsz5bzGQ==} + peerDependencies: + storybook: ^9.1.17 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + '@storybook/csf-plugin@9.0.12': resolution: {integrity: sha512-5EueJQJAu77Lh+EedG4Q/kEOZNlTY/g+fWsT7B5DTtLVy0ypnghsHY8X3KYT/0+NNgTtoO0if4F+ejVYaLnMzA==} peerDependencies: storybook: ^9.0.12 + '@storybook/csf-plugin@9.1.17': + resolution: {integrity: sha512-o+ebQDdSfZHDRDhu2hNDGhCLIazEB4vEAqJcHgz1VsURq+l++bgZUcKojPMCAbeblptSEz2bwS0eYAOvG7aSXg==} + peerDependencies: + storybook: ^9.1.17 + '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} - '@storybook/icons@1.2.12': - resolution: {integrity: sha512-UxgyK5W3/UV4VrI3dl6ajGfHM4aOqMAkFLWe2KibeQudLf6NJpDrDMSHwZj+3iKC4jFU7dkKbbtH2h/al4sW3Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - '@storybook/icons@1.4.0': resolution: {integrity: sha512-Td73IeJxOyalzvjQL+JXx72jlIYHgs+REaHiREOqfpo3A2AYYG71AUbcv+lg7mEDIweKVCxsMQ0UKo634c8XeA==} engines: {node: '>=14.0.0'} @@ -2254,6 +2539,13 @@ packages: react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta storybook: ^9.0.12 + '@storybook/react-dom-shim@9.1.17': + resolution: {integrity: sha512-Ss/lNvAy0Ziynu+KniQIByiNuyPz3dq7tD62hqSC/pHw190X+M7TKU3zcZvXhx2AQx1BYyxtdSHIZapb+P5mxQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^9.1.17 + '@storybook/react-vite@9.0.12': resolution: {integrity: sha512-TAXkBBiy2dYGL8rXiqAZh1A9w83R9SFa9EiGDYIek+fSKRnbMAclO8cxtDUOuwKzVQ0mzvL2DPtHV6uaoec/Eg==} engines: {node: '>=20.0.0'} @@ -2263,6 +2555,15 @@ packages: storybook: ^9.0.12 vite: ^5.0.0 || ^6.0.0 + '@storybook/react-vite@9.1.17': + resolution: {integrity: sha512-RZHsqD1mnTMo4MCJw68t3swS5BTMSTpeRhlelMwjoTEe7jJCPa+qx00uMlWliR1QBN1hMO8Y1dkchxSiUS9otA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^9.1.17 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + '@storybook/react@9.0.12': resolution: {integrity: sha512-rDrf5MDfsguNDTSOfGqhAjQDhp3jDMdzAoCqLjQ75M647C8nsv9i+fftO3k0rMxIJRrESpZWqVZ4tsjOX+J3DA==} engines: {node: '>=20.0.0'} @@ -2275,6 +2576,18 @@ packages: typescript: optional: true + '@storybook/react@9.1.17': + resolution: {integrity: sha512-TZCplpep5BwjHPIIcUOMHebc/2qKadJHYPisRn5Wppl014qgT3XkFLpYkFgY1BaRXtqw8Mn3gqq4M/49rQ7Iww==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^9.1.17 + typescript: '>= 4.9.x' + peerDependenciesMeta: + typescript: + optional: true + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -2545,8 +2858,8 @@ packages: resolution: {integrity: sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/jest-dom@6.6.3': - resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} '@testing-library/react@16.0.1': @@ -2597,6 +2910,9 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chart.js@2.9.41': resolution: {integrity: sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==} @@ -2911,12 +3227,26 @@ packages: '@vitest/browser': optional: true - '@vitest/expect@3.0.9': - resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} '@vitest/expect@4.0.10': resolution: {integrity: sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==} + '@vitest/expect@4.0.16': + resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.10': resolution: {integrity: sha512-e2OfdexYkjkg8Hh3L9NVEfbwGXq5IZbDovkf30qW2tOh7Rh9sVtmSr2ztEXOFbymNxS4qjzLXUQIvATvN4B+lg==} peerDependencies: @@ -2928,35 +3258,61 @@ packages: vite: optional: true - '@vitest/pretty-format@3.0.9': - resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==} + '@vitest/mocker@4.0.16': + resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} '@vitest/pretty-format@4.0.10': resolution: {integrity: sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==} + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/runner@4.0.10': resolution: {integrity: sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==} + '@vitest/runner@4.0.16': + resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + '@vitest/snapshot@4.0.10': resolution: {integrity: sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==} - '@vitest/spy@3.0.9': - resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==} + '@vitest/snapshot@4.0.16': + resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} '@vitest/spy@4.0.10': resolution: {integrity: sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==} + '@vitest/spy@4.0.16': + resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + '@vitest/ui@4.0.10': resolution: {integrity: sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==} peerDependencies: vitest: 4.0.10 - '@vitest/utils@3.0.9': - resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} '@vitest/utils@4.0.10': resolution: {integrity: sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==} + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@vueless/storybook-dark-mode@9.0.5': resolution: {integrity: sha512-JU0bQe+KHvmg04k2yprzVkM0d8xdKwqFaFuQmO7afIUm//ttroDpfHfPzwLZuTDW9coB5bt2+qMSHZOBbt0w4g==} engines: {node: '>=20'} @@ -2990,16 +3346,16 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - akamai-cds-react-components@0.0.1-alpha.18: - resolution: {integrity: sha512-vSwKqxsolHewLqjLZgX6eCMb/Dwfdm1V1Ql258BJtvm32h8YN2HcAiQf6RdCzKP+09S8dlEQMmUhSgKmBugz0Q==} + akamai-cds-react-components@0.0.1-alpha.19: + resolution: {integrity: sha512-YNQPeP4i65qKA5bjOb4ti0ciwQOYZqsKlJ9f14g7xeJCHamyqxPuHIGsOTvykhKyFoXLL8+kejDS52H5vLvVug==} peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 - akamai-cds-web-components@0.0.1-alpha.18: - resolution: {integrity: sha512-VCUItyxy9eTe98HzZ9tZvZHi/13iKuHj7eSqujOm2WumaHM4qiUKcgF2EuR2SrtjTgq7xUe3U15DEfMWrHjpuw==} + akamai-cds-web-components@0.0.1-alpha.19: + resolution: {integrity: sha512-8xzQjUtNgpbmINt72deakR96vWDJekfjWMt7DJ3DWumlhXDzekf2hm3Q8++hObjKVx6KgXpUDOGzAdU5yMKeiQ==} peerDependencies: - '@linode/design-language-system': ^4.0.0 + '@linode/design-language-system': ^5.2.0 algoliasearch@4.24.0: resolution: {integrity: sha512-bf0QV/9jVejssFBmz2HQLxUadxk574t4iwjCKp5E7NBzwKkrDEhKPISIIjAU/p6K5qDx3qoeh4+26zWN1jmw3g==} @@ -3279,9 +3635,9 @@ packages: peerDependencies: chai: ^4.1.2 - chai@5.2.0: - resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} - engines: {node: '>=12'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} chai@6.2.1: resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} @@ -3843,6 +4199,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -4009,6 +4370,10 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -4891,9 +5256,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@3.1.3: - resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -5142,6 +5504,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5268,8 +5633,8 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.0: - resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} pdf2json@3.1.4: @@ -5534,8 +5899,8 @@ packages: resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} engines: {node: '>= 14.16.0'} - recast@0.23.9: - resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==} + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} recharts-scale@0.4.5: @@ -5667,6 +6032,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.53.5: + resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -5726,6 +6096,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -5863,8 +6238,8 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} - storybook@9.0.12: - resolution: {integrity: sha512-mpACe6BMd/M5sqcOiA8NmWIm2zdx0t4ujnA4NTcq4aErdK/KKuU255UM4pO3DIf5zWR5VrDfNV5UaMi/VgE2mA==} + storybook@9.1.17: + resolution: {integrity: sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==} hasBin: true peerDependencies: prettier: ^2 || ^3 @@ -6039,6 +6414,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} @@ -6055,8 +6434,8 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} tldts-core@6.1.61: @@ -6233,8 +6612,13 @@ packages: engines: {node: '>=14.17'} hasBin: true - uc.micro@2.1.0: - resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} @@ -6376,6 +6760,46 @@ packages: yaml: optional: true + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.3.0 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@4.0.10: resolution: {integrity: sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -6410,6 +6834,40 @@ packages: jsdom: optional: true + vitest@4.0.16: + resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.16 + '@vitest/browser-preview': 4.0.16 + '@vitest/browser-webdriverio': 4.0.16 + '@vitest/ui': 4.0.16 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -6506,6 +6964,18 @@ packages: utf-8-validate: optional: true + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -6599,6 +7069,8 @@ snapshots: '@adobe/css-tools@4.4.1': {} + '@adobe/css-tools@4.4.4': {} + '@algolia/cache-browser-local-storage@4.24.0': dependencies: '@algolia/cache-common': 4.24.0 @@ -6701,10 +7173,10 @@ snapshots: '@babel/traverse': 7.25.9 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 - semver: 7.6.3 + semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -6722,7 +7194,7 @@ snapshots: '@babel/helper-validator-option': 7.25.9 browserslist: 4.24.2 lru-cache: 5.1.1 - semver: 7.6.3 + semver: 7.7.3 '@babel/helper-module-imports@7.25.9': dependencies: @@ -6772,7 +7244,7 @@ snapshots: '@babel/parser': 7.27.0 '@babel/template': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -6963,153 +7435,231 @@ snapshots: '@esbuild/aix-ppc64@0.25.3': optional: true + '@esbuild/aix-ppc64@0.27.2': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.25.3': optional: true + '@esbuild/android-arm64@0.27.2': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.25.3': optional: true + '@esbuild/android-arm@0.27.2': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.25.3': optional: true + '@esbuild/android-x64@0.27.2': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.25.3': optional: true + '@esbuild/darwin-arm64@0.27.2': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.25.3': optional: true + '@esbuild/darwin-x64@0.27.2': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.25.3': optional: true + '@esbuild/freebsd-arm64@0.27.2': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.25.3': optional: true + '@esbuild/freebsd-x64@0.27.2': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.25.3': optional: true + '@esbuild/linux-arm64@0.27.2': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.25.3': optional: true + '@esbuild/linux-arm@0.27.2': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.25.3': optional: true + '@esbuild/linux-ia32@0.27.2': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.25.3': optional: true + '@esbuild/linux-loong64@0.27.2': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.25.3': optional: true + '@esbuild/linux-mips64el@0.27.2': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.25.3': optional: true + '@esbuild/linux-ppc64@0.27.2': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.25.3': optional: true + '@esbuild/linux-riscv64@0.27.2': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.25.3': optional: true + '@esbuild/linux-s390x@0.27.2': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.25.3': optional: true + '@esbuild/linux-x64@0.27.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.25.3': optional: true + '@esbuild/netbsd-arm64@0.27.2': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.25.3': optional: true + '@esbuild/netbsd-x64@0.27.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.25.3': optional: true + '@esbuild/openbsd-arm64@0.27.2': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.25.3': optional: true + '@esbuild/openbsd-x64@0.27.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.27.2': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.25.3': optional: true + '@esbuild/sunos-x64@0.27.2': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.25.3': optional: true + '@esbuild/win32-arm64@0.27.2': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.25.3': optional: true + '@esbuild/win32-ia32@0.27.2': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.25.3': optional: true + '@esbuild/win32-x64@0.27.2': + optional: true + '@eslint-community/eslint-utils@4.4.1(eslint@9.31.0(jiti@2.4.2))': dependencies: eslint: 9.31.0(jiti@2.4.2) @@ -7342,14 +7892,23 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(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))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: glob: 10.5.0 magic-string: 0.30.17 - react-docgen-typescript: 2.2.2(typescript@5.7.3) + react-docgen-typescript: 2.2.2(typescript@5.9.3) + vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + optionalDependencies: + typescript: 5.9.3 + + '@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))': + dependencies: + glob: 10.5.0 + magic-string: 0.30.21 + react-docgen-typescript: 2.2.2(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) optionalDependencies: - typescript: 5.7.3 + typescript: 5.9.3 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -7613,7 +8172,7 @@ snapshots: '@peggyjs/from-mem@1.3.5': dependencies: - semver: 7.6.3 + semver: 7.7.3 '@pkgjs/parseargs@0.11.0': optional: true @@ -7656,21 +8215,21 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.32': {} - '@rollup/pluginutils@5.1.3(rollup@4.53.3)': + '@rollup/pluginutils@5.1.3(rollup@4.53.5)': dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.53.3 + rollup: 4.53.5 - '@rollup/pluginutils@5.2.0(rollup@4.53.3)': + '@rollup/pluginutils@5.2.0(rollup@4.53.5)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.53.3 + rollup: 4.53.5 '@rollup/rollup-android-arm-eabi@4.40.1': optional: true @@ -7678,63 +8237,96 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.53.3': optional: true + '@rollup/rollup-android-arm-eabi@4.53.5': + optional: true + '@rollup/rollup-android-arm64@4.40.1': optional: true '@rollup/rollup-android-arm64@4.53.3': optional: true + '@rollup/rollup-android-arm64@4.53.5': + optional: true + '@rollup/rollup-darwin-arm64@4.40.1': optional: true '@rollup/rollup-darwin-arm64@4.53.3': optional: true + '@rollup/rollup-darwin-arm64@4.53.5': + optional: true + '@rollup/rollup-darwin-x64@4.40.1': optional: true '@rollup/rollup-darwin-x64@4.53.3': optional: true + '@rollup/rollup-darwin-x64@4.53.5': + optional: true + '@rollup/rollup-freebsd-arm64@4.40.1': optional: true '@rollup/rollup-freebsd-arm64@4.53.3': optional: true + '@rollup/rollup-freebsd-arm64@4.53.5': + optional: true + '@rollup/rollup-freebsd-x64@4.40.1': optional: true '@rollup/rollup-freebsd-x64@4.53.3': optional: true + '@rollup/rollup-freebsd-x64@4.53.5': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.40.1': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.53.3': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.40.1': optional: true '@rollup/rollup-linux-arm-musleabihf@4.53.3': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.53.5': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.40.1': optional: true '@rollup/rollup-linux-arm64-gnu@4.53.3': optional: true + '@rollup/rollup-linux-arm64-gnu@4.53.5': + optional: true + '@rollup/rollup-linux-arm64-musl@4.40.1': optional: true '@rollup/rollup-linux-arm64-musl@4.53.3': optional: true + '@rollup/rollup-linux-arm64-musl@4.53.5': + optional: true + '@rollup/rollup-linux-loong64-gnu@4.53.3': optional: true + '@rollup/rollup-linux-loong64-gnu@4.53.5': + optional: true + '@rollup/rollup-linux-loongarch64-gnu@4.40.1': optional: true @@ -7744,60 +8336,93 @@ snapshots: '@rollup/rollup-linux-ppc64-gnu@4.53.3': optional: true + '@rollup/rollup-linux-ppc64-gnu@4.53.5': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.40.1': optional: true '@rollup/rollup-linux-riscv64-gnu@4.53.3': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.53.5': + optional: true + '@rollup/rollup-linux-riscv64-musl@4.40.1': optional: true '@rollup/rollup-linux-riscv64-musl@4.53.3': optional: true + '@rollup/rollup-linux-riscv64-musl@4.53.5': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.40.1': optional: true '@rollup/rollup-linux-s390x-gnu@4.53.3': optional: true + '@rollup/rollup-linux-s390x-gnu@4.53.5': + optional: true + '@rollup/rollup-linux-x64-gnu@4.40.1': optional: true '@rollup/rollup-linux-x64-gnu@4.53.3': optional: true + '@rollup/rollup-linux-x64-gnu@4.53.5': + optional: true + '@rollup/rollup-linux-x64-musl@4.40.1': optional: true '@rollup/rollup-linux-x64-musl@4.53.3': optional: true + '@rollup/rollup-linux-x64-musl@4.53.5': + optional: true + '@rollup/rollup-openharmony-arm64@4.53.3': optional: true + '@rollup/rollup-openharmony-arm64@4.53.5': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.40.1': optional: true '@rollup/rollup-win32-arm64-msvc@4.53.3': optional: true + '@rollup/rollup-win32-arm64-msvc@4.53.5': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.40.1': optional: true '@rollup/rollup-win32-ia32-msvc@4.53.3': optional: true + '@rollup/rollup-win32-ia32-msvc@4.53.5': + optional: true + '@rollup/rollup-win32-x64-gnu@4.53.3': optional: true + '@rollup/rollup-win32-x64-gnu@4.53.5': + optional: true + '@rollup/rollup-win32-x64-msvc@4.40.1': optional: true '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@rollup/rollup-win32-x64-msvc@4.53.5': + optional: true + '@sentry-internal/browser-utils@9.19.0': dependencies: '@sentry/core': 9.19.0 @@ -7868,68 +8493,103 @@ snapshots: '@standard-schema/spec@1.0.0': {} - '@storybook/addon-a11y@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))': + '@standard-schema/spec@1.1.0': {} + + '@storybook/addon-a11y@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)))': dependencies: '@storybook/global': 5.0.0 axe-core: 4.10.2 - storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + 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/addon-docs@9.0.12(@types/react@19.1.6)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))': + '@storybook/addon-docs@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)))': dependencies: '@mdx-js/react': 3.1.0(@types/react@19.1.6)(react@19.1.0) - '@storybook/csf-plugin': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) - '@storybook/icons': 1.2.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-dom-shim': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) + '@storybook/csf-plugin': 9.1.17(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) + '@storybook/icons': 1.4.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@storybook/react-dom-shim': 9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + 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)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/builder-vite@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(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/builder-vite@9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + dependencies: + '@storybook/csf-plugin': 9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + ts-dedent: 2.2.0 + vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + + '@storybook/builder-vite@9.1.17(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@storybook/csf-plugin': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) - storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + '@storybook/csf-plugin': 9.1.17(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) + 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)) ts-dedent: 2.2.0 vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - '@storybook/csf-plugin@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))': + '@storybook/csf-plugin@9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': dependencies: - storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + unplugin: 1.16.0 + + '@storybook/csf-plugin@9.1.17(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': + dependencies: + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) unplugin: 1.16.0 '@storybook/global@5.0.0': {} - '@storybook/icons@1.2.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@storybook/icons@1.4.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@storybook/icons@1.4.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@storybook/react-dom-shim@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': dependencies: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@storybook/react-dom-shim@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))': + '@storybook/react-dom-shim@9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': dependencies: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.3)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(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))': + '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(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)) - '@rollup/pluginutils': 5.1.3(rollup@4.53.3) - '@storybook/builder-vite': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(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': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@rollup/pluginutils': 5.1.3(rollup@4.53.5) + '@storybook/builder-vite': 9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@storybook/react': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3) find-up: 7.0.0 magic-string: 0.30.17 react: 19.1.0 react-docgen: 8.0.0 react-dom: 19.1.0(react@19.1.0) resolve: 1.22.8 - storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + tsconfig-paths: 4.2.0 + vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + transitivePeerDependencies: + - rollup + - supports-color + - typescript + + '@storybook/react-vite@9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@rollup/pluginutils': 5.2.0(rollup@4.53.5) + '@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 + magic-string: 0.30.21 + react: 19.1.0 + react-docgen: 8.0.0 + react-dom: 19.1.0(react@19.1.0) + resolve: 1.22.8 + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) tsconfig-paths: 4.2.0 vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: @@ -7937,15 +8597,25 @@ snapshots: - supports-color - typescript - '@storybook/react@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)': + '@storybook/react@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) + '@storybook/react-dom-shim': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) optionalDependencies: - typescript: 5.7.3 + typescript: 5.9.3 + + '@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)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + optionalDependencies: + typescript: 5.9.3 '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.26.0)': dependencies: @@ -7991,12 +8661,12 @@ snapshots: '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.26.0) '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.26.0) - '@svgr/core@8.1.0(typescript@5.7.3)': + '@svgr/core@8.1.0(typescript@5.9.3)': dependencies: '@babel/core': 7.26.0 '@svgr/babel-preset': 8.1.0(@babel/core@7.26.0) camelcase: 6.3.0 - cosmiconfig: 8.3.6(typescript@5.7.3) + cosmiconfig: 8.3.6(typescript@5.9.3) snake-case: 3.0.4 transitivePeerDependencies: - supports-color @@ -8007,11 +8677,11 @@ snapshots: '@babel/types': 7.27.0 entities: 4.5.0 - '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.7.3))': + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': dependencies: '@babel/core': 7.26.0 '@svgr/babel-preset': 8.1.0(@babel/core@7.26.0) - '@svgr/core': 8.1.0(typescript@5.7.3) + '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/hast-util-to-babel-ast': 8.0.0 svg-parser: 2.0.4 transitivePeerDependencies: @@ -8189,14 +8859,13 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/jest-dom@6.6.3': + '@testing-library/jest-dom@6.9.1': dependencies: - '@adobe/css-tools': 4.4.1 + '@adobe/css-tools': 4.4.4 aria-query: 5.3.2 - chalk: 3.0.0 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 - lodash: 4.17.21 + picocolors: 1.1.1 redent: 3.0.0 '@testing-library/react@16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': @@ -8253,6 +8922,11 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/chart.js@2.9.41': dependencies: moment: 2.30.1 @@ -8489,7 +9163,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.7.3) '@typescript-eslint/types': 8.38.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -8512,7 +9186,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.7.3) '@typescript-eslint/utils': 8.29.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) - debug: 4.4.1(supports-color@8.1.1) + 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 @@ -8539,11 +9213,11 @@ snapshots: dependencies: '@typescript-eslint/types': 8.29.0 '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.6.3 + semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.7.3) typescript: 5.7.3 transitivePeerDependencies: @@ -8559,7 +9233,7 @@ snapshots: fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.6.3 + semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.7.3) typescript: 5.7.3 transitivePeerDependencies: @@ -8607,7 +9281,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@3.2.4(vitest@4.0.10)': + '@vitest/coverage-v8@3.2.4(vitest@4.0.16(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -8622,15 +9296,16 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 4.0.10(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 4.0.16(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color - '@vitest/expect@3.0.9': + '@vitest/expect@3.2.4': dependencies: - '@vitest/spy': 3.0.9 - '@vitest/utils': 3.0.9 - chai: 5.2.0 + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 tinyrainbow: 2.0.0 '@vitest/expect@4.0.10': @@ -8642,6 +9317,33 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 + '@vitest/expect@4.0.16': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + chai: 6.2.1 + tinyrainbow: 3.0.3 + + '@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: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.6.5(@types/node@22.18.1)(typescript@5.9.3) + vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + + '@vitest/mocker@3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.6.5(@types/node@22.18.1)(typescript@5.9.3) + vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + '@vitest/mocker@4.0.10(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@vitest/spy': 4.0.10 @@ -8651,7 +9353,16 @@ 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/pretty-format@3.0.9': + '@vitest/mocker@4.0.16(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + dependencies: + '@vitest/spy': 4.0.16 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.6.5(@types/node@22.18.1)(typescript@5.9.3) + vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -8659,23 +9370,40 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/pretty-format@4.0.16': + dependencies: + tinyrainbow: 3.0.3 + '@vitest/runner@4.0.10': dependencies: '@vitest/utils': 4.0.10 pathe: 2.0.3 + '@vitest/runner@4.0.16': + dependencies: + '@vitest/utils': 4.0.16 + pathe: 2.0.3 + '@vitest/snapshot@4.0.10': dependencies: '@vitest/pretty-format': 4.0.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.0.9': + '@vitest/snapshot@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.4 '@vitest/spy@4.0.10': {} + '@vitest/spy@4.0.16': {} + '@vitest/ui@4.0.10(vitest@4.0.10)': dependencies: '@vitest/utils': 4.0.10 @@ -8687,10 +9415,10 @@ snapshots: tinyrainbow: 3.0.3 vitest: 4.0.10(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - '@vitest/utils@3.0.9': + '@vitest/utils@3.2.4': dependencies: - '@vitest/pretty-format': 3.0.9 - loupe: 3.1.3 + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 tinyrainbow: 2.0.0 '@vitest/utils@4.0.10': @@ -8698,6 +9426,11 @@ snapshots: '@vitest/pretty-format': 4.0.10 tinyrainbow: 3.0.3 + '@vitest/utils@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + tinyrainbow: 3.0.3 + '@vueless/storybook-dark-mode@9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@storybook/global': 5.0.0 @@ -8720,7 +9453,7 @@ snapshots: agent-base@7.1.1: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -8736,17 +9469,17 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - akamai-cds-react-components@0.0.1-alpha.18(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + akamai-cds-react-components@0.0.1-alpha.19(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@lit/react': 1.0.8(@types/react@19.1.6) - akamai-cds-web-components: 0.0.1-alpha.18(@linode/design-language-system@5.0.0) + akamai-cds-web-components: 0.0.1-alpha.19(@linode/design-language-system@5.0.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: - '@linode/design-language-system' - '@types/react' - akamai-cds-web-components@0.0.1-alpha.18(@linode/design-language-system@5.0.0): + akamai-cds-web-components@0.0.1-alpha.19(@linode/design-language-system@5.0.0): dependencies: '@linode/design-language-system': 5.0.0 lit: 3.3.1 @@ -9056,13 +9789,13 @@ snapshots: dependencies: chai: 6.2.1 - chai@5.2.0: + chai@5.3.3: dependencies: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 loupe: 3.2.1 - pathval: 2.0.0 + pathval: 2.0.1 chai@6.2.1: {} @@ -9251,14 +9984,14 @@ snapshots: path-type: 4.0.0 yaml: 2.6.1 - cosmiconfig@8.3.6(typescript@5.7.3): + cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.0 js-yaml: 4.1.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: - typescript: 5.7.3 + typescript: 5.9.3 country-region-data@3.1.0: {} @@ -9686,10 +10419,10 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild-register@3.6.0(esbuild@0.25.3): + esbuild-register@3.6.0(esbuild@0.25.12): dependencies: - debug: 4.4.0 - esbuild: 0.25.3 + debug: 4.4.3(supports-color@8.1.1) + esbuild: 0.25.12 transitivePeerDependencies: - supports-color @@ -9750,6 +10483,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.3 '@esbuild/win32-x64': 0.25.3 + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -9977,6 +10739,8 @@ snapshots: expect-type@1.2.2: {} + expect-type@1.3.0: {} + extend@3.0.2: {} extract-zip@2.0.1(supports-color@8.1.1): @@ -10898,8 +11662,6 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.1.3: {} - loupe@3.2.1: {} lower-case@2.0.2: @@ -10932,7 +11694,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.6.3 + semver: 7.7.3 map-or-similar@1.5.0: {} @@ -11128,6 +11890,32 @@ snapshots: typescript: 5.7.3 transitivePeerDependencies: - '@types/node' + optional: true + + msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3): + dependencies: + '@bundled-es-modules/cookie': 2.0.1 + '@bundled-es-modules/statuses': 1.0.1 + '@bundled-es-modules/tough-cookie': 0.1.6 + '@inquirer/confirm': 5.1.0(@types/node@22.18.1) + '@mswjs/interceptors': 0.37.1 + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.6.0 + '@types/statuses': 2.0.5 + chalk: 4.1.2 + graphql: 16.9.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + strict-event-emitter: 0.5.1 + type-fest: 4.27.0 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' mute-stream@2.0.0: {} @@ -11206,6 +11994,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -11327,7 +12117,7 @@ snapshots: pathe@2.0.3: {} - pathval@2.0.0: {} + pathval@2.0.1: {} pdf2json@3.1.4: {} @@ -11450,9 +12240,9 @@ snapshots: react-csv@2.2.2: {} - react-docgen-typescript@2.2.2(typescript@5.7.3): + react-docgen-typescript@2.2.2(typescript@5.9.3): dependencies: - typescript: 5.7.3 + typescript: 5.9.3 react-docgen@8.0.0: dependencies: @@ -11557,7 +12347,7 @@ snapshots: readdirp@4.0.2: {} - recast@0.23.9: + recast@0.23.11: dependencies: ast-types: 0.16.1 esprima: 4.0.1 @@ -11749,6 +12539,34 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 + rollup@4.53.5: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.5 + '@rollup/rollup-android-arm64': 4.53.5 + '@rollup/rollup-darwin-arm64': 4.53.5 + '@rollup/rollup-darwin-x64': 4.53.5 + '@rollup/rollup-freebsd-arm64': 4.53.5 + '@rollup/rollup-freebsd-x64': 4.53.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.5 + '@rollup/rollup-linux-arm-musleabihf': 4.53.5 + '@rollup/rollup-linux-arm64-gnu': 4.53.5 + '@rollup/rollup-linux-arm64-musl': 4.53.5 + '@rollup/rollup-linux-loong64-gnu': 4.53.5 + '@rollup/rollup-linux-ppc64-gnu': 4.53.5 + '@rollup/rollup-linux-riscv64-gnu': 4.53.5 + '@rollup/rollup-linux-riscv64-musl': 4.53.5 + '@rollup/rollup-linux-s390x-gnu': 4.53.5 + '@rollup/rollup-linux-x64-gnu': 4.53.5 + '@rollup/rollup-linux-x64-musl': 4.53.5 + '@rollup/rollup-openharmony-arm64': 4.53.5 + '@rollup/rollup-win32-arm64-msvc': 4.53.5 + '@rollup/rollup-win32-ia32-msvc': 4.53.5 + '@rollup/rollup-win32-x64-gnu': 4.53.5 + '@rollup/rollup-win32-x64-msvc': 4.53.5 + fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} run-async@4.0.6: {} @@ -11811,6 +12629,8 @@ snapshots: semver@7.6.3: {} + semver@7.7.3: {} + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -11979,26 +12799,53 @@ snapshots: std-env@3.9.0: {} - storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3): + 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 - '@testing-library/jest-dom': 6.6.3 + '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) - '@vitest/expect': 3.0.9 - '@vitest/spy': 3.0.9 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/spy': 3.2.4 better-opn: 3.0.2 - esbuild: 0.25.3 - esbuild-register: 3.6.0(esbuild@0.25.3) - recast: 0.23.9 - semver: 7.6.3 - ws: 8.18.0 + esbuild: 0.25.12 + esbuild-register: 3.6.0(esbuild@0.25.12) + recast: 0.23.11 + semver: 7.7.3 + ws: 8.18.3 + optionalDependencies: + prettier: 3.5.3 + transitivePeerDependencies: + - '@testing-library/dom' + - bufferutil + - msw + - supports-color + - utf-8-validate + - vite + + storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + dependencies: + '@storybook/global': 5.0.0 + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/spy': 3.2.4 + better-opn: 3.0.2 + esbuild: 0.25.12 + esbuild-register: 3.6.0(esbuild@0.25.12) + recast: 0.23.11 + semver: 7.7.3 + ws: 8.18.3 optionalDependencies: prettier: 3.5.3 transitivePeerDependencies: - '@testing-library/dom' - bufferutil + - msw - supports-color - utf-8-validate + - vite strict-event-emitter@0.5.1: {} @@ -12186,6 +13033,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.2) @@ -12200,7 +13049,7 @@ snapshots: tinyrainbow@3.0.3: {} - tinyspy@3.0.2: {} + tinyspy@4.0.4: {} tldts-core@6.1.61: {} @@ -12271,7 +13120,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.7.3)(yaml@2.6.1): + tsup@8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.3) cac: 6.7.14 @@ -12292,7 +13141,7 @@ snapshots: optionalDependencies: '@swc/core': 1.13.5 postcss: 8.5.6 - typescript: 5.7.3 + typescript: 5.9.3 transitivePeerDependencies: - jiti - supports-color @@ -12301,7 +13150,7 @@ snapshots: tsx@4.19.3: dependencies: - esbuild: 0.25.3 + esbuild: 0.25.12 get-tsconfig: 4.8.1 optionalDependencies: fsevents: 2.3.3 @@ -12377,6 +13226,8 @@ snapshots: typescript@5.7.3: {} + typescript@5.9.3: {} + uc.micro@2.1.0: {} unbox-primitive@1.1.0: @@ -12493,17 +13344,28 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-svgr@4.5.0(rollup@4.53.3)(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)): + vite-plugin-svgr@4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.53.3) - '@svgr/core': 8.1.0(typescript@5.7.3) - '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3)) + '@rollup/pluginutils': 5.2.0(rollup@4.53.5) + '@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) transitivePeerDependencies: - rollup - supports-color - typescript + vite-plugin-svgr@4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + dependencies: + '@rollup/pluginutils': 5.2.0(rollup@4.53.5) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + transitivePeerDependencies: + - rollup + - supports-color + - typescript + 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: esbuild: 0.25.12 @@ -12520,6 +13382,22 @@ snapshots: tsx: 4.19.3 yaml: 2.6.1 + vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.5 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.18.1 + fsevents: 2.3.3 + jiti: 2.4.2 + terser: 5.36.0 + tsx: 4.19.3 + yaml: 2.6.1 + vitest@4.0.10(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: '@vitest/expect': 4.0.10 @@ -12561,6 +13439,45 @@ snapshots: - tsx - yaml + vitest@4.0.16(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + dependencies: + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + es-module-lexer: 1.7.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 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + 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: xml-name-validator: 5.0.0 @@ -12672,6 +13589,8 @@ snapshots: ws@8.18.0: {} + ws@8.18.3: {} + xml-name-validator@5.0.0: {} xml2js@0.6.2: