diff --git a/acc/Jenkinsfile b/acc/Jenkinsfile new file mode 100644 index 00000000000..a4b475e277b --- /dev/null +++ b/acc/Jenkinsfile @@ -0,0 +1,197 @@ +pipeline { + agent { + label "cloud-focal" + } + + options { + disableConcurrentBuilds() + } + + environment { + CI = 'true' + } + + stages { + stage("Setup pnpm") { + when { + anyOf { + branch 'develop' + branch 'staging' + branch 'master' + expression { env.CHANGE_ID != null } // It's a PR + } + } + steps { + nodejs(configId: "npmrc-ro", nodeJSInstallationName: "v22") { + sh "node -v" + sh "npm -v" + sh "pnpm -v || corepack enable pnpm" + sh "pnpm install:all" + } + } + } + + stage("Build dependencies") { + when { + anyOf { + branch 'develop' + branch 'staging' + branch 'master' + expression { env.CHANGE_ID != null } // It's a PR + } + } + steps { + nodejs(configId: "npmrc-ro", nodeJSInstallationName: "v22") { + sh "pnpm build:validation" + sh "pnpm build:sdk" + } + } + } + + stage("Validate") { + when { + anyOf { + branch 'develop' + branch 'staging' + branch 'master' + expression { env.CHANGE_ID != null } // It's a PR + } + } + parallel { + stage("Lint") { + steps { + nodejs(configId: "npmrc-ro", nodeJSInstallationName: "v22") { + sh "pnpm lint:all" + } + } + } + stage("Typecheck") { + steps { + nodejs(configId: "npmrc-ro", nodeJSInstallationName: "v22") { + sh "pnpm -r --parallel typecheck" + } + } + } + stage("Test") { + steps { + nodejs(configId: "npmrc-ro", nodeJSInstallationName: "v22") { + sh "pnpm test" + } + } + } + } + } + + stage("Build PR") { + when { + expression { env.CHANGE_ID != null } // It's a PR + } + steps { + nodejs(configId: "npmrc-ro", nodeJSInstallationName: "v22") { + sh "pnpm build" + } + } + } + + stage("Build develop") { + when { + anyOf { + branch pattern: "develop" + } + } + environment { + REACT_APP_API_MAX_PAGE_SIZE = "500" + REACT_APP_API_ROOT = "https://api.devcloud.linode.com/v4" + REACT_APP_APP_ROOT = "https://cloud.devcloud.linode.com" + REACT_APP_CLIENT_ID = "4bd20531408b51e73313" + REACT_APP_LAUNCH_DARKLY_ID = "67ed97f93736be0c14fa0ea2" + REACT_APP_LKE_HIGH_AVAILABILITY_PRICE = "60" + REACT_APP_LOGIN_ROOT = "https://login.devcloud.linode.com" + } + steps { + nodejs(configId: "npmrc-ro", nodeJSInstallationName: "v22") { + sh "pnpm build" + sh "pnpm release --env=develop" + } + } + } + + stage("Build staging") { + when { + anyOf { + branch pattern: "staging" + } + } + environment { + REACT_APP_PAYPAL_CLIENT_ID = "AWdnFJ_Yx5X9uqKZQdbdkLfCnEJwtauQJ2tyesKf3S0IxSrkRLmB2ZN2ACSwy37gxY_AZoTagHWlZCOA" + REACT_APP_GPAY_MERCHANT_ID = "BCR2DN6TZ6OMNHTP" + REACT_APP_ALGOLIA_APPLICATION_ID = "KGUN8FAIPF" + REACT_APP_ALGOLIA_SEARCH_KEY = "d4847002cd30392fe0fbd00a1da933ed" + REACT_APP_API_ROOT = "https://cloud.staging.linode.com/api/v4" + REACT_APP_APP_ROOT = "https://cloud.staging.linode.com" + REACT_APP_CLIENT_ID = "6714f4f895cbe13a5781" + REACT_APP_ENVIRONMENT_NAME = "staging" + REACT_APP_LISH_ROOT = "webconsole.linode.com" + REACT_APP_LOGIN_ROOT = "https://login.staging.linode.com" + REACT_APP_ADOBE_ANALYTICS_URL = "https://assets.adobedtm.com/fcfd3580c848/15e23aa7fce2/launch-5bda4b7a1db9-staging.min.js" + REACT_APP_PENDO_API_KEY = "46f744c8-8628-4dc4-55f9-83fdd3bf2eef" + REACT_APP_SENTRY_URL = "https://ec5fcee0daee4bc9bb74e5b614e27ee0@o83654.ingest.sentry.io/182732" + REACT_APP_LAUNCH_DARKLY_ID = "5ed11a54d310ac0a0e542702" + REACT_APP_API_MAX_PAGE_SIZE = "500" + REACT_APP_STATUS_PAGE_URL = "https://status.linode.com/api/v2" + } + steps { + nodejs(configId: "npmrc-ro", nodeJSInstallationName: "v22") { + sh "pnpm build" + sh "pnpm release --env=staging" + } + } + } + + stage("Build master") { + when { + anyOf { + branch pattern: "master" + } + } + environment { + REACT_APP_ADOBE_ANALYTICS_URL = "https://assets.adobedtm.com/fcfd3580c848/15e23aa7fce2/launch-9ea21650035a.min.js" + REACT_APP_ALGOLIA_APPLICATION_ID = "KGUN8FAIPF" + REACT_APP_ALGOLIA_SEARCH_KEY = "d4847002cd30392fe0fbd00a1da933ed" + REACT_APP_API_MAX_PAGE_SIZE = "500" + REACT_APP_API_ROOT = "https://cloud.linode.com/api/v4" + REACT_APP_APP_ROOT = "https://cloud.linode.com" + REACT_APP_CLIENT_ID = "04b4276a4094ce378d27" + REACT_APP_ENVIRONMENT_NAME = "production" + REACT_APP_GPAY_MERCHANT_ID = "BCR2DN6TZ6OMNHTP" + REACT_APP_LAUNCH_DARKLY_ID = "5cd5be32283709081fd70fbc" + REACT_APP_LISH_ROOT = "webconsole.linode.com" + REACT_APP_LOGIN_ROOT = "https://login.linode.com" + REACT_APP_PAYPAL_CLIENT_ID = "AWdnFJ_Yx5X9uqKZQdbdkLfCnEJwtauQJ2tyesKf3S0IxSrkRLmB2ZN2ACSwy37gxY_AZoTagHWlZCOA" + REACT_APP_PENDO_API_KEY = '46f744c8-8628-4dc4-55f9-83fdd3bf2eef' + REACT_APP_SENTRY_URL = "https://ec5fcee0daee4bc9bb74e5b614e27ee0@o83654.ingest.sentry.io/182732" + REACT_APP_STATUS_PAGE_URL = "https://status.linode.com/api/v2" + } + steps { + nodejs(configId: "npmrc-ro", nodeJSInstallationName: "v22") { + sh "pnpm build" + sh "pnpm release --env=master" + } + } + } + + stage("Scan") { + when { + anyOf { + branch pattern: "develop" + branch pattern: "staging" + branch pattern: "master" + } + } + steps { + pulsarBlackduck(bdApplicationId: "cloud-manager", buildType: "FRONTEND") + pulsarSonar(jobType: "FREESTYLE", projectName: "cloud-manager", customSrc: true) + } + } + } +} diff --git a/acc/publish.mjs b/acc/publish.mjs new file mode 100644 index 00000000000..858d4f09c2a --- /dev/null +++ b/acc/publish.mjs @@ -0,0 +1,49 @@ +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; +import { fileURLToPath } from "url"; + +const args = process.argv.slice(2); +const envFlag = args.find(arg => arg.startsWith("--env=")); +const env = envFlag ? envFlag.split("=")[1] : ''; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const pathToPackage = path.join( + __dirname, + "../packages/manager/package.json", +); +const file = fs.readFileSync(pathToPackage, "utf-8"); +const pkg = JSON.parse(file); + +const gitHash = execSync("git rev-parse --short HEAD", { + encoding: "utf8", +}).trim(); +const currentDate = execSync("date +%Y%m%dT%H%M%S", { + encoding: "utf8", +}).trim(); + +// uses a variable that we know exists on jenkins: https://wiki.jenkins.io/display/JENKINS/Git+Plugin +const branch = (process.env.GIT_BRANCH || "").trim(); +const isDev = branch === "develop"; +const isStage = branch === "staging"; +const isProd = branch === "master"; + +if (isDev || isStage) { + console.log("Publishing develop:", pkg.version); + pkg.version = `${pkg.version.split("-")[0]}-${env}-${currentDate}-${gitHash}`; + fs.writeFileSync(pathToPackage, JSON.stringify(pkg, null, 2)); + execSync("pnpm publish --no-git-checks", { + cwd: path.join(__dirname, "../packages/manager"), + stdio: "inherit", + }); +} else if (isProd) { + console.log("Publishing master:", pkg.version); + execSync("pnpm publish --no-git-checks", { + cwd: path.join(__dirname, "../packages/manager"), + stdio: "inherit", + }); +} else { + console.log("On branch:", branch, ", skipping publish"); +} \ No newline at end of file diff --git a/package.json b/package.json index cc10638349c..6d64339a9f5 100644 --- a/package.json +++ b/package.json @@ -34,40 +34,41 @@ "install:all": "pnpm install --frozen-lockfile", "build:sdk": "pnpm run --filter @linode/api-v4 build", "build:validation": "pnpm run --filter @linode/validation build", - "build": "pnpm build:validation && pnpm build:sdk && pnpm --filter linode-manager build", - "build:analyze": "pnpm run --filter linode-manager build:analyze", + "build": "pnpm build:validation && pnpm build:sdk && pnpm --filter @linode/cloud-manager build", + "build:analyze": "pnpm run --filter @linode/cloud-manager build:analyze", "bootstrap": "pnpm install:all && pnpm build:validation && pnpm build:sdk", "up:expose": "npm_config_package_import_method=clone-or-copy pnpm install:all && pnpm build:validation && pnpm build:sdk && pnpm start:all:expose", - "dev": "concurrently -n api-v4,validation,ui,utilities,queries,shared,manager -c blue,yellow,magenta,cyan,gray,blue,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter @linode/shared start\" \"pnpm run --filter linode-manager start\"", - "start:all:expose": "concurrently -n api-v4,validation,ui,utilities,queries,shared,manager -c blue,yellow,magenta,cyan,gray,blue,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter @linode/shared start\" \"pnpm run --filter linode-manager start:expose\"", - "start:manager": "pnpm --filter linode-manager start", - "start:manager:ci": "pnpm run --filter linode-manager start:ci", + "dev": "concurrently -n api-v4,validation,ui,utilities,queries,shared,manager -c blue,yellow,magenta,cyan,gray,blue,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter @linode/shared start\" \"pnpm run --filter @linode/cloud-manager start\"", + "start:all:expose": "concurrently -n api-v4,validation,ui,utilities,queries,shared,manager -c blue,yellow,magenta,cyan,gray,blue,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter @linode/shared start\" \"pnpm run --filter @linode/cloud-manager start:expose\"", + "start:manager": "pnpm --filter @linode/cloud-manager start", + "start:manager:ci": "pnpm run --filter @linode/cloud-manager start:ci", "docs": "bunx vitepress@1.0.0-rc.44 dev docs", - "storybook": "pnpm run --filter linode-manager storybook", + "storybook": "pnpm run --filter @linode/cloud-manager storybook", "test": "vitest run", "test:watch": "vitest", - "test:manager": "pnpm run --filter linode-manager test", + "test:manager": "pnpm run --filter @linode/cloud-manager test", "test:sdk": "pnpm run --filter @linode/api-v4 test", "test:search": "pnpm run --filter @linode/search test", "test:ui": "pnpm run --filter @linode/ui test", "test:utilities": "pnpm run --filter @linode/utilities test", "test:shared": "pnpm run --filter @linode/shared test", - "coverage": "pnpm run --filter linode-manager coverage", - "coverage:summary": "pnpm run --filter linode-manager coverage:summary", - "cy:run": "pnpm run --filter linode-manager cy:run", - "cy:e2e": "pnpm run --filter linode-manager cy:e2e", + "coverage": "pnpm run --filter @linode/cloud-manager coverage", + "coverage:summary": "pnpm run --filter @linode/cloud-manager coverage:summary", + "cy:run": "pnpm run --filter @linode/cloud-manager cy:run", + "cy:e2e": "pnpm run --filter @linode/cloud-manager cy:e2e", "cy:ci": "pnpm cy:e2e", - "cy:debug": "pnpm run --filter linode-manager cy:debug", - "cy:component": "pnpm run --filter linode-manager cy:component", - "cy:component:run": "pnpm run --filter linode-manager cy:component:run", - "cy:rec-snap": "pnpm run --filter linode-manager cy:rec-snap", + "cy:debug": "pnpm run --filter @linode/cloud-manager cy:debug", + "cy:component": "pnpm run --filter @linode/cloud-manager cy:component", + "cy:component:run": "pnpm run --filter @linode/cloud-manager cy:component:run", + "cy:rec-snap": "pnpm run --filter @linode/cloud-manager cy:rec-snap", "changeset": "pnpm run --filter @linode/scripts changeset", "generate-changelogs": "pnpm run --filter @linode/scripts generate-changelogs", "package-versions": "pnpm run --filter @linode/scripts package-versions", "junit:summary": "pnpm run --filter @linode/scripts --silent junit:summary", "generate-tod": "pnpm run --filter @linode/scripts --silent generate-tod", "clean": "concurrently \"rm -rf node_modules\" \"pnpm -r exec rm -rf node_modules lib dist\" \"pnpm store prune\"", - "prepare": "husky" + "prepare": "husky", + "release": "node acc/publish.mjs" }, "resolutions": { "semver": "^7.5.2", diff --git a/packages/api-v4/.changeset/pr-13504-added-1775602581034.md b/packages/api-v4/.changeset/pr-13504-added-1775602581034.md new file mode 100644 index 00000000000..11cd67a7fe9 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13504-added-1775602581034.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +SubnetAssignedDatabaseData interface and update to Subnet to include databases property ([#13504](https://github.com/linode/manager/pull/13504)) diff --git a/packages/api-v4/.changeset/pr-13559-tech-stories-1775480693116.md b/packages/api-v4/.changeset/pr-13559-tech-stories-1775480693116.md new file mode 100644 index 00000000000..2e63563425e --- /dev/null +++ b/packages/api-v4/.changeset/pr-13559-tech-stories-1775480693116.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Tech Stories +--- + +Bump `tsup` from `8.4.0` to `8.5.1` ([#13559](https://github.com/linode/manager/pull/13559)) diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index efa9208682d..31ba719d4b2 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -41,7 +41,7 @@ "unpkg": "./lib/index.global.js", "dependencies": { "@linode/validation": "workspace:*", - "axios": "~1.13.5", + "axios": "~1.15.0", "ipaddr.js": "^2.0.0", "yup": "^1.4.0" }, @@ -59,7 +59,7 @@ "@linode/tsconfig": "workspace:*", "axios-mock-adapter": "^1.22.0", "concurrently": "^9.0.1", - "tsup": "^8.4.0", + "tsup": "^8.5.1", "@types/node": "^22.13.14" }, "lint-staged": { diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts index 384cf4a7b26..97f2202f0b9 100644 --- a/packages/api-v4/src/delivery/types.ts +++ b/packages/api-v4/src/delivery/types.ts @@ -1,5 +1,7 @@ export const streamStatus = { Active: 'active', + Deactivating: 'deactivating', + Failed: 'failed', Inactive: 'inactive', Provisioning: 'provisioning', } as const; diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 79b35707a30..7487e13cab4 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -653,6 +653,11 @@ export interface CreateLinodeRequest { * @default false */ backups_enabled?: boolean | null; + /** + * When deploying from an Image, this field is optional, otherwise it is ignored. + * This is used to set the boot size for the newly-created Linode. + */ + boot_size?: null | number; /** * If it is deployed from an Image or a Backup and you wish it to remain offline after deployment, set this to false. * @@ -694,6 +699,11 @@ export interface CreateLinodeRequest { * Must be empty if Linode is configured to use new Linode Interfaces. */ ipv4?: string[]; + /** + * When deploying from an Image, this field is optional, otherwise it is ignored. + * This is used to set the kernel type for the newly-created Linode. + */ + kernel?: null | string; /** * The Linode's label is for display purposes only. * If no label is provided for a Linode, a default will be assigned. diff --git a/packages/api-v4/src/networking/networking.ts b/packages/api-v4/src/networking/networking.ts index 71f4a415b5c..7fa3db1ce7a 100644 --- a/packages/api-v4/src/networking/networking.ts +++ b/packages/api-v4/src/networking/networking.ts @@ -294,7 +294,7 @@ export const unReserveIP = (ipAddress: string) => */ export const getReservedIPsTypes = (params?: Params) => Request>( - setURL(`${API_ROOT}/networking/reserved/ips/types`), + setURL(`${BETA_API_ROOT}/networking/reserved/ips/types`), setMethod('GET'), setParams(params), ); diff --git a/packages/api-v4/src/networking/types.ts b/packages/api-v4/src/networking/types.ts index 45252fb763b..6e49dbe17bc 100644 --- a/packages/api-v4/src/networking/types.ts +++ b/packages/api-v4/src/networking/types.ts @@ -1,13 +1,8 @@ -export interface AssignedEntity { - id: number; - label: string; - type: string; - url: string; -} +import type { Entity } from '../account/types'; export interface IPAddress { address: string; - assigned_entity: AssignedEntity | null; + assigned_entity: Entity | null; gateway: null | string; interface_id: null | number; linode_id: null | number; diff --git a/packages/api-v4/src/vpcs/types.ts b/packages/api-v4/src/vpcs/types.ts index 8bd11c29d41..33d0c486f35 100644 --- a/packages/api-v4/src/vpcs/types.ts +++ b/packages/api-v4/src/vpcs/types.ts @@ -42,6 +42,7 @@ export interface CreateSubnetPayload { export interface Subnet extends CreateSubnetPayload { created: string; + databases: SubnetAssignedDatabaseData[]; id: number; linodes: SubnetAssignedLinodeData[]; nodebalancers: SubnetAssignedNodeBalancerData[]; @@ -68,6 +69,12 @@ export interface SubnetAssignedNodeBalancerData { ipv4_range: string; } +export interface SubnetAssignedDatabaseData { + id: number; + ipv4_range: string; + ipv6_ranges: null | { range: string }[]; +} + export interface VPCIP { active: boolean; address: null | string; diff --git a/packages/manager/.changeset/pr-13485-upcoming-features-1773779457837.md b/packages/manager/.changeset/pr-13485-upcoming-features-1773779457837.md new file mode 100644 index 00000000000..f184eaf4151 --- /dev/null +++ b/packages/manager/.changeset/pr-13485-upcoming-features-1773779457837.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Upcoming: [M3-10780] - prepare for LKE Dashboard removal ([#13485](https://github.com/linode/manager/pull/13485)) diff --git a/packages/manager/.changeset/pr-13492-added-1773404986416.md b/packages/manager/.changeset/pr-13492-added-1773404986416.md new file mode 100644 index 00000000000..cbd40ecf891 --- /dev/null +++ b/packages/manager/.changeset/pr-13492-added-1773404986416.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Stream Metrics tab with embedded metrics dashboard ([#13492](https://github.com/linode/manager/pull/13492)) diff --git a/packages/manager/.changeset/pr-13504-upcoming-features-1775679549160.md b/packages/manager/.changeset/pr-13504-upcoming-features-1775679549160.md new file mode 100644 index 00000000000..d1e65099f62 --- /dev/null +++ b/packages/manager/.changeset/pr-13504-upcoming-features-1775679549160.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DBaaS resource counts and databases resource table in VPC UI ([#13504](https://github.com/linode/manager/pull/13504)) diff --git a/packages/manager/.changeset/pr-13516-upcoming-features-1774344661328.md b/packages/manager/.changeset/pr-13516-upcoming-features-1774344661328.md new file mode 100644 index 00000000000..65215878176 --- /dev/null +++ b/packages/manager/.changeset/pr-13516-upcoming-features-1774344661328.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + + Allow Creating Secure Linodes without Root Password ([#13516](https://github.com/linode/manager/pull/13516)) diff --git a/packages/manager/.changeset/pr-13539-tests-1775850586793.md b/packages/manager/.changeset/pr-13539-tests-1775850586793.md new file mode 100644 index 00000000000..aafad303056 --- /dev/null +++ b/packages/manager/.changeset/pr-13539-tests-1775850586793.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Tests +--- + +Mock `databaseResizeGenerationalPlans` feature flag to false for resize-database.spec.ts ([#13539](https://github.com/linode/manager/pull/13539)) diff --git a/packages/manager/.changeset/pr-13539-upcoming-features-1775850537076.md b/packages/manager/.changeset/pr-13539-upcoming-features-1775850537076.md new file mode 100644 index 00000000000..a7e7d9274b4 --- /dev/null +++ b/packages/manager/.changeset/pr-13539-upcoming-features-1775850537076.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Generational plans changes for Databases ([#13539](https://github.com/linode/manager/pull/13539)) diff --git a/packages/manager/.changeset/pr-13541-upcoming-features-1774844980662.md b/packages/manager/.changeset/pr-13541-upcoming-features-1774844980662.md new file mode 100644 index 00000000000..5f0d6574281 --- /dev/null +++ b/packages/manager/.changeset/pr-13541-upcoming-features-1774844980662.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Reserve IP - Implement drawer to reserve an IP address ([#13541](https://github.com/linode/manager/pull/13541)) diff --git a/packages/manager/.changeset/pr-13547-fixed-1774943336443.md b/packages/manager/.changeset/pr-13547-fixed-1774943336443.md new file mode 100644 index 00000000000..8e10d68ebcf --- /dev/null +++ b/packages/manager/.changeset/pr-13547-fixed-1774943336443.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Increase profile preferences reliability ([#13547](https://github.com/linode/manager/pull/13547)) diff --git a/packages/manager/.changeset/pr-13549-upcoming-features-1774976696073.md b/packages/manager/.changeset/pr-13549-upcoming-features-1774976696073.md new file mode 100644 index 00000000000..0b2ddd23261 --- /dev/null +++ b/packages/manager/.changeset/pr-13549-upcoming-features-1774976696073.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Implemented Reserved IPs Landing Page ([#13549](https://github.com/linode/manager/pull/13549)) diff --git a/packages/manager/.changeset/pr-13550-upcoming-features-1774974481770.md b/packages/manager/.changeset/pr-13550-upcoming-features-1774974481770.md new file mode 100644 index 00000000000..0dee27b3bb8 --- /dev/null +++ b/packages/manager/.changeset/pr-13550-upcoming-features-1774974481770.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Private Image Sharing: Implement basic structure of Share Group Create page ([#13550](https://github.com/linode/manager/pull/13550)) diff --git a/packages/manager/.changeset/pr-13551-added-1774974854479.md b/packages/manager/.changeset/pr-13551-added-1774974854479.md new file mode 100644 index 00000000000..882d080c970 --- /dev/null +++ b/packages/manager/.changeset/pr-13551-added-1774974854479.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +New stream statuses - deactivating, failed ([#13551](https://github.com/linode/manager/pull/13551)) diff --git a/packages/manager/.changeset/pr-13552-changed-1775047281616.md b/packages/manager/.changeset/pr-13552-changed-1775047281616.md new file mode 100644 index 00000000000..14abaa5c92c --- /dev/null +++ b/packages/manager/.changeset/pr-13552-changed-1775047281616.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Use API error message in Stream and Destination Create and Edit forms snack bars ([#13552](https://github.com/linode/manager/pull/13552)) diff --git a/packages/manager/.changeset/pr-13554-upcoming-features-1775117165280.md b/packages/manager/.changeset/pr-13554-upcoming-features-1775117165280.md new file mode 100644 index 00000000000..dc15f465a9a --- /dev/null +++ b/packages/manager/.changeset/pr-13554-upcoming-features-1775117165280.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add newFeatureChip to the PrimaryNav,AlertReusableComponent to ACLP Alert's heading ([#13554](https://github.com/linode/manager/pull/13554)) diff --git a/packages/manager/.changeset/pr-13556-changed-1775141881220.md b/packages/manager/.changeset/pr-13556-changed-1775141881220.md new file mode 100644 index 00000000000..b28ec278a77 --- /dev/null +++ b/packages/manager/.changeset/pr-13556-changed-1775141881220.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Logs Delivery copy based on tech writing updates ([#13556](https://github.com/linode/manager/pull/13556)) diff --git a/packages/manager/.changeset/pr-13558-upcoming-features-1775269042594.md b/packages/manager/.changeset/pr-13558-upcoming-features-1775269042594.md new file mode 100644 index 00000000000..e04813ea9f1 --- /dev/null +++ b/packages/manager/.changeset/pr-13558-upcoming-features-1775269042594.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Private Image Sharing: add View Shared Image Details drawer ([#13558](https://github.com/linode/manager/pull/13558)) diff --git a/packages/manager/.changeset/pr-13559-tech-stories-1775480790969.md b/packages/manager/.changeset/pr-13559-tech-stories-1775480790969.md new file mode 100644 index 00000000000..6d6f49cc486 --- /dev/null +++ b/packages/manager/.changeset/pr-13559-tech-stories-1775480790969.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Bump `vite` from `7.2.2` to `7.3.2` ([#13559](https://github.com/linode/manager/pull/13559)) diff --git a/packages/manager/.changeset/pr-13562-fixed-1775692362893.md b/packages/manager/.changeset/pr-13562-fixed-1775692362893.md new file mode 100644 index 00000000000..4be1981c38d --- /dev/null +++ b/packages/manager/.changeset/pr-13562-fixed-1775692362893.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +LKE version upgrade logic ([#13562](https://github.com/linode/manager/pull/13562)) diff --git a/packages/manager/.changeset/pr-13567-upcoming-features-1775674749388.md b/packages/manager/.changeset/pr-13567-upcoming-features-1775674749388.md new file mode 100644 index 00000000000..04fd0c9e1ee --- /dev/null +++ b/packages/manager/.changeset/pr-13567-upcoming-features-1775674749388.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Private Image Sharing: implement Select Images table on Share Group Create page ([#13567](https://github.com/linode/manager/pull/13567)) diff --git a/packages/manager/.changeset/pr-13568-upcoming-features-1775671959206.md b/packages/manager/.changeset/pr-13568-upcoming-features-1775671959206.md new file mode 100644 index 00000000000..9296046d031 --- /dev/null +++ b/packages/manager/.changeset/pr-13568-upcoming-features-1775671959206.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Private Image Sharing: include public images in images table in Linode Rebuild dialog ([#13568](https://github.com/linode/manager/pull/13568)) diff --git a/packages/manager/.changeset/pr-13570-fixed-1776157063540.md b/packages/manager/.changeset/pr-13570-fixed-1776157063540.md new file mode 100644 index 00000000000..c8196a1cbed --- /dev/null +++ b/packages/manager/.changeset/pr-13570-fixed-1776157063540.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Login issue: Use sessionStorage for codeVerifier and nonce ([#13570](https://github.com/linode/manager/pull/13570)) diff --git a/packages/manager/.changeset/pr-13571-changed-1775819280655.md b/packages/manager/.changeset/pr-13571-changed-1775819280655.md new file mode 100644 index 00000000000..ffa7900d973 --- /dev/null +++ b/packages/manager/.changeset/pr-13571-changed-1775819280655.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM: remove LA badge and add only New badge, text update for User's Account Delegation search result ([#13571](https://github.com/linode/manager/pull/13571)) diff --git a/packages/manager/.changeset/pr-13572-tests-1775773189306.md b/packages/manager/.changeset/pr-13572-tests-1775773189306.md new file mode 100644 index 00000000000..525d92dea5a --- /dev/null +++ b/packages/manager/.changeset/pr-13572-tests-1775773189306.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Mock private image sharing flag to be disabled in Image tests ([#13572](https://github.com/linode/manager/pull/13572)) diff --git a/packages/manager/.changeset/pr-13573-upcoming-features-1775770343868.md b/packages/manager/.changeset/pr-13573-upcoming-features-1775770343868.md new file mode 100644 index 00000000000..f2aef03a078 --- /dev/null +++ b/packages/manager/.changeset/pr-13573-upcoming-features-1775770343868.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add databaseValkey feature flag ([#13573](https://github.com/linode/manager/pull/13573)) diff --git a/packages/manager/.changeset/pr-13574-fixed-1775805450095.md b/packages/manager/.changeset/pr-13574-fixed-1775805450095.md new file mode 100644 index 00000000000..6aa9f0d13d5 --- /dev/null +++ b/packages/manager/.changeset/pr-13574-fixed-1775805450095.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Destination Form: adjust tooltips' icons positions for Akamai Object Storage destination ([#13574](https://github.com/linode/manager/pull/13574)) diff --git a/packages/manager/.changeset/pr-13577-upcoming-features-1776064521778.md b/packages/manager/.changeset/pr-13577-upcoming-features-1776064521778.md new file mode 100644 index 00000000000..8d6978ccd56 --- /dev/null +++ b/packages/manager/.changeset/pr-13577-upcoming-features-1776064521778.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Reserved IPs - Unreserve an IP address ([#13577](https://github.com/linode/manager/pull/13577)) diff --git a/packages/manager/.changeset/pr-62-upcoming-features-1776425273770.md b/packages/manager/.changeset/pr-62-upcoming-features-1776425273770.md new file mode 100644 index 00000000000..9058acbc3aa --- /dev/null +++ b/packages/manager/.changeset/pr-62-upcoming-features-1776425273770.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Upcoming: [UIE-10437] - Handle Reserve IP integration for Node Balancer Create ([#62](https://git.source.akamai.com/projects/FEE/repos/cloud-manager/pull-requests/62/overview)) diff --git a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts index a03280a344b..7c674e36eeb 100644 --- a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts +++ b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts @@ -106,6 +106,7 @@ describe('restricted user details pages', () => { mockAppendFeatureFlags({ apl: false, dbaasV2: { beta: false, enabled: false }, + privateImageSharing: false, }); }); diff --git a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts index 62d822849ba..35acb55ff47 100644 --- a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts @@ -1,11 +1,3 @@ -/** - * @file DBaaS integration tests for resize operations. - */ -import { - ClusterSize, - DatabaseStatus, - RegionAvailability, -} from '@linode/api-v4'; import { accountFactory } from '@src/factories'; import { databaseConfigurationsResize, @@ -20,6 +12,7 @@ import { mockResize, mockResizeProvisioningDatabase, } from 'support/intercepts/databases'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetRegionAvailability } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { randomIp, randomNumber, randomString } from 'support/util/random'; @@ -27,6 +20,14 @@ import { getRegionById } from 'support/util/regions'; import { databaseFactory } from 'src/factories/databases'; +/** + * @file DBaaS integration tests for resize operations. + */ +import type { + ClusterSize, + DatabaseStatus, + RegionAvailability, +} from '@linode/api-v4'; import type { DatabaseClusterConfiguration } from 'support/constants/databases'; /** @@ -63,16 +64,20 @@ const resizeDatabase = (initialLabel: string) => { * @param clusterSize - Database Cluster Size */ const getNodes = (clusterSize: number) => { - const nodes = - clusterSize == 1 - ? 'Primary (1 Node)' - : clusterSize == 2 - ? 'Primary (+1 Node)' - : 'Primary (+2 Nodes)'; - return nodes; + return clusterSize === 1 + ? 'Primary (1 Node)' + : clusterSize === 2 + ? 'Primary (+1 Node)' + : 'Primary (+2 Nodes)'; }; describe('Resizing existing clusters', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + databaseResizeGenerationalPlans: false, + }); + }); + databaseConfigurationsResize.forEach( (configuration: DatabaseClusterConfiguration) => { describe(`Resizes a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { 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 e225347f4e6..d7dbbc7eaf5 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -4,7 +4,7 @@ import { accountFactory } from '@src/factories'; import { - databaseConfigurations, + databaseConfigurationsUpdate, mockDatabaseNodeTypes, } from 'support/constants/databases'; import { mockGetAccount } from 'support/intercepts/account'; @@ -21,7 +21,6 @@ import { mockUpdateDatabase, mockUpdateSuspendResumeDatabase, } from 'support/intercepts/databases'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { randomIp, @@ -228,87 +227,16 @@ const suspendCluster = (label: string) => { }; /** - * Validates no updates can be performed for a suspended or resuming cluster + * Returns the appropriate error message for a given database status. * - * This requires that the cluster is 'Suspended' or 'Resuming' - * - * @param engine - db engine - * @param id - cluster id - * @param initialLabel - cluster name - * @param updateAttemptLabel - cluster updated name - * @param errorMessage - error thrown for updating a suspended/resuming cluster - * @param allowedIp - ip for manage access actions + * @param status - The database status. + * @returns Error message string. */ - -const validateSuspendResume = ( - engine: string, - id: number, - initialLabel: string, - updateAttemptLabel: string, - errorMessage: string, - allowedIp: string -) => { - cy.visit(`/databases/${engine}/${id}`); - cy.wait('@getDatabase'); - - // Cannot update label when database/cluster is suspended or resuming. - updateDatabaseLabel(initialLabel, updateAttemptLabel); - cy.wait('@updateDatabase'); - cy.findByText(errorMessage).should('be.visible'); - cy.get('[data-qa-cancel-edit="true"]') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.findByText('Connection Details'); - - // DBaaS passwords cannot be revealed when database/cluster is suspended or resuming. - ui.cdsButton.findButtonByTitle('Show').should('be.disabled'); - - // Navigate to "Settings" tab. - ui.tabList.findTabByTitle('Settings').click(); - - // Cannot reset root password when database/cluster is suspended or resuming. - resetRootPassword(); - cy.wait('@resetRootPassword'); - ui.dialog - .findByTitle('Reset Root Password') - .should('be.visible') - .within(() => { - cy.findByText(errorMessage).should('be.visible'); - - ui.buttonGroup - .findButtonByTitle('Cancel') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Cannot change maintenance schedule when database/cluster is suspended or resuming. - modifyMaintenanceWindow('Day of Week', 'Wednesday'); - cy.wait('@updateDatabase'); - cy.findByText(errorMessage).should('be.visible'); - - // Navigate to "Networking" tab. - ui.tabList.findTabByTitle('Networking').click(); - - // Cannot add or remove allowed IPs when database/cluster is suspended or resuming. - removeAllowedIp(allowedIp); - cy.wait('@updateDatabase'); - ui.dialog - .findByTitle(`Remove IP Address ${allowedIp}`) - .should('be.visible') - .within(() => { - cy.findByText(errorMessage).should('be.visible'); - ui.buttonGroup.findButtonByTitle('Cancel').should('be.visible').click(); - }); - - manageAccessControl([randomIp()], 1); - cy.wait('@updateDatabase'); - ui.drawer.findByTitle('Manage Access').within(() => { - cy.findByText(errorMessage).should('be.visible'); - ui.drawerCloseButton.find().click(); - }); +const getBlockedErrorMessage = (status: string): string => { + if (status === 'provisioning') { + return 'Database still provisioning; please try again later.'; + } + return `Your database is ${status}; please wait until it becomes active to perform this operation.`; }; const validateActionItems = (state: string, label: string) => { @@ -345,500 +273,513 @@ 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({ - databaseVpc: true, - }); + mockGetAccount(accountFactory.build()).as('getAccount'); + mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); }); - databaseConfigurations.forEach( + + databaseConfigurationsUpdate.forEach( (configuration: DatabaseClusterConfiguration) => { - describe(`updates a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { - /* - * - Tests active database update UI flows using mocked data. - * - Confirms that users can change database label. - * - Confirms that users can change access controls. - * - Confirms that users can reset root passwords for active clusters. - * - Confirms that users can change maintenance schedules. - */ - it(`Can update active database clusters`, () => { - const initialLabel = configuration.label; - const updatedLabel = randomLabel(); - const allowedIp = randomIp(); - const newAllowedIp = randomIp(); - const initialPassword = randomString(16); - const database = databaseFactory.build({ - allow_list: [allowedIp], - engine: configuration.dbType, - id: randomNumber(1, 1000), - label: initialLabel, - platform: 'rdbms-default', - region: configuration.region.id, - status: 'active', - type: configuration.linodeType, - version: configuration.version, - }); + describe(`${configuration.engine} ${configuration.clusterSize}-node cluster`, () => { + // Test for statuses where updates are allowed (active, provisioning) + const editableStatuses = ['active', 'provisioning'] as const; + + editableStatuses.forEach((status) => { + describe(`${status} clusters`, () => { + let database: Database; + let allowedIp: string; + let newAllowedIp: string; + + beforeEach(() => { + allowedIp = randomIp(); + newAllowedIp = randomIp(); + database = databaseFactory.build({ + allow_list: [allowedIp], + engine: configuration.dbType, + id: randomNumber(1, 1000), + label: configuration.label, + platform: 'rdbms-default', + region: configuration.region.id, + status, + type: configuration.linodeType, + version: configuration.version, + }); + + mockGetDatabase(database).as('getDatabase'); + }); - mockGetAccount(accountFactory.build()).as('getAccount'); - mockGetDatabase(database).as('getDatabase'); - mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); - mockResetPassword(database.id, database.engine).as( - 'resetRootPassword' - ); - mockGetDatabaseCredentials( - database.id, - database.engine, - initialPassword - ).as('getCredentials'); - - cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); - cy.wait(['@getDatabase', '@getDatabaseTypes']); - - // Validation for summary page details - cy.findByText('Cluster Configuration'); - cy.findByText(configuration.region.label).should('be.visible'); - cy.findByText(database.total_disk_size_gb + ' GB').should( - 'be.visible' - ); - - cy.findByText('Connection Details'); - // "Show" button should be enabled to reveal password when DB is active. - ui.cdsButton.findButtonByTitle('Show').should('be.enabled').click(); - - cy.wait('@getCredentials'); - cy.findByText(`${initialPassword}`); - - // "Hide" button should be enabled to hide password when password is revealed. - ui.cdsButton - .findButtonByTitle('Hide') - .should('be.visible') - .should('be.enabled') - .click(); - - // Update labels for cluster in active/provisioning state - mockUpdateDatabase(database.id, database.engine, { - ...database, - label: updatedLabel, - }).as('updateDatabaseLabel'); - updateDatabaseLabel(initialLabel, updatedLabel); - cy.wait('@updateDatabaseLabel'); - cy.get('[data-qa-header]') - .should('be.visible') - .should('have.text', updatedLabel); - - // Navigate to "Settings" tab. - ui.tabList.findTabByTitle('Settings').click(); - - // Reset root password. - resetRootPassword(); - cy.wait('@resetRootPassword'); - - // Change maintenance window and database version upgrade. - mockUpdateDatabase(database.id, database.engine, database).as( - 'updateDatabaseMaintenance' - ); - upgradeEngineVersion(database.engine, database.version); - - modifyMaintenanceWindow('Day of Week', 'Wednesday'); - cy.wait('@updateDatabaseMaintenance'); - ui.toast.assertMessage( - 'Maintenance Window settings saved successfully.' - ); - - modifyMaintenanceWindow('Time', '12:00'); - cy.wait('@updateDatabaseMaintenance'); - ui.toast.assertMessage( - 'Maintenance Window settings saved successfully.' - ); - - // Navigate to "Networking" tab. - ui.tabList.findTabByTitle('Networking').click(); - - // Remove allowed IP, manage IP access control. - mockUpdateDatabase(database.id, database.engine, { - ...database, - allow_list: [], - }).as('updateDatabaseAllowedIp'); - removeAllowedIp(allowedIp); - cy.wait('@updateDatabaseAllowedIp'); - - mockUpdateDatabase(database.id, database.engine, { - ...database, - allow_list: [newAllowedIp], - }).as('updateAccessControl'); - - manageAccessControl([newAllowedIp]); - cy.wait('@updateAccessControl'); - cy.get('[data-qa-access-controls]').within(() => { - cy.findByText(newAllowedIp).should('be.visible'); - }); - }); + // Test to update database label + it(`Can update label when ${status}`, () => { + const updatedLabel = randomLabel(); - /* - * - Tests provisioning database update UI flows using mocked data. - * - Confirms that users can change database label. - * - Confirms that users can change access controls. - * - Confirms that users cannot reset root passwords for provisioning clusters. - * - Confirms that users can change maintenance schedules. - */ - - it(`Can update provisioning database clusters`, () => { - const initialLabel = configuration.label; - const updatedLabel = randomLabel(); - const allowedIp = randomIp(); - const newAllowedIp = randomIp(); - const initialPassword = randomString(16); - const database = databaseFactory.build({ - allow_list: [allowedIp], - engine: configuration.dbType, - id: randomNumber(1, 1000), - label: initialLabel, - platform: 'rdbms-default', - region: configuration.region.id, - status: 'provisioning', - type: configuration.linodeType, - version: configuration.version, - }); + mockUpdateDatabase(database.id, database.engine, { + ...database, + label: updatedLabel, + }).as('updateDatabaseLabel'); - const errorMessage = - 'Database still provisioning; please try again later.'; - - mockGetAccount(accountFactory.build()).as('getAccount'); - mockGetDatabase(database).as('getDatabase'); - mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); - mockResetPasswordProvisioningDatabase( - database.id, - database.engine, - errorMessage - ).as('resetRootPassword'); - mockGetDatabaseCredentials( - database.id, - database.engine, - initialPassword - ).as('getCredentials'); - - cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); - cy.wait(['@getDatabase', '@getDatabaseTypes']); - - // Validation for summary page details - cy.findByText('Cluster Configuration'); - cy.findByText(configuration.region.label).should('be.visible'); - cy.findByText(database.total_disk_size_gb + ' GB').should( - 'be.visible' - ); - - cy.findByText('Connection Details'); - // DBaaS passwords cannot be revealed until database/cluster has provisioned. - ui.cdsButton - .findButtonByTitle('Show') - .should('be.visible') - .should('be.disabled'); - - // Update labels for cluster in active/provisioning state - mockUpdateDatabase(database.id, database.engine, { - ...database, - label: updatedLabel, - }).as('updateDatabaseLabel'); - updateDatabaseLabel(initialLabel, updatedLabel); - cy.wait('@updateDatabaseLabel'); - cy.get('[data-qa-header]') - .should('be.visible') - .should('have.text', updatedLabel); - - // Navigate to "Settings" tab. - ui.tabList.findTabByTitle('Settings').click(); - - cy.get('[data-testid="settings-button-Suspend Cluster"]').within( - () => { - ui.cdsButton - .findButtonByTitle('Suspend Cluster') - .should('be.disabled'); - } - ); - - // Reset root password. - resetRootPassword(); - cy.wait('@resetRootPassword'); - ui.dialog - .findByTitle('Reset Root Password') - .should('be.visible') - .within(() => { - cy.findByText(errorMessage).should('be.visible'); + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getDatabase', '@getDatabaseTypes']); - ui.buttonGroup - .findButtonByTitle('Cancel') + updateDatabaseLabel(database.label, updatedLabel); + cy.wait('@updateDatabaseLabel'); + cy.get('[data-qa-header]') .should('be.visible') - .should('be.enabled') - .click(); + .should('have.text', updatedLabel); }); - // Change maintenance window and databe version upgrade. - mockUpdateDatabase(database.id, database.engine, database).as( - 'updateDatabaseMaintenance' - ); - upgradeEngineVersion(database.engine, database.version); - - modifyMaintenanceWindow('Day of Week', 'Wednesday'); - cy.wait('@updateDatabaseMaintenance'); - ui.toast.assertMessage( - 'Maintenance Window settings saved successfully.' - ); - - modifyMaintenanceWindow('Time', '12:00'); - cy.wait('@updateDatabaseMaintenance'); - ui.toast.assertMessage( - 'Maintenance Window settings saved successfully.' - ); - - // Navigate to "Networking" tab. - ui.tabList.findTabByTitle('Networking').click(); - - // Remove allowed IP, manage IP access control. - mockUpdateDatabase(database.id, database.engine, { - ...database, - allow_list: [], - }).as('updateDatabaseAllowedIp'); - removeAllowedIp(allowedIp); - cy.wait('@updateDatabaseAllowedIp'); - - mockUpdateDatabase(database.id, database.engine, { - ...database, - allow_list: [newAllowedIp], - }).as('updateAccessControl'); - - manageAccessControl([newAllowedIp]); - cy.wait('@updateAccessControl'); - cy.get('[data-qa-access-controls]').within(() => { - cy.findByText(newAllowedIp).should('be.visible'); - }); - }); + // Tests to update maintenance window and engine version (if applicable) + it(`Can modify maintenance window when ${status}`, () => { + mockUpdateDatabase(database.id, database.engine, database).as( + 'updateDatabaseMaintenance' + ); - /* - * - Tests suspend/resume database update UI flows using mocked data. - * - Confirms that database update flows work under error conditions. - * - Confirms that users cannot change database label for suspended DBs. - * - Confirms that users cannot change access controls for suspended DBs. - * - Confirms that users cannot reset root passwords for suspended DBs. - * - Confirms that users cannot change maintenance schedules for suspended DBs. - */ - it(`Cannot update database clusters while they are suspended via Settings`, () => { - const initialLabel = configuration.label; - const updateAttemptLabel = randomLabel(); - const allowedIp = randomIp(); - const database: Database = databaseFactory.build({ - allow_list: [allowedIp], - engine: configuration.dbType, - hosts: { - primary: undefined, - secondary: undefined, - }, - id: randomNumber(1, 1000), - label: initialLabel, - platform: 'rdbms-default', - region: configuration.region.id, - status: 'active', - type: configuration.linodeType, - }); + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getDatabase', '@getDatabaseTypes']); - const errorMessage = - 'Your database is suspended; please wait until it becomes active to perform this operation.'; - - mockGetAccount(accountFactory.build()).as('getAccount'); - mockGetDatabase(database).as('getDatabase'); - mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); - - mockUpdateSuspendResumeDatabase( - database.id, - database.engine, - errorMessage - ).as('updateDatabase'); - - mockResetPasswordSuspendResumeDatabase( - database.id, - database.engine, - errorMessage - ).as('resetRootPassword'); - - mockSuspendDatabase(database.id, database.engine).as( - 'suspendDatabase' - ); - - // Database mock once instance has been suspended. - const databaseMockSuspend: Database = { - ...database, - status: 'suspended', - }; - - cy.visitWithLogin( - `/databases/${database.engine}/${database.id}/Settings` - ); - cy.wait(['@getAccount', '@getDatabase', '@getDatabaseTypes']); - - // Navigate to "Settings" tab. - ui.tabList.findTabByTitle('Settings').click(); - - // Suspend an active cluster - cy.get('[data-testid="settings-button-Suspend Cluster"]').within( - () => { - ui.cdsButton - .findButtonByTitle('Suspend Cluster') - .should('be.visible') - .should('be.enabled') - .click(); - } - ); - suspendCluster(initialLabel); - cy.wait('@suspendDatabase'); - - cy.url().should('endWith', '/databases'); - ui.toast.assertMessage('Database Cluster suspended successfully.'); - - // Mock next request to fetch databases so that instance appears suspended. - mockGetDatabases([databaseMockSuspend]).as('getDatabases'); - - cy.findByText(database.label).should('be.visible'); - - // Mock database with updated action - Suspend - mockGetDatabase(databaseMockSuspend).as('getDatabase'); - cy.wait('@getDatabase'); - - // Confirm enabled dropdown option when cluster is in suspended state - validateActionItems('suspended', initialLabel); - - // Validate updates are not allowed when a cluster is suspended - validateSuspendResume( - database.engine, - database.id, - initialLabel, - updateAttemptLabel, - errorMessage, - allowedIp - ); + ui.tabList.findTabByTitle('Settings').click(); + upgradeEngineVersion(database.engine, database.version); + + modifyMaintenanceWindow('Day of Week', 'Wednesday'); + cy.wait('@updateDatabaseMaintenance'); + ui.toast.assertMessage( + 'Maintenance Window settings saved successfully.' + ); + + modifyMaintenanceWindow('Time', '12:00'); + cy.wait('@updateDatabaseMaintenance'); + ui.toast.assertMessage( + 'Maintenance Window settings saved successfully.' + ); + }); + + // Test to manage access controls (add/remove allowed IPs) + it(`Can manage access controls when ${status}`, () => { + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getDatabase', '@getDatabaseTypes']); + + ui.tabList.findTabByTitle('Networking').click(); + + // Set up mock right before the action that triggers it + mockUpdateDatabase(database.id, database.engine, { + ...database, + allow_list: [], + }).as('updateDatabaseAllowedIp'); + + removeAllowedIp(allowedIp); + cy.wait('@updateDatabaseAllowedIp'); + + // Set up next mock after first request completes + mockUpdateDatabase(database.id, database.engine, { + ...database, + allow_list: [newAllowedIp], + }).as('updateAccessControl'); + + manageAccessControl([newAllowedIp]); + cy.wait('@updateAccessControl'); + cy.get('[data-qa-access-controls]').within(() => { + cy.findByText(newAllowedIp).should('be.visible'); + }); + }); + }); }); - /* - * - Tests suspend/resume database update UI flows using mocked data. - * - Confirms that database update flows work under error conditions. - * - Confirms that users cannot change database label for suspended/resuming DBs. - * - Confirms that users cannot change access controls for suspended/resuming DBs. - * - Confirms that users cannot reset root passwords for suspended/resuming DBs. - * - Confirms that users cannot change maintenance schedules for suspended/resuming DBs. - */ - - const actionItemState = ['suspended', 'resuming']; - actionItemState.forEach((action) => { - it(`Cannot update database clusters while they are ${action}`, () => { - const currentState = action === 'resuming' ? 'suspended' : 'active'; - const initialLabel = configuration.label; - const updateAttemptLabel = randomLabel(); - const allowedIp = randomIp(); - const database: Database = databaseFactory.build({ - allow_list: [allowedIp], + // Tests operations that are only allowed when cluster is active (show/hide password, reset root password) + describe('active clusters - password operations', () => { + it('Can view cluster details and show/hide password', () => { + const initialPassword = randomString(16); + const database = databaseFactory.build({ + allow_list: [randomIp()], engine: configuration.dbType, - hosts: { - primary: undefined, - secondary: undefined, - }, id: randomNumber(1, 1000), - label: initialLabel, + label: configuration.label, platform: 'rdbms-default', region: configuration.region.id, - status: currentState, + status: 'active', type: configuration.linodeType, + version: configuration.version, }); - const errorMessage = `Your database is ${action}; please wait until it becomes active to perform this operation.`; - - mockGetAccount(accountFactory.build()).as('getAccount'); - mockGetDatabases([database]).as('getDatabases'); mockGetDatabase(database).as('getDatabase'); - mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); - - mockUpdateSuspendResumeDatabase( + mockGetDatabaseCredentials( database.id, database.engine, - errorMessage - ).as('updateDatabase'); + initialPassword + ).as('getCredentials'); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getDatabase', '@getDatabaseTypes']); + + // Verify cluster configuration details + cy.findByText('Cluster Configuration'); + cy.findByText(configuration.region.label).should('be.visible'); + cy.findByText(database.total_disk_size_gb + ' GB').should( + 'be.visible' + ); + + cy.findByText('Connection Details'); + ui.cdsButton.findButtonByTitle('Show').should('be.enabled').click(); + + cy.wait('@getCredentials'); + cy.findByText(`${initialPassword}`); + + ui.cdsButton.findButtonByTitle('Hide').should('be.enabled').click(); + }); + + it('Can reset root password when active', () => { + const database = databaseFactory.build({ + allow_list: [randomIp()], + engine: configuration.dbType, + id: randomNumber(1, 1000), + label: configuration.label, + platform: 'rdbms-default', + region: configuration.region.id, + status: 'active', + type: configuration.linodeType, + version: configuration.version, + }); - mockResetPasswordSuspendResumeDatabase( + mockGetDatabase(database).as('getDatabase'); + mockResetPassword(database.id, database.engine).as( + 'resetRootPassword' + ); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getDatabase', '@getDatabaseTypes']); + + ui.tabList.findTabByTitle('Settings').click(); + resetRootPassword(); + cy.wait('@resetRootPassword'); + }); + }); + + // Tests operations that are blocked when cluster is provisioning (show/hide password, reset root password, suspend cluster) + describe('provisioning clusters - blocked operations', () => { + it('Cannot reveal password or reset root password when provisioning', () => { + const errorMessage = getBlockedErrorMessage('provisioning'); + const database = databaseFactory.build({ + allow_list: [randomIp()], + engine: configuration.dbType, + id: randomNumber(1, 1000), + label: configuration.label, + platform: 'rdbms-default', + region: configuration.region.id, + status: 'provisioning', + type: configuration.linodeType, + version: configuration.version, + }); + + mockGetDatabase(database).as('getDatabase'); + mockResetPasswordProvisioningDatabase( database.id, database.engine, errorMessage ).as('resetRootPassword'); + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getDatabase', '@getDatabaseTypes']); + + // Cannot reveal password when provisioning + cy.findByText('Connection Details'); + ui.cdsButton.findButtonByTitle('Show').should('be.disabled'); + + // Navigate to Settings and verify blocked operations + ui.tabList.findTabByTitle('Settings').click(); + + cy.get('[data-testid="settings-button-Suspend Cluster"]').within( + () => { + ui.cdsButton + .findButtonByTitle('Suspend Cluster') + .should('be.disabled'); + } + ); + + // Cannot reset root password when provisioning + resetRootPassword(); + cy.wait('@resetRootPassword'); + ui.dialog + .findByTitle('Reset Root Password') + .should('be.visible') + .within(() => { + cy.findByText(errorMessage).should('be.visible'); + ui.buttonGroup + .findButtonByTitle('Cancel') + .should('be.enabled') + .click(); + }); + }); + }); + + // Tests operations that are blocked when cluster is suspended or resuming + const blockedStatuses = ['suspended', 'resuming'] as const; + + blockedStatuses.forEach((status) => { + describe(`${status} clusters`, () => { + let database: Database; + let allowedIp: string; + let errorMessage: string; + + beforeEach(() => { + allowedIp = randomIp(); + errorMessage = getBlockedErrorMessage(status); + database = databaseFactory.build({ + allow_list: [allowedIp], + engine: configuration.dbType, + hosts: null, + id: randomNumber(1, 1000), + label: configuration.label, + platform: 'rdbms-default', + region: configuration.region.id, + status, + type: configuration.linodeType, + version: configuration.version, + }); + + mockGetDatabase(database).as('getDatabase'); + mockUpdateSuspendResumeDatabase( + database.id, + database.engine, + errorMessage + ).as('updateDatabase'); + mockResetPasswordSuspendResumeDatabase( + database.id, + database.engine, + errorMessage + ).as('resetRootPassword'); + }); + + // Tests to verify that update operations are blocked and appropriate error message is shown when cluster is not active + it(`Cannot perform updates when ${status}`, () => { + const updateAttemptLabel = randomLabel(); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait('@getDatabase'); + + // Cannot update label + updateDatabaseLabel(database.label, updateAttemptLabel); + cy.wait('@updateDatabase'); + cy.findByText(errorMessage).should('be.visible'); + cy.get('[data-qa-cancel-edit="true"]') + .should('be.enabled') + .click(); + + // Navigate to Settings tab + ui.tabList.findTabByTitle('Settings').click(); + + // Cannot reset password + resetRootPassword(); + cy.wait('@resetRootPassword'); + ui.dialog + .findByTitle('Reset Root Password') + .should('be.visible') + .within(() => { + cy.findByText(errorMessage).should('be.visible'); + ui.buttonGroup + .findButtonByTitle('Cancel') + .should('be.enabled') + .click(); + }); + + // Cannot modify maintenance window + modifyMaintenanceWindow('Day of Week', 'Wednesday'); + cy.wait('@updateDatabase'); + cy.findByText(errorMessage).should('be.visible'); + + // Navigate to Networking tab + ui.tabList.findTabByTitle('Networking').click(); + + // Cannot remove allowed IP + removeAllowedIp(allowedIp); + cy.wait('@updateDatabase'); + ui.dialog + .findByTitle(`Remove IP Address ${allowedIp}`) + .should('be.visible') + .within(() => { + cy.findByText(errorMessage).should('be.visible'); + ui.buttonGroup + .findButtonByTitle('Cancel') + .should('be.visible') + .click(); + }); + + // Cannot add new IP via manage access + manageAccessControl([randomIp()], 1); + cy.wait('@updateDatabase'); + ui.drawer.findByTitle('Manage Access').within(() => { + cy.findByText(errorMessage).should('be.visible'); + ui.drawerCloseButton.find().click(); + }); + }); + + it(`Action menu shows correct options when ${status}`, () => { + mockGetDatabases([database]).as('getDatabases'); + + cy.visitWithLogin('/databases/'); + cy.wait(['@getAccount', '@getDatabases', '@getDatabaseTypes']); + + validateActionItems(status, database.label); + }); + }); + }); + + // Tests state transitions (suspend/resume) via action menu and settings page + describe('state transitions', () => { + it('Can suspend via Settings', () => { + const database = databaseFactory.build({ + allow_list: [randomIp()], + engine: configuration.dbType, + hosts: null, + id: randomNumber(1, 1000), + label: configuration.label, + platform: 'rdbms-default', + region: configuration.region.id, + status: 'active', + type: configuration.linodeType, + }); + + const databaseMockSuspend: Database = { + ...database, + status: 'suspended', + }; + + mockGetDatabase(database).as('getDatabase'); mockSuspendDatabase(database.id, database.engine).as( 'suspendDatabase' ); + mockGetDatabases([databaseMockSuspend]).as('getDatabases'); - mockResumeDatabase(database.id, database.engine).as( - 'resumeDatabase' + cy.visitWithLogin( + `/databases/${database.engine}/${database.id}/Settings` + ); + cy.wait(['@getAccount', '@getDatabase', '@getDatabaseTypes']); + + ui.tabList.findTabByTitle('Settings').click(); + + cy.get('[data-testid="settings-button-Suspend Cluster"]').within( + () => { + ui.cdsButton + .findButtonByTitle('Suspend Cluster') + .should('be.enabled') + .click(); + } ); + suspendCluster(database.label); + cy.wait('@suspendDatabase'); + + cy.url().should('endWith', '/databases'); + ui.toast.assertMessage('Database Cluster suspended successfully.'); + + cy.findByText(database.label).should('be.visible'); + + mockGetDatabase(databaseMockSuspend).as('getDatabase'); + cy.wait('@getDatabase'); + + validateActionItems('suspended', database.label); + }); - const changeState = - action === 'resuming' ? 'resuming' : 'suspended'; + it('Can suspend via action menu', () => { + const database = databaseFactory.build({ + allow_list: [randomIp()], + engine: configuration.dbType, + hosts: null, + id: randomNumber(1, 1000), + label: configuration.label, + platform: 'rdbms-default', + region: configuration.region.id, + status: 'active', + type: configuration.linodeType, + }); - // Database mock once instance has been suspended or resuming. - const databaseMockSuspendResume: Database = { + const databaseMockSuspend: Database = { ...database, - status: changeState, + status: 'suspended', }; - cy.visitWithLogin(`/databases/`); + mockGetDatabases([database]).as('getDatabases'); + mockGetDatabase(database).as('getDatabase'); + mockSuspendDatabase(database.id, database.engine).as( + 'suspendDatabase' + ); + + cy.visitWithLogin('/databases/'); cy.wait(['@getAccount', '@getDatabases', '@getDatabaseTypes']); cy.get(`[data-qa-database-cluster-id=${database.id}]`).within( () => { - cy.findByText(initialLabel).should('be.visible'); + cy.findByText(database.label).should('be.visible'); } ); - // Suspend/Resume cluster via action item menu on homepage ui.actionMenu - .findByTitle(`Action menu for Database ${initialLabel}`) + .findByTitle(`Action menu for Database ${database.label}`) .should('be.visible') .click(); - const menuAction = action === 'resuming' ? 'Resume' : 'Suspend'; - ui.actionMenuItem - .findByTitle(menuAction) - .should('be.visible') + .findByTitle('Suspend') .should('be.enabled') .click(); - if (action === 'resuming') { - cy.wait('@resumeDatabase'); - ui.toast.assertMessage('Database Cluster resumed successfully.'); - } else { - suspendCluster(initialLabel); - cy.wait('@suspendDatabase'); - ui.toast.assertMessage( - 'Database Cluster suspended successfully.' - ); - } + suspendCluster(database.label); + cy.wait('@suspendDatabase'); + ui.toast.assertMessage('Database Cluster suspended successfully.'); - // Mock next request to fetch databases so that instance appears suspended or resuming - mockGetDatabases([databaseMockSuspendResume]).as('getDatabases'); - cy.wait('@getDatabases'); + // Set up mock for subsequent requests and verify action menu + mockGetDatabases([databaseMockSuspend]).as('getDatabases'); + mockGetDatabase(databaseMockSuspend).as('getDatabase'); + validateActionItems('suspended', database.label); + }); - cy.findByText(database.label).should('be.visible'); + it('Can resume via action menu', () => { + const database = databaseFactory.build({ + allow_list: [randomIp()], + engine: configuration.dbType, + hosts: null, + id: randomNumber(1, 1000), + label: configuration.label, + platform: 'rdbms-default', + region: configuration.region.id, + status: 'suspended', + type: configuration.linodeType, + }); - // Mock database with updated action - Suspend/Resume - mockGetDatabase(databaseMockSuspendResume).as('getDatabase'); + const databaseMockResuming: Database = { + ...database, + status: 'resuming', + }; + + mockGetDatabases([database]).as('getDatabases'); + mockGetDatabase(database).as('getDatabase'); + mockResumeDatabase(database.id, database.engine).as( + 'resumeDatabase' + ); - // Confirm enabled dropdown option when cluster is in suspended/resuming state - validateActionItems(action, initialLabel); + cy.visitWithLogin('/databases/'); + cy.wait(['@getAccount', '@getDatabases', '@getDatabaseTypes']); - // Validate updates are not allowed when a cluster is suspended/resuming - validateSuspendResume( - database.engine, - database.id, - initialLabel, - updateAttemptLabel, - errorMessage, - allowedIp + cy.get(`[data-qa-database-cluster-id=${database.id}]`).within( + () => { + cy.findByText(database.label).should('be.visible'); + } ); + + ui.actionMenu + .findByTitle(`Action menu for Database ${database.label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem + .findByTitle('Resume') + .should('be.enabled') + .click(); + + cy.wait('@resumeDatabase'); + ui.toast.assertMessage('Database Cluster resumed successfully.'); + + // Set up mock for subsequent requests and verify action menu + mockGetDatabases([databaseMockResuming]).as('getDatabases'); + mockGetDatabase(databaseMockResuming).as('getDatabase'); + validateActionItems('resuming', database.label); }); }); }); diff --git a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts index 8a413f8655f..120ff896cb7 100644 --- a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts @@ -1,4 +1,5 @@ import { + CREATE_DESTINATION_ERROR_MESSAGE, mockAkamaiObjectStorageDestination, mockAkamaiObjectStorageDestinationPayload, mockCustomHttpsDestination, @@ -78,13 +79,16 @@ describe('Create Destination', () => { ); // Submit the destination create form - failure - mockCreateDestination({}, 400); + mockCreateDestination( + { errors: [{ reason: CREATE_DESTINATION_ERROR_MESSAGE }] }, + 500 + ); cy.findByRole('button', { name: 'Create Destination' }) .should('be.enabled') .should('have.attr', 'type', 'button') .click(); - ui.toast.assertMessage(`There was an issue creating your destination`); + ui.toast.assertMessage(CREATE_DESTINATION_ERROR_MESSAGE); // Submit the destination create form - success mockCreateDestination(mockAkamaiObjectStorageDestination); @@ -159,7 +163,7 @@ describe('Create Destination', () => { ); // Switch to manual mode and fill in values - cy.findByLabelText('Enter Bucket manually').click(); + cy.findByLabelText('Enter Bucket details manually').click(); logsDestinationForm.setBucket('my-manual-bucket'); logsDestinationForm.setEndpoint('my-endpoint.com'); @@ -223,13 +227,16 @@ describe('Create Destination', () => { ); // Submit the destination create form - failure - mockCreateDestination({}, 400); + mockCreateDestination( + { errors: [{ reason: CREATE_DESTINATION_ERROR_MESSAGE }] }, + 500 + ); cy.findByRole('button', { name: 'Create Destination' }) .should('be.enabled') .should('have.attr', 'type', 'button') .click(); - ui.toast.assertMessage(`There was an issue creating your destination`); + ui.toast.assertMessage(CREATE_DESTINATION_ERROR_MESSAGE); // Submit the destination create form - success mockCreateDestination(mockCustomHttpsDestination); diff --git a/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts index 18e1c40ef4d..4b4009c6eba 100644 --- a/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts @@ -1,6 +1,8 @@ import { streamType } from '@linode/api-v4'; import { regionFactory } from '@linode/utilities'; import { + CREATE_DESTINATION_ERROR_MESSAGE, + CREATE_STREAM_ERROR_MESSAGE, mockAkamaiObjectStorageDestination, mockCustomHttpsDestination, } from 'support/constants/delivery'; @@ -19,6 +21,7 @@ import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; import { logsStreamForm } from 'support/ui/pages/logs-stream-form'; import { randomLabel } from 'support/util/random'; +import { DEFAULT_ERROR_MESSAGE } from 'src/constants'; import { accountFactory, kubernetesClusterFactory } from 'src/factories'; describe('Create Stream', () => { @@ -107,7 +110,7 @@ describe('Create Stream', () => { .should('have.attr', 'type', 'button') .click(); - ui.toast.assertMessage(`There was an issue creating your destination`); + ui.toast.assertMessage(DEFAULT_ERROR_MESSAGE); // Submit the stream create form - success mockCreateDestination(mockAkamaiObjectStorageDestination); @@ -167,7 +170,7 @@ describe('Create Stream', () => { cy.findByRole('button', { name: 'Create Stream' }).click(); cy.wait('@createStreamFail'); - ui.toast.assertMessage('There was an issue creating your stream'); + ui.toast.assertMessage(DEFAULT_ERROR_MESSAGE); // Submit the stream create form - success mockCreateStream({ label: streamName }).as('createStream'); @@ -248,13 +251,16 @@ describe('Create Stream', () => { .should('have.attr', 'type', 'button'); // Submit the stream create form - failure in creating destination - mockCreateDestination({}, 400); + mockCreateDestination( + { errors: [{ reason: CREATE_DESTINATION_ERROR_MESSAGE }] }, + 500 + ); cy.findByRole('button', { name: 'Create Stream' }) .should('be.enabled') .should('have.attr', 'type', 'button') .click(); - ui.toast.assertMessage(`There was an issue creating your destination`); + ui.toast.assertMessage(CREATE_DESTINATION_ERROR_MESSAGE); // Submit the stream create form - success mockCreateDestination(mockCustomHttpsDestination); @@ -313,11 +319,14 @@ describe('Create Stream', () => { .should('have.attr', 'type', 'button'); // Submit the stream create form - failure - mockCreateStream({}, 400).as('createStreamFail'); + mockCreateStream( + { errors: [{ reason: CREATE_STREAM_ERROR_MESSAGE }] }, + 400 + ).as('createStreamFail'); cy.findByRole('button', { name: 'Create Stream' }).click(); cy.wait('@createStreamFail'); - ui.toast.assertMessage('There was an issue creating your stream'); + ui.toast.assertMessage(CREATE_STREAM_ERROR_MESSAGE); // Submit the stream create form - success mockCreateStream({ label: streamName }).as('createStream'); diff --git a/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts index 2b868c07260..3c9da9e446c 100644 --- a/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts @@ -92,7 +92,7 @@ function editDestinationViaActionMenu( mockGetDestination(destination); // Edit destination redirect ui.actionMenuItem.findByTitle('Edit').click(); - cy.url().should('endWith', `/destinations/${destination.id}/edit`); + cy.url().should('endWith', `/destinations/${destination.id}/summary`); }); } @@ -181,7 +181,7 @@ describe('destinations landing checks for non-empty state', () => { }); }); - it('navigates to edit page when clicking destination label', () => { + it('navigates to summary page when clicking destination label', () => { cy.visitWithLogin('/logs/delivery/destinations'); cy.wait('@getDestinations'); @@ -189,7 +189,7 @@ describe('destinations landing checks for non-empty state', () => { mockGetDestination(destination).as('getDestination'); cy.findByText(destination.label).click(); - cy.url().should('endWith', `/destinations/${destination.id}/edit`); + cy.url().should('endWith', `/destinations/${destination.id}/summary`); cy.wait('@getDestination'); }); @@ -255,7 +255,7 @@ describe('destinations landing checks for non-empty state', () => { mockGetDestination(destination).as('getDestination'); cy.findByText(destination.label).click(); - cy.url().should('endWith', `/destinations/${destination.id}/edit`); + cy.url().should('endWith', `/destinations/${destination.id}/summary`); cy.wait('@getDestination'); }); diff --git a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts index 9ff583e6185..852a3eeb07b 100644 --- a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts @@ -43,7 +43,7 @@ describe('Edit Destination', () => { describe('given Akamai Object Storage type destination', () => { beforeEach(() => { cy.visitWithLogin( - `/logs/delivery/destinations/${mockAkamaiObjectStorageDestination.id}/edit` + `/logs/delivery/destinations/${mockAkamaiObjectStorageDestination.id}/summary` ); mockGetDestination(mockAkamaiObjectStorageDestination); }); @@ -160,7 +160,9 @@ describe('Edit Destination', () => { ]); // Edit mode defaults to manual bucket entry - cy.findByLabelText('Enter Bucket manually').should('be.checked'); + cy.findByLabelText('Enter Bucket details manually').should( + 'be.checked' + ); // Endpoint should be enabled in manual mode cy.findByLabelText('Endpoint').should('be.enabled'); @@ -199,7 +201,7 @@ describe('Edit Destination', () => { describe('given Custom HTTPS type destination', () => { beforeEach(() => { cy.visitWithLogin( - `/logs/delivery/destinations/${mockCustomHttpsDestination.id}/edit` + `/logs/delivery/destinations/${mockCustomHttpsDestination.id}/summary` ); mockGetDestination(mockCustomHttpsDestination); }); diff --git a/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts b/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts index cfc64627750..27251fe76e8 100644 --- a/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts @@ -21,6 +21,7 @@ import { ui } from 'support/ui'; import { logsStreamForm } from 'support/ui/pages/logs-stream-form'; import { randomLabel } from 'support/util/random'; +import { DEFAULT_ERROR_MESSAGE } from 'src/constants'; import { kubernetesClusterFactory } from 'src/factories'; describe('Edit Stream', () => { @@ -45,9 +46,7 @@ describe('Edit Stream', () => { mockGetStream(mockAuditLogsStream); // Visit the Edit Stream page - cy.visitWithLogin( - `/logs/delivery/streams/${mockAuditLogsStream.id}/edit/` - ); + cy.visitWithLogin(`/logs/delivery/streams/${mockAuditLogsStream.id}`); const updatedLabel = randomLabel(); @@ -114,7 +113,7 @@ describe('Edit Stream', () => { mockCreateDestination({}, 400); ui.button.findByTitle(saveChangesButtonText).should('be.enabled').click(); - ui.toast.assertMessage(`There was an issue creating your destination`); + ui.toast.assertMessage(DEFAULT_ERROR_MESSAGE); // Submit the stream edit form - success mockCreateDestination(mockAkamaiObjectStorageDestination); @@ -206,9 +205,7 @@ describe('Edit Stream', () => { mockGetClusters([cluster1, cluster2, cluster3, cluster4]); // Visit the Edit Stream page - cy.visitWithLogin( - `/logs/delivery/streams/${mockLKEAuditLogsStream.id}/edit/` - ); + cy.visitWithLogin(`/logs/delivery/streams/${mockLKEAuditLogsStream.id}`); const updatedLabel = randomLabel(); @@ -309,7 +306,7 @@ describe('Edit Stream', () => { ui.button.findByTitle(saveChangesButtonText).click(); cy.wait('@updateStreamFail'); - ui.toast.assertMessage('There was an issue editing your stream'); + ui.toast.assertMessage(DEFAULT_ERROR_MESSAGE); // Submit the stream edit form - success mockUpdateStream( diff --git a/packages/manager/cypress/e2e/core/delivery/streams-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/streams-non-empty-landing-page.spec.ts index 2f4087cfadf..f4382119e3c 100644 --- a/packages/manager/cypress/e2e/core/delivery/streams-non-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/streams-non-empty-landing-page.spec.ts @@ -88,7 +88,7 @@ function editStreamViaActionMenu(tableAlias: string, stream: Stream) { mockGetStream(stream); // Edit stream redirect ui.actionMenuItem.findByTitle('Edit').click(); - cy.url().should('endWith', `/streams/${stream.id}/edit`); + cy.url().should('endWith', `/streams/${stream.id}/summary`); }); } @@ -180,7 +180,7 @@ describe('Streams non-empty landing page', () => { // Redirect to stream edit page via name cy.findByText(exampleStream.label).click(); - cy.url().should('endWith', `/streams/${exampleStream.id}/edit`); + cy.url().should('endWith', `/streams/${exampleStream.id}/summary`); cy.wait(['@getStream', '@getDestinations']); // Redirect to stream edit page via menu item diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts index b71bf8fd0ba..05d007e9de4 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -1,4 +1,5 @@ import { authenticate } from 'support/api/authentication'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; @@ -12,6 +13,10 @@ describe('create image (e2e)', () => { cleanUp(['linodes', 'images']); }); + beforeEach(() => { + mockAppendFeatureFlags({ privateImageSharing: false }); + }); + it('create image from a linode', () => { cy.tag('method:e2e'); const label = randomLabel(); diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index 3206e9ed69e..a3615f05802 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -1,5 +1,6 @@ import { linodeFactory } from '@linode/utilities'; import { firewallFactory, imageFactory } from '@src/factories'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockGetAllImages } from 'support/intercepts/images'; import { ui } from 'support/ui'; @@ -81,6 +82,10 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { }; describe('create linode from image, mocked data', () => { + beforeEach(() => { + mockAppendFeatureFlags({ privateImageSharing: false }); + }); + /* * - Confirms UI flow when user attempts to create a Linode from images without having any images. */ diff --git a/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts index 483e2bdd60c..e2578d91e26 100644 --- a/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts @@ -1,6 +1,7 @@ import { grantsFactory, profileFactory } from '@linode/utilities'; import { accountUserFactory } from '@src/factories/accountUsers'; import { mockGetUser } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAllImages } from 'support/intercepts/images'; import { mockGetProfile, @@ -13,6 +14,7 @@ describe('Images empty landing page', () => { beforeEach(() => { // Mock setup to display the Image landing page in an empty state mockGetAllImages([]).as('getImages'); + mockAppendFeatureFlags({ privateImageSharing: false }); }); /* diff --git a/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts index b420d385708..c3d132851f5 100644 --- a/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts @@ -1,6 +1,7 @@ import { profileFactory } from '@linode/utilities'; import { grantsFactory } from '@linode/utilities'; import { mockGetUser } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetCustomImages, mockGetRecoveryImages, @@ -74,6 +75,9 @@ describe('image landing checks for non-empty state with restricted user', () => }); }); + // Disable Private Image Sharing feature flag. + mockAppendFeatureFlags({ privateImageSharing: false }); + // Mock setup to display the Image landing page in an non-empty state mockGetCustomImages(mockCustomImages).as('getCustomImages'); mockGetRecoveryImages(mockRecoveryImages).as('getRecoveryImages'); diff --git a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts index 930cc1e7618..1b294fd6c8e 100644 --- a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts @@ -5,6 +5,7 @@ import { } from '@linode/utilities'; import { mockGetUser } from 'support/intercepts/account'; import { mockGetEvents } from 'support/intercepts/events'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateImage } from 'support/intercepts/images'; import { mockGetLinodeDisks, mockGetLinodes } from 'support/intercepts/linodes'; import { @@ -19,6 +20,10 @@ import { linodeDiskFactory } from 'src/factories/disk'; import { imageFactory } from 'src/factories/images'; describe('create image (using mocks)', () => { + beforeEach(() => { + mockAppendFeatureFlags({ privateImageSharing: false }); + }); + it('create image from a linode', () => { const mockDisks = [ linodeDiskFactory.build({ filesystem: 'ext4', label: 'Debian 12 Disk' }), 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 86072c549f6..efbb74b370d 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -171,6 +171,7 @@ describe('rebuild linode', () => { iam: { enabled: false, }, + privateImageSharing: false, }); }); @@ -525,6 +526,7 @@ describe('rebuild linode', () => { enabled: true, la: true, }, + privateImageSharing: false, }).as('getFeatureFlags'); mockGetRegionAvailability(linodeCreatePayload.region, []).as( 'getRegionAvailability' diff --git a/packages/manager/cypress/support/constants/databases.ts b/packages/manager/cypress/support/constants/databases.ts index 6094ef2e698..19d3a04b578 100644 --- a/packages/manager/cypress/support/constants/databases.ts +++ b/packages/manager/cypress/support/constants/databases.ts @@ -597,3 +597,38 @@ export const databaseConfigurationsAdvConfig: DatabaseClusterConfiguration[] = [ ip: randomIp(), }, ]; + +export const databaseConfigurationsUpdate: DatabaseClusterConfiguration[] = [ + { + clusterSize: 1, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g6-nanode-1', + region: chooseRegion({ capabilities: ['Managed Databases'] }), + version: '8', + ip: randomIp(), + }, + { + clusterSize: 3, + dbType: 'postgresql', + engine: 'PostgreSQL', + label: randomLabel(), + linodeType: 'g6-nanode-1', + region: chooseRegion({ capabilities: ['Managed Databases'] }), + version: '13', + ip: randomIp(), + }, + { + clusterSize: 2, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g7-premium-4', + region: chooseRegion({ + capabilities: ['Managed Databases', 'Premium Plans'], + }), + version: '8', + ip: randomIp(), + }, +]; diff --git a/packages/manager/cypress/support/constants/delivery.ts b/packages/manager/cypress/support/constants/delivery.ts index f8d58619cae..8fe387b60d7 100644 --- a/packages/manager/cypress/support/constants/delivery.ts +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -114,3 +114,9 @@ export const mockLKEAuditLogsStream: Stream = streamFactory.build({ destinations: [mockAkamaiObjectStorageDestination], version: '1.0', }); + +export const CREATE_DESTINATION_ERROR_MESSAGE = + 'Cannot create destination at this time.'; + +export const CREATE_STREAM_ERROR_MESSAGE = + 'You\u2019ve reached the limit of streams you can create. If you have any questions, please contact support.'; diff --git a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts index 0e3479c96f6..832a1150aa8 100644 --- a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts +++ b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts @@ -76,7 +76,7 @@ export const logsDestinationForm = { cy.findByLabelText('Endpoint') .should('be.visible') .should('be.enabled') - .should('have.attr', 'placeholder', 'Endpoint for the destination') + .should('have.attr', 'placeholder', 'https://us-ord-1.linodeobjects.com') .clear(); cy.focused().type(endpoint); }, @@ -129,7 +129,7 @@ export const logsDestinationForm = { data: AkamaiObjectStorageDetailsExtended ) => { // Switch to manual bucket entry - cy.findByLabelText('Enter Bucket manually').click(); + cy.findByLabelText('Enter Bucket details manually').click(); logsDestinationForm.setBucket(data.bucket_name); logsDestinationForm.setEndpoint(data.host); diff --git a/packages/manager/package.json b/packages/manager/package.json index 1ec4e6ad21d..d4623eb0028 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,10 +1,13 @@ { - "name": "linode-manager", + "name": "@linode/cloud-manager", "author": "Linode", "description": "The Linode Manager website", "version": "1.161.0", - "private": true, + "private": false, "type": "module", + "files": [ + "build" + ], "bugs": { "url": "https://github.com/Linode/manager/issues" }, @@ -14,6 +17,9 @@ "url": "https://github.com/Linode/manager.git" }, "dependencies": { + "@akamai/cds-components": "0.0.0-20260407204557", + "@akamai/cds-icons": "0.0.0-20260407204557", + "@akamai/cds-tokens": "0.0.0-20260407204557", "@braintree/sanitize-url": "^7.1.0", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", @@ -45,9 +51,8 @@ "@tanstack/react-query-devtools": "5.51.24", "@tanstack/react-router": "^1.111.11", "@xterm/xterm": "^5.5.0", - "akamai-cds-react-components": "0.1.0", "algoliasearch": "^4.14.3", - "axios": "~1.13.5", + "axios": "~1.15.0", "braintree-web": "^3.92.2", "chart.js": "~2.9.4", "copy-to-clipboard": "^3.0.8", @@ -99,7 +104,7 @@ "lint": "eslint . --ext .js,.ts,.tsx --quiet", "build": "vite build", "build:analyze": "bunx vite-bundle-visualizer", - "test": "vitest run", + "test": "NODE_OPTIONS='--max-old-space-size=4096' vitest run", "test:ui": "vitest --ui", "storybook": "NODE_OPTIONS='--max-old-space-size=4096' storybook dev -p 6006", "storybook-static": "storybook build -c .storybook -o .out", @@ -181,7 +186,7 @@ "pdfreader": "^3.0.7", "redux-mock-store": "^1.5.3", "storybook": "^10.3.3", - "vite": "^7.2.2", + "vite": "^7.3.2", "vite-plugin-svgr": "^4.5.0" }, "browserslist": [ diff --git a/packages/manager/src/OAuth/oauth.test.tsx b/packages/manager/src/OAuth/oauth.test.tsx index 92029f12fa8..6409804d10f 100644 --- a/packages/manager/src/OAuth/oauth.test.tsx +++ b/packages/manager/src/OAuth/oauth.test.tsx @@ -78,10 +78,10 @@ describe('generateOAuthAuthorizeEndpoint', () => { expect(url).toContain('code_challenge='); }); - it('generates a "state" (aka nonce), stores it in local storage, and includes it in the url', async () => { + it('generates a "state" (aka nonce), stores it in session storage, and includes it in the url', async () => { storage.authentication.nonce.clear(); - expect(storage.authentication.nonce.get()).toBeNull(); + expect(storage.authentication.nonce.get()).toBeUndefined(); const url = await generateOAuthAuthorizeEndpoint('/linodes'); @@ -91,10 +91,10 @@ describe('generateOAuthAuthorizeEndpoint', () => { expect(url).toContain(`state=${nonceInStorage}`); }); - it('generates a code verifier and stores it in local storage', async () => { + it('generates a code verifier and stores it in session storage', async () => { storage.authentication.codeVerifier.clear(); - expect(storage.authentication.codeVerifier.get()).toBeNull(); + expect(storage.authentication.codeVerifier.get()).toBeUndefined(); await generateOAuthAuthorizeEndpoint('/linodes'); @@ -126,7 +126,7 @@ describe('handleOAuthCallback', () => { ).rejects.toThrowError('Error parsing search params on OAuth callback.'); }); - it('should throw if there is no code verifier found in local storage', async () => { + it('should throw if there is no code verifier found in session storage', async () => { storage.authentication.codeVerifier.clear(); await expect( @@ -134,11 +134,11 @@ describe('handleOAuthCallback', () => { params: 'state=fehgefhgkefghk&code=gyuwyutfetyfew', }) ).rejects.toThrowError( - 'No code codeVerifier found in local storage when running OAuth callback.' + 'No code codeVerifier found in session storage when running OAuth callback.' ); }); - it('should throw if there is no nonce found in local storage', async () => { + it('should throw if there is no nonce found in session storage', async () => { storage.authentication.codeVerifier.set('fakecodeverifier'); storage.authentication.nonce.clear(); @@ -147,11 +147,11 @@ describe('handleOAuthCallback', () => { params: 'state=fehgefhgkefghk&code=gyuwyutfetyfew', }) ).rejects.toThrowError( - 'No nonce found in local storage when running OAuth callback.' + 'No nonce found in session storage when running OAuth callback.' ); }); - it('should throw if the nonce in local storage does not match the "state" sent back by login', async () => { + it('should throw if the nonce in session storage does not match the "state" sent back by login', async () => { storage.authentication.codeVerifier.set('fakecodeverifier'); storage.authentication.nonce.set('fakenonce'); diff --git a/packages/manager/src/OAuth/oauth.ts b/packages/manager/src/OAuth/oauth.ts index 3d111dbecbe..7f3bb831396 100644 --- a/packages/manager/src/OAuth/oauth.ts +++ b/packages/manager/src/OAuth/oauth.ts @@ -207,7 +207,7 @@ export async function handleOAuthCallback(options: AuthCallbackOptions) { if (!codeVerifier) { throw new AuthenticationError( - 'No code codeVerifier found in local storage when running OAuth callback.' + 'No code codeVerifier found in session storage when running OAuth callback.' ); } @@ -217,7 +217,7 @@ export async function handleOAuthCallback(options: AuthCallbackOptions) { if (!storedNonce) { throw new AuthenticationError( - 'No nonce found in local storage when running OAuth callback.' + 'No nonce found in session storage when running OAuth callback.' ); } diff --git a/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx b/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx index 6f9b14cfa49..d552503e5e8 100644 --- a/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx +++ b/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx @@ -40,6 +40,10 @@ export interface BreadcrumbProps { * A string representation of the path of a resource. Each crumb is separated by a `/` character. */ pathname: string; + /** + * A string that can be used to set a custom Pendo ID for the breadcrumb for tracking purposes. + */ + pendoId?: string; /** * A number indicating the position of the crumb to remove. Not zero indexed. */ @@ -65,6 +69,7 @@ export const Breadcrumb = (props: BreadcrumbProps) => { labelTitle, onEditHandlers, pathname, + pendoId, removeCrumbX, sx, } = props; @@ -91,6 +96,7 @@ export const Breadcrumb = (props: BreadcrumbProps) => { theme.spacing(3) }) }} {...breadcrumbDataAttrs} + data-pendo-id={pendoId} > boolean; /** * Callback fired when the user selects an image row. */ @@ -65,12 +73,18 @@ interface Props { */ pendoIDs: | typeof IMAGE_SELECT_TABLE_LINODE_CREATE_PENDO_IDS - | typeof IMAGE_SELECT_TABLE_LINODE_REBUILD_PENDO_IDS; + | typeof IMAGE_SELECT_TABLE_LINODE_REBUILD_PENDO_IDS + | typeof IMAGE_SELECT_TABLE_SHARE_GROUP_CREATE_PENDO_IDS; queryParamsPrefix?: string; /** - * The ID of the currently selected image. + * The IDs of the currently selected images, when using multi-select mode with checkboxes. + */ + selectedImageIds: string[]; + /** + * Whether this table should use single-select mode with radio buttons, or multi-select mode with checkboxes. + * The default is single select. */ - selectedImageId?: null | string; + selectionMode: SelectionMode; } type OptionType = { label: string; value: string }; @@ -79,10 +93,12 @@ export const ImageSelectTable = (props: Props) => { const { currentRoute, errorText, + filter, onSelect, pendoIDs, queryParamsPrefix, - selectedImageId, + selectionMode, + selectedImageIds, } = props; const theme = useTheme(); @@ -121,7 +137,7 @@ export const ImageSelectTable = (props: Props) => { }); const { - data: imagesData, + data: _imagesData, error: imagesError, isFetching, isLoading, @@ -129,11 +145,11 @@ export const ImageSelectTable = (props: Props) => { {}, { ...combinedFilter, - is_public: false, - type: 'manual', } ); + const imagesData = filter ? _imagesData?.filter(filter) : _imagesData; + const pagination = usePaginationV2({ clientSidePaginationData: imagesData, currentRoute, @@ -256,29 +272,31 @@ export const ImageSelectTable = (props: Props) => { Replicated in - - - - Share Group - - - - + {selectionMode === 'single' && ( + + + + Share Group + + + + + )} - Size + {selectionMode === 'single' ? 'Size' : 'Original Image'} { onSelect={() => onSelect(image)} pendoIDs={pendoIDs} regions={regions ?? []} - selected={image.id === selectedImageId} + selectedImageIds={selectedImageIds} + selectionMode={selectionMode} timezone={profile?.timezone} /> ))} diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx index 98dd251e9a4..6f068940822 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx @@ -1,3 +1,4 @@ +import { TableCell, TableRow } from '@akamai/cds-components/react/Table'; import { FormControlLabel, Hidden, @@ -7,7 +8,6 @@ import { } from '@linode/ui'; import { convertStorageUnit, pluralize } from '@linode/utilities'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; import React from 'react'; import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; @@ -26,20 +26,31 @@ import type { } from './constants'; import type { Image, ImageRegion, Region } from '@linode/api-v4'; import type { Theme } from '@linode/ui'; +import type { IMAGE_SELECT_TABLE_SHARE_GROUP_CREATE_PENDO_IDS } from 'src/components/ImageSelect/constants'; interface Props { image: Image; - onSelect: () => void; + onSelect?: () => void; pendoIDs: | typeof IMAGE_SELECT_TABLE_LINODE_CREATE_PENDO_IDS - | typeof IMAGE_SELECT_TABLE_LINODE_REBUILD_PENDO_IDS; + | typeof IMAGE_SELECT_TABLE_LINODE_REBUILD_PENDO_IDS + | typeof IMAGE_SELECT_TABLE_SHARE_GROUP_CREATE_PENDO_IDS; regions: Region[]; - selected: boolean; + selectedImageIds: string[]; + selectionMode: 'multi' | 'single'; timezone?: string; } export const ImageSelectTableRow = (props: Props) => { - const { image, onSelect, pendoIDs, regions, selected, timezone } = props; + const { + image, + onSelect, + pendoIDs, + regions, + selectedImageIds, + timezone, + selectionMode, + } = props; const { capabilities, @@ -47,7 +58,7 @@ export const ImageSelectTableRow = (props: Props) => { id, image_sharing, label, - regions: imageRegions, + regions: _imageRegions, size, status, type, @@ -77,6 +88,8 @@ export const ImageSelectTableRow = (props: Props) => { return '—'; }; + const imageRegions = _imageRegions ?? []; // Failsafe for manual images whose `regions` property is null + const FormattedRegionList = () => ( {imageRegions.map((region: ImageRegion, idx) => { @@ -89,16 +102,27 @@ export const ImageSelectTableRow = (props: Props) => { ); + const selected = selectedImageIds.includes(id); return ( - + - } - label={label} - onChange={onSelect} - sx={{ gap: 2 }} - /> + {selectionMode === 'single' ? ( + } + label={label} + onChange={onSelect} + sx={{ gap: 2 }} + /> + ) : ( + label + )} {type === 'manual' && capabilities.includes('cloud-init') && ( { ? pluralize('Region', 'Regions', imageRegions.length) : '—' } - tooltipText={} + tooltipText={ + imageRegions?.length > 0 ? : 'N/A' + } /> - - - {getShareGroupDisplay()} - - + {selectionMode === 'single' && ( + + + {getShareGroupDisplay()} + + + )} {getSizeDisplay()} diff --git a/packages/manager/src/components/ImageSelect/constants.ts b/packages/manager/src/components/ImageSelect/constants.ts index 705234914b8..8f0480791f6 100644 --- a/packages/manager/src/components/ImageSelect/constants.ts +++ b/packages/manager/src/components/ImageSelect/constants.ts @@ -22,3 +22,18 @@ export const IMAGE_SELECT_TABLE_LINODE_REBUILD_PENDO_IDS = { replicatedRegionPopover: 'Linodes Rebuild Images-Replicated in', shareGroupInfoIcon: 'Linodes Rebuild Images-Share Group info icon', }; + +export const IMAGE_SELECT_TABLE_SHARE_GROUP_CREATE_PENDO_IDS = { + searchImagesBar: 'Share Groups Create Images-Search click', + tagFilterSelect: 'Share Groups Create Images-Filter by Tag click', + regionFilterSelect: 'Share Groups Create Images-Filter by Region click', + metadataSupportedIcon: 'Share Groups Create Images-Metadata Supported icon', + replicatedRegionPopover: 'Share Groups Create Images-Replicated in', + shareGroupInfoIcon: 'Share Groups Create Images-Share Group info icon', + createImageLink: 'Share Groups Create Images-Create Image Link', + uploadImageLink: 'Share Groups Create Images-Upload Image Link', + imageCheckbox: 'Share Groups Create Images-Image Checkbox', + pageSizeSelect: 'Share Groups Create Images-Page Size Select', + previousPageButton: 'Share Groups Create Images-Previous Page Button', + nextPageButton: 'Share Groups Create Images-Next Page Button', +}; diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.tsx index a617543b766..04cd13525a3 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.tsx @@ -29,6 +29,7 @@ export interface LandingHeaderProps { onButtonClick?: () => void; onButtonKeyPress?: (e: React.KeyboardEvent) => void; onDocsClick?: () => void; + pendoId?: string; removeCrumbX?: number | number[]; shouldHideDocsAndCreateButtons?: boolean; spacingBottom?: 0 | 4 | 16 | 24; @@ -46,6 +47,7 @@ export const LandingHeader = ({ breadcrumbProps, buttonDataAttrs, createButtonText, + pendoId, disabledBreadcrumbEditButton, disabledCreateButton, docsLabel, @@ -98,6 +100,7 @@ export const LandingHeader = ({ {...breadcrumbDataAttrs} {...breadcrumbProps} disabledBreadcrumbEditButton={disabledBreadcrumbEditButton} + pendoId={pendoId} /> {!shouldHideDocsAndCreateButtons && ( diff --git a/packages/manager/src/components/Markdown/Markdown.test.tsx b/packages/manager/src/components/Markdown/Markdown.test.tsx index b32a869c2a2..e940ae4f0ba 100644 --- a/packages/manager/src/components/Markdown/Markdown.test.tsx +++ b/packages/manager/src/components/Markdown/Markdown.test.tsx @@ -8,10 +8,15 @@ const sampleMarkdown = '# Some markdown \n ```javascript\n const x = function() { return true; }\n```'; describe('Markdown component', () => { - it('should highlight text consistently', () => { - const { asFragment } = renderWithTheme( + it('should render markdown content', () => { + const { container } = renderWithTheme( ); - expect(asFragment()).toMatchSnapshot(); + + // Test for the presence of key elements rather than exact rendering + expect(container.querySelector('h1')).toBeInTheDocument(); + expect(container.querySelector('code')).toBeInTheDocument(); + expect(container.textContent).toContain('Some markdown'); + expect(container.textContent).toContain('const x = function()'); }); }); diff --git a/packages/manager/src/components/Markdown/__snapshots__/Markdown.test.tsx.snap b/packages/manager/src/components/Markdown/__snapshots__/Markdown.test.tsx.snap deleted file mode 100644 index 3b2c384c680..00000000000 --- a/packages/manager/src/components/Markdown/__snapshots__/Markdown.test.tsx.snap +++ /dev/null @@ -1,71 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Markdown component > should highlight text consistently 1`] = ` - -

-

- Some markdown -

- - -
-    
-      
-        
-          const
-        
-        
-           x
-        
-        
-           =
-        
-        
-           function
-        
-        
-          () { 
-        
-        
-          return
-        
-        
-           true
-        
-        
-          ; }
-        
-      
-      
-
-      
-    
-  
-

- -`; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index ff7b6114d23..93ed6a9b6cc 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -94,6 +94,20 @@ export interface PrimaryNavProps { isCollapsed: boolean; } +/** Indices for product-family accordions when the user has not saved `collapsedSideNavProductFamilies` yet. */ +const DEFAULT_COLLAPSED_SIDE_NAV_INDICES = [1, 2, 3, 4, 5, 6, 7]; + +function getActiveSideNavGroupIndex( + groups: ProductFamilyLinkGroup[], + pathname: string +) { + return groups.findIndex((group) => { + const filteredLinks = group.links.filter((link) => !link.hide); + + return filteredLinks.some((link) => linkIsActive(pathname, link.to)); + }); +} + export const PrimaryNav = (props: PrimaryNavProps) => { const { closeMenu, desktopMenuToggle, isCollapsed } = props; const navItemsRef = React.useRef(null); @@ -123,7 +137,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isDatabasesEnabled, isDatabasesV2Beta } = useIsDatabasesEnabled(); const { isIAMEnabled } = useIsIAMEnabled(); - const showLimitedAvailabilityBadges = flags.iamLimitedAvailabilityBadges; const { isNetworkLoadBalancerEnabled } = useIsNetworkLoadBalancerEnabled(); @@ -131,20 +144,11 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isReserveIpEnabled } = useIsReserveIpEnabled(); - const { - data: preferences, - error: preferencesError, - isLoading: preferencesLoading, - } = usePreferences(); + const { data: preferences } = usePreferences(); const collapsedSideNavPreference = preferences?.collapsedSideNavProductFamilies; - const collapsedAccordions = React.useMemo( - () => collapsedSideNavPreference ?? [1, 2, 3, 4, 5, 6, 7], // by default, we collapse all categories if no preference is set; - [collapsedSideNavPreference] - ); - const { mutateAsync: updatePreferences } = useMutatePreferences(); const productFamilyLinkGroups: ProductFamilyLinkGroup[] = @@ -309,7 +313,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { display: 'Identity & Access', hide: !isIAMEnabled, to: '/iam', - isNew: isIAMEnabled && showLimitedAvailabilityBadges, + isNew: isIAMEnabled && flags.iamNewBadge, }, { display: 'Quotas', @@ -372,6 +376,30 @@ export const PrimaryNav = (props: PrimaryNavProps) => { ] ); + const collapsedAccordions = React.useMemo(() => { + if (preferences === undefined) { + return DEFAULT_COLLAPSED_SIDE_NAV_INDICES; + } + if (collapsedSideNavPreference !== undefined) { + return collapsedSideNavPreference; + } + const activeGroupIndex = getActiveSideNavGroupIndex( + productFamilyLinkGroups, + location.pathname + ); + if (activeGroupIndex === -1) { + return DEFAULT_COLLAPSED_SIDE_NAV_INDICES; + } + return DEFAULT_COLLAPSED_SIDE_NAV_INDICES.filter( + (idx) => idx !== activeGroupIndex + ); + }, [ + collapsedSideNavPreference, + preferences, + productFamilyLinkGroups, + location.pathname, + ]); + const accordionClicked = React.useCallback( (index: number) => { let updatedCollapsedAccordions: number[]; @@ -435,48 +463,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { }; }, [checkOverflow]); - // This effect will only run if the collapsedSideNavPreference is not set - // When a user lands on a page and does not have any preference set, - // we want to expand the accordion that contains the active link for convenience and discoverability - React.useEffect(() => { - // Wait for preferences to load or if there's an error - if (preferencesLoading || preferencesError) { - return; - } - - // Wait for preferences data to be available (not just the field, but the whole object) - if (!preferences) { - return; - } - - // If user has already set collapsedSideNavProductFamilies preference, don't override it - if (collapsedSideNavPreference) { - return; - } - - // Find the index of the group containing the active link and expand it - const activeGroupIndex = productFamilyLinkGroups.findIndex((group) => { - const filteredLinks = group.links.filter((link) => !link.hide); - - return filteredLinks.some((link) => - linkIsActive(location.pathname, link.to) - ); - }); - - if (activeGroupIndex !== -1) { - accordionClicked(activeGroupIndex); - } - }, [ - accordionClicked, - location.pathname, - location.search, - productFamilyLinkGroups, - collapsedSideNavPreference, - preferences, - preferencesLoading, - preferencesError, - ]); - let activeProductFamily = ''; return ( diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index 12a921d2886..c0cb3931bfa 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -47,6 +47,10 @@ export interface TagsInputProps { * Callback fired when the value changes. */ onChange: (selected: TagOption[]) => void; + /** + * If true, displays "(optional)" after the label. + */ + optional?: boolean; /** * An error to display beneath the input. */ @@ -58,8 +62,16 @@ export interface TagsInputProps { } export const TagsInput = (props: TagsInputProps) => { - const { disabled, hideLabel, label, noMarginTop, onChange, tagError, value } = - props; + const { + disabled, + hideLabel, + label, + noMarginTop, + onChange, + optional, + tagError, + value, + } = props; const [errors, setErrors] = React.useState([]); @@ -185,7 +197,7 @@ export const TagsInput = (props: TagsInputProps) => { /> )); }} - textFieldProps={{ hideLabel, noMarginTop }} + textFieldProps={{ hideLabel, noMarginTop, optional }} value={value} /> ); diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index a77562d06ba..f8eeb143115 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -49,6 +49,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, { flag: 'objectStorageGlobalQuotas', label: 'OBJ Global Quotas' }, + { flag: 'passwordlessLinodes', label: 'PasswordLess Linodes' }, { flag: 'placementGroupPolicyUpdate', label: 'Placement Group Policy Update', @@ -59,6 +60,10 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, { flag: 'dbaasV2', label: 'Databases V2 Beta' }, { flag: 'dbaasV2MonitorMetrics', label: 'Databases V2 Monitor' }, + { + flag: 'databaseResizeGenerationalPlans', + label: 'Database Resize Generational Plans', + }, { flag: 'databasePgBouncer', label: 'Database PgBouncer' }, { flag: 'databaseResize', label: 'Database Resize' }, { flag: 'databaseAdvancedConfig', label: 'Database Advanced Config' }, @@ -69,11 +74,15 @@ const options: { flag: keyof Flags; label: string }[] = [ flag: 'databaseRestrictPlanResize', label: 'Database Restrict Premium Plan Resize', }, + { + flag: 'databaseValkey', + label: 'Database Valkey', + }, { flag: 'apicliButtonCopy', label: 'APICLI Button Copy' }, { flag: 'iam', label: 'IAM enabled & Beta' }, { - flag: 'iamLimitedAvailabilityBadges', - label: 'IAM Limited Availability Badges', + flag: 'iamNewBadge', + label: 'IAM New Badge', }, { flag: 'iamDelegation', label: 'IAM Delegation (Parent/Child)' }, { @@ -94,6 +103,7 @@ const options: { flag: keyof Flags; label: string }[] = [ label: 'Object Storage Contextual Metrics', }, { flag: 'objSummaryPage', label: 'OBJ Summary Page' }, + { flag: 'vpcDbaasResources', label: 'VPC DBaaS Resources' }, { flag: 'vpcIpv6', label: 'VPC IPv6' }, { flag: 'reserveIp', label: 'Reserve IP' }, { flag: 'marketplaceV2GlobalBanner', label: 'Marketplace V2 Global Banner' }, diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 2d47993e8f1..9fffbc8da76 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -199,6 +199,8 @@ export const databaseInstanceFactory = label: Factory.each((i) => `example.com-database-${i}`), members: { '2.2.2.2': 'primary', + '2.2.2.3': 'failover', + '2.2.2.4': 'failover', }, platform: 'rdbms-default', region: Factory.each((i) => possibleRegions[i % possibleRegions.length]), @@ -268,6 +270,8 @@ export const databaseFactory = Factory.Sync.makeFactory({ label: Factory.each((i) => `database-${i}`), members: { '2.2.2.2': 'primary', + '2.2.2.3': 'failover', + '2.2.2.4': 'failover', }, oldest_restore_time: '2024-09-15T17:15:12', platform: 'rdbms-default', diff --git a/packages/manager/src/factories/networking.ts b/packages/manager/src/factories/networking.ts index ec1ad0fee40..85122e7e340 100644 --- a/packages/manager/src/factories/networking.ts +++ b/packages/manager/src/factories/networking.ts @@ -17,3 +17,55 @@ export const ipAddressFactory = Factory.Sync.makeFactory({ reserved: false, tags: [], }); + +const REGIONS = ['pl-labkrk-2', 'us-labedgeeat-2', 'us-labedgeeat-3']; +const SAMPLE_TAGS = [ + ['web', 'production', 'db', 'staging', 'lb', 'api', 'internal'], + ['db', 'staging'], + ['lb'], + ['api', 'internal'], + [], +]; +const SAMPLE_ENTITIES: Array = [ + { + id: 1, + label: 'web-server-01', + type: 'linode', + url: '/v4/linode/instances/1', + }, + { + id: 2, + label: 'ubuntu-pl-labkrk-2', + type: 'linode', + url: '/v4/linode/instances/2', + }, + null, + { + id: 5, + label: 'my-nodebalancer', + type: 'nodebalancer', + url: '/v4/nodebalancers/5', + }, + null, +]; + +export const reservedIPsFactory = Factory.Sync.makeFactory({ + address: Factory.each((id) => `203.0.113.${id}`), + assigned_entity: Factory.each( + (id) => SAMPLE_ENTITIES[id % SAMPLE_ENTITIES.length] + ), + gateway: '203.0.113.1', + interface_id: null, + linode_id: Factory.each((id) => { + const entity = SAMPLE_ENTITIES[id % SAMPLE_ENTITIES.length]; + return entity?.type === 'linode' ? entity.id : null; + }), + prefix: 24, + public: true, + rdns: '172-24-226-80.ip.linodeusercontent.com', + region: Factory.each((id) => REGIONS[id % REGIONS.length]), + reserved: true, + subnet_mask: '255.255.255.0', + tags: Factory.each((id) => SAMPLE_TAGS[id % SAMPLE_TAGS.length]), + type: 'ipv4', +}); diff --git a/packages/manager/src/factories/subnets.ts b/packages/manager/src/factories/subnets.ts index d81bf06a94c..4c460a5dc15 100644 --- a/packages/manager/src/factories/subnets.ts +++ b/packages/manager/src/factories/subnets.ts @@ -2,6 +2,7 @@ import { Factory } from '@linode/utilities'; import type { Subnet, + SubnetAssignedDatabaseData, SubnetAssignedLinodeData, SubnetAssignedNodeBalancerData, } from '@linode/api-v4/lib/vpcs/types'; @@ -27,6 +28,23 @@ export const subnetAssignedNodebalancerDataFactory = ipv4_range: Factory.each((i) => `192.168.${i}.0/30`), }); +export const subnetAssignedDatabaseDataFactory = + Factory.Sync.makeFactory({ + id: Factory.each((i) => i), + ipv4_range: Factory.each((i) => `192.168.${i}.0/30`), + ipv6_ranges: Factory.each((i) => [ + { + range: `2600:3c11:e41c:${i}::/64`, + }, + { + range: `2600:3c11:e41c:${i}::/64`, + }, + { + range: `2600:3c11:e41c:${i}::/64`, + }, + ]), + }); + export const subnetFactory = Factory.Sync.makeFactory({ created: '2023-07-12T16:08:53', id: Factory.each((i) => i), @@ -46,5 +64,12 @@ export const subnetFactory = Factory.Sync.makeFactory({ }) ) ), + databases: Factory.each((i) => + Array.from({ length: 3 }, (_, arrIdx) => + subnetAssignedDatabaseDataFactory.build({ + id: i * 10 + arrIdx, + }) + ) + ), updated: '2023-07-12T16:08:53', }); diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index e4d82346194..f389401737a 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -298,3 +298,20 @@ export const networkTransferPriceTypeFactory = ], transfer: 0, }); + +export const reservedIPsTypeFactory = Factory.Sync.makeFactory({ + id: 'reserved-ipv4', + label: 'Reserved IPv4 Address', + price: { + hourly: 0.007, + monthly: 5, + }, + region_prices: [ + { + hourly: 0.014, + id: 'pl-labkrk-2', + monthly: 10, + }, + ], + transfer: 0, +}); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index f44b37ed4de..28b1b691fae 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -135,6 +135,10 @@ interface AclpLogsFlag extends BetaFeatureFlag { * This property indicates whether to show Custom HTTPS destination type */ customHttpsEnabled?: boolean; + /** + * This property indicates whether to show the "Metrics" tab on Logs Stream details page or not + */ + metricsEnabled?: boolean; /** * This property indicates whether the feature is new or not */ @@ -238,8 +242,10 @@ export interface Flags { databasePgBouncer: boolean; databasePremium: boolean; databaseResize: boolean; + databaseResizeGenerationalPlans: boolean; databaseRestrictPlanResize: boolean; databases: boolean; + databaseValkey: BetaFeatureFlag; databaseVpc: boolean; databaseVpcBeta: boolean; dbaasV2: BetaFeatureFlag; @@ -252,7 +258,7 @@ export interface Flags { hostnameEndpoints: boolean; iam: BaseFeatureFlag; iamDelegation: BaseFeatureFlag; - iamLimitedAvailabilityBadges: boolean; + iamNewBadge: boolean; ipv6Sharing: boolean; kubernetesBlackwellPlans: boolean; limitsEvolution: LimitsEvolution; @@ -275,6 +281,7 @@ export interface Flags { objectStorageGlobalQuotas: boolean; objMultiCluster: boolean; objSummaryPage: boolean; + passwordlessLinodes: boolean; placementGroupPolicyUpdate: boolean; privateImageSharing: boolean; productInformationBanners: ProductInformationBannerFlag[]; @@ -295,6 +302,7 @@ export interface Flags { udp: boolean; vmHostMaintenance: VMHostMaintenanceFlag; volumeSummaryPage: boolean; + vpcDbaasResources: boolean; vpcIpv6: boolean; } diff --git a/packages/manager/src/features/Account/Quotas/utils.test.tsx b/packages/manager/src/features/Account/Quotas/utils.test.tsx index d4ad5f3d3de..40a4775d69e 100644 --- a/packages/manager/src/features/Account/Quotas/utils.test.tsx +++ b/packages/manager/src/features/Account/Quotas/utils.test.tsx @@ -30,11 +30,15 @@ vi.mock('src/queries/object-storage/queries', () => { }); describe('useGetLocationsForQuotaService', () => { + let queryClient: QueryClient; + const wrapper = ({ children }: { children: React.ReactNode }) => { - const queryClient = new QueryClient({ + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, + gcTime: 0, // Prevent queries from being cached/hanging after test completes + staleTime: 0, }, }, }); @@ -44,6 +48,12 @@ describe('useGetLocationsForQuotaService', () => { ); }; + afterEach(() => { + // Clean up query client to prevent async operations after test teardown + queryClient?.clear(); + vi.clearAllMocks(); + }); + it('should handle object storage endpoints with null values', () => { const { result } = renderHook( () => useGetLocationsForQuotaService('object-storage'), diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx index 524960b2cb0..38663a3a222 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx @@ -1,11 +1,11 @@ -import { Box, CircleProgress, LinkButton, useTheme } from '@linode/ui'; -import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { Pagination } from '@akamai/cds-components/react/Pagination'; import { Table, TableBody, TableCell, TableRow, -} from 'akamai-cds-react-components/Table'; +} from '@akamai/cds-components/react/Table'; +import { Box, CircleProgress, LinkButton, useTheme } from '@linode/ui'; import React from 'react'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index d0db461a823..988b5fa800e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -139,7 +139,7 @@ describe('AlertDefinition Create', () => { it( 'should render client side validation errors for threshold and trigger occurences text field', - { timeout: 10000 }, + { timeout: 30000 }, async () => { const user = userEvent.setup(); const container = renderWithTheme(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx index 3dc4ff8f240..a7951b8cee1 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx @@ -64,7 +64,7 @@ const alertDetails = alertFactory.build({ const ENTITY_TYPE_SELECT_TEST_ID = 'entity-type-select'; describe('EditAlertDefinition component', () => { - it('renders the components of the form', { timeout: 20000 }, async () => { + it('renders the components of the form', { timeout: 30000 }, async () => { const { findByPlaceholderText, getByLabelText, getByText } = renderWithTheme( @@ -90,7 +90,7 @@ describe('EditAlertDefinition component', () => { expect(getByText('4. Notification Channels')).toBeVisible(); }); - it('should submit form data correctly', { timeout: 10000 }, async () => { + it('should submit form data correctly', { timeout: 30000 }, async () => { navigate({ to: '/alerts/definitions/edit/linode/1', }); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.style.ts b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.style.ts index a19ea764dff..b802af7c09e 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.style.ts +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.style.ts @@ -1,6 +1,6 @@ +import { Button } from '@akamai/cds-components/react'; import { Box, TextField, Typography } from '@linode/ui'; import { Grid, styled } from '@mui/material'; -import { Button } from 'akamai-cds-react-components'; import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 7c670ebf370..773d8bde45c 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -18,7 +18,10 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorMessage } from 'src/components/ErrorMessage'; import { LandingHeader } from 'src/components/LandingHeader'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { getIsLimitedAvailability } from 'src/features/components/PlansPanel/utils'; +import { + getIsLimitedAvailability, + useShouldDisablePremiumPlansTab, +} from 'src/features/components/PlansPanel/utils'; import { DatabaseClusterData } from 'src/features/Databases/DatabaseCreate/DatabaseClusterData'; import { StyledBtnCtn, @@ -252,11 +255,15 @@ export const DatabaseCreate = () => { } }; + const shouldDisablePremiumPlansTab = useShouldDisablePremiumPlansTab({ + types: dbtypes, + }); + if (regionsLoading || !regionsData || enginesLoading || typesLoading) { return ; } - if (regionsError || typesError || enginesError) { + if (regionsError || enginesError || typesError) { return ; } @@ -312,6 +319,9 @@ export const DatabaseCreate = () => { { regionsData={regionsData} selectedId={field.value} selectedRegionID={region} + tabDisabledMessage={ + shouldDisablePremiumPlansTab + ? 'Premium CPUs are now called G7 Dedicated plans.' + : undefined + } types={displayTypes} /> )} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx index be62f9b5cb5..82894385212 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx @@ -1,6 +1,6 @@ +import { Button } from '@akamai/cds-components/react'; import { useDatabaseMutation } from '@linode/queries'; import { ActionsPanel, Notice, Typography } from '@linode/ui'; -import { Button } from 'akamai-cds-react-components'; import * as React from 'react'; import type { JSX } from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx index e7d54305105..7bdb62454ff 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx @@ -1,7 +1,7 @@ +import { Button } from '@akamai/cds-components/react'; import { Box, Paper, Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { useNavigate } from '@tanstack/react-router'; -import { Button } from 'akamai-cds-react-components'; import React from 'react'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx index 033206afd43..ffcfab1b68c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx @@ -1,3 +1,4 @@ +import { Button } from '@akamai/cds-components/react'; import { yupResolver } from '@hookform/resolvers/yup'; import { useDatabaseEngineConfig, useDatabaseMutation } from '@linode/queries'; import { @@ -12,7 +13,6 @@ import { import { scrollErrorIntoViewV2 } from '@linode/utilities'; import { createDynamicAdvancedConfigSchema } from '@linode/validation'; import Grid from '@mui/material/Grid'; -import { Button } from 'akamai-cds-react-components'; import { enqueueSnackbar } from 'notistack'; import React, { useEffect, useMemo, useState } from 'react'; import { Controller, get, useFieldArray, useForm } from 'react-hook-form'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx index 9cb39fa948b..853ce9a96f6 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx @@ -1,3 +1,4 @@ +import { Button } from '@akamai/cds-components/react'; import { Autocomplete, CloseIcon, @@ -6,7 +7,6 @@ import { Toggle, Typography, } from '@linode/ui'; -import { Button } from 'akamai-cds-react-components'; import React from 'react'; import { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx index a8fe2fe0c43..f6b54344eaa 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx @@ -1,5 +1,5 @@ +import { TableCell, TableRow } from '@akamai/cds-components/react/Table'; 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'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx index b6664b2cc9f..eac6b1809f6 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx @@ -1,3 +1,12 @@ +import { Pagination } from '@akamai/cds-components/react/Pagination'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@akamai/cds-components/react/Table'; import { useDatabaseConnectionPoolsQuery } from '@linode/queries'; import { Button, @@ -9,15 +18,6 @@ import { } from '@linode/ui'; import Grid from '@mui/material/Grid'; 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 { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.style.ts index d30b3c71db5..35e86f554ab 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.style.ts @@ -1,6 +1,6 @@ +import { Button } from '@akamai/cds-components/react'; import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; -import { Button } from 'akamai-cds-react-components'; import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx index 544c74e7ed9..edc2fe1f925 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx @@ -151,6 +151,7 @@ describe('database resize', () => { beta: false, enabled: true, }, + databasePremium: true, }; it('resize button should be disabled when no input is provided in the form', async () => { @@ -485,45 +486,6 @@ describe('database resize', () => { }); }); - describe('should disable Shared Plans Tab for 2 nodes cluster', () => { - const database = databaseFactory.build({ - cluster_size: 2, - type: 'g6-dedicated-8', - }); - it('should disable Shared Plans Tab', async () => { - const standardTypes = [ - databaseTypeFactory.build({ - class: 'nanode', - id: 'g6-nanode-1', - label: `Nanode 1 GB`, - memory: 1024, - }), - ]; - server.use( - http.get('*/databases/types', () => { - return HttpResponse.json( - makeResourcePage([...dedicatedTypes, ...standardTypes]) - ); - }), - http.get('*/account', () => { - const account = accountFactory.build(); - return HttpResponse.json(account); - }) - ); - - const { getByTestId, getByText } = renderWithTheme( - - - - ); - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - expect(getByText('Shared CPU')).toHaveAttribute('aria-disabled', 'true'); - }); - }); - describe('on rendering resize when databaseRestrictPlanResize feature flag is enabled', () => { beforeEach(() => { const standardTypes = [ diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx index e80542c07ce..fe078785863 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx @@ -33,11 +33,12 @@ import { } from 'src/features/Databases/utilities'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; import { useFlags } from 'src/hooks/useFlags'; +import { useIsGenerationalPlansEnabled } from 'src/utilities/linodes'; import { RESIZE_DISABLED_DEDICATED_SHARED_PLAN_TABS_TEXT, + RESIZE_DISABLED_NON_G7_DEDICATED_SHARED_PLAN_TABS_TEXT, RESIZE_DISABLED_PREMIUM_PLAN_TAB_TEXT, - RESIZE_DISABLED_SHARED_PLAN_TAB_LEGACY_TEXT, } from '../../constants'; import { useDatabaseDetailContext } from '../DatabaseDetailContext'; import { @@ -112,45 +113,62 @@ export const DatabaseResize = () => { (type: DatabaseType) => type.id === database.type ); - const isDisabledSharedTab = database.cluster_size === 2; - - const premiumRestrictedTabsCopy = - currentPlanType?.class === 'premium' - ? RESIZE_DISABLED_DEDICATED_SHARED_PLAN_TABS_TEXT - : RESIZE_DISABLED_PREMIUM_PLAN_TAB_TEXT; - - const restrictPlanTypes = () => { - if (currentPlanType?.class === 'premium') { - return ['shared', 'dedicated']; - } else { - return ['premium']; - } - }; + const { isGenerationalPlansEnabled } = useIsGenerationalPlansEnabled( + dbTypes, + currentPlanType?.class + ); const disabledTabsConfig: { disabledTabs: string[]; disabledTabsCopy: string; } = React.useMemo(() => { - // For new database clusters, restrict plan types based on the current plan - if (isDefaultDatabase(database) && flags.databaseRestrictPlanResize) { + if ( + !flags.databaseRestrictPlanResize || + (flags.databaseResizeGenerationalPlans && + currentPlanType?.class === 'premium') + ) { return { - disabledTabsCopy: premiumRestrictedTabsCopy, - disabledTabs: restrictPlanTypes(), + disabledTabs: [], + disabledTabsCopy: '', }; } - // Disable shared tab for legacy database clusters when cluster size is 2 - if (!isNewDatabaseGA && isDisabledSharedTab) { + + if (!isGenerationalPlansEnabled && currentPlanType?.class === 'premium') { + return { + disabledTabs: ['shared', 'dedicated'], + disabledTabsCopy: RESIZE_DISABLED_DEDICATED_SHARED_PLAN_TABS_TEXT, + }; + } + + if (isGenerationalPlansEnabled && currentPlanType?.class === 'premium') { return { - disabledTabsCopy: RESIZE_DISABLED_SHARED_PLAN_TAB_LEGACY_TEXT, disabledTabs: ['shared'], + disabledTabsCopy: + RESIZE_DISABLED_NON_G7_DEDICATED_SHARED_PLAN_TABS_TEXT, + }; + } + + if ( + isGenerationalPlansEnabled && + flags.databaseResizeGenerationalPlans && + currentPlanType?.class !== 'premium' + ) { + return { + disabledTabs: ['premium'], + disabledTabsCopy: 'Premium CPUs are now called G7 Dedicated plans.', }; } return { - disabledTabs: [], - disabledTabsCopy: '', + disabledTabs: ['premium'], + disabledTabsCopy: RESIZE_DISABLED_PREMIUM_PLAN_TAB_TEXT, }; - }, [database, flags, isNewDatabaseGA]); + }, [ + currentPlanType?.class, + flags.databaseResizeGenerationalPlans, + flags.databaseRestrictPlanResize, + isGenerationalPlansEnabled, + ]); const { enqueueSnackbar } = useSnackbar(); @@ -314,13 +332,34 @@ export const DatabaseResize = () => { setSelectedTab(initialTab); }, []); - const disabledPlans = isSmallerOrEqualCurrentPlan( + const disabledPlansDueToDiskSize = isSmallerOrEqualCurrentPlan( currentPlan?.id, database?.used_disk_size_gb, displayTypes, isNewDatabaseGA ); + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + const isCurrentPlanAPremiumPlan = + currentPlan?.class.includes('premium') || + currentPlan?.id.includes('g7-dedicated'); + + const disabledResizeToPremiumPlans = + !flags.databaseResizeGenerationalPlans && !isCurrentPlanAPremiumPlan + ? displayTypes.filter( + (type) => + type.class.includes('premium') || type.id.includes('g7-dedicated') + ) + : []; + + const disabledResizeFromPremiumPlans = + !flags.databaseResizeGenerationalPlans && isCurrentPlanAPremiumPlan + ? displayTypes.filter( + (type) => + !type.class.includes('premium') && !type.id.includes('g7-dedicated') + ) + : []; + const shouldSubmitBeDisabled = React.useMemo(() => { return !summaryText; }, [summaryText]); @@ -405,7 +444,10 @@ export const DatabaseResize = () => { currentPlanHeading={currentPlan?.heading} data-qa-select-plan disabled={disabled} - disabledSmallerPlans={disabledPlans} + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + disabledResizeFromPremiumPlans={disabledResizeFromPremiumPlans} + disabledResizeToPremiumPlans={disabledResizeToPremiumPlans} + disabledSmallerPlans={disabledPlansDueToDiskSize} disabledTabs={disabledTabsConfig.disabledTabs} flow="database" handleTabChange={handleTabChange} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMaintenance.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMaintenance.tsx index 8d76a33f127..fb263c4b667 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMaintenance.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMaintenance.tsx @@ -1,7 +1,7 @@ +import { Button } from '@akamai/cds-components/react'; import { useDatabaseEnginesQuery } from '@linode/queries'; import { TooltipIcon, Typography } from '@linode/ui'; import { GridLegacy, styled } from '@mui/material'; -import { Button } from 'akamai-cds-react-components'; import * as React from 'react'; import { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMenuItem.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMenuItem.tsx index 9d285dc4a32..19bebf0f4bd 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMenuItem.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMenuItem.tsx @@ -1,5 +1,5 @@ +import { Button } from '@akamai/cds-components/react'; import { Typography } from '@linode/ui'; -import { Button } from 'akamai-cds-react-components'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx index 9b7048eb6dc..b94161597e2 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx @@ -1,3 +1,4 @@ +import { Button, Select } from '@akamai/cds-components/react'; import { yupResolver } from '@hookform/resolvers/yup'; import { useDatabaseMutation } from '@linode/queries'; import { @@ -15,7 +16,6 @@ import { } from '@linode/ui'; import { updateMaintenanceSchema } from '@linode/validation'; import { styled } from '@mui/material/styles'; -import { Button, Select } from 'akamai-cds-react-components'; import { DateTime } from 'luxon'; import { useSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx index 9d8e840467c..f3b5e70bf1c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx @@ -1,8 +1,8 @@ +import { Button } from '@akamai/cds-components/react'; import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; import { TooltipIcon } from '@linode/ui'; import { downloadFile } from '@linode/utilities'; import { styled } from '@mui/material/styles'; -import { Button } from 'akamai-cds-react-components'; import { useSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index 94f6f00fb0a..95617578579 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -1,6 +1,6 @@ +import { Button } from '@akamai/cds-components/react'; import { useDatabaseCredentialsQuery } from '@linode/queries'; import { Box, CircleProgress, TooltipIcon, Typography } from '@linode/ui'; -import { Button } from 'akamai-cds-react-components'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx index 30a81843369..cece488c8c2 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx @@ -96,7 +96,7 @@ describe('Database Table', () => { expect(getByTestId(loadingTestId)).toBeInTheDocument(); await waitForElementToBeRemoved(getByTestId(loadingTestId), { - timeout: 3000, + timeout: 30000, }); // Static text and table column headers diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index d033335b735..be746f7a77a 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -68,7 +68,8 @@ export const DatabaseLanding = () => { page_size: newDatabasesPagination.pageSize, }, databasesFilter, - isDefaultEnabled // TODO (UIE-8634): Determine if check if still necessary + isDefaultEnabled, // TODO (UIE-8634): Determine if check is still necessary + 20000 ); if (databasesError) { diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index 3eaeba8e5e9..de1d7c08910 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -1,13 +1,13 @@ -import { Hidden } from '@linode/ui'; -import { useTheme } from '@mui/material/styles'; -import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { Pagination } from '@akamai/cds-components/react/Pagination'; import { Table, TableBody, TableHead, TableHeaderCell, TableRow, -} from 'akamai-cds-react-components/Table'; +} from '@akamai/cds-components/react/Table'; +import { Hidden } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; import React from 'react'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; @@ -117,7 +117,7 @@ const DatabaseLandingTable = ({ headerborder > + onSort={() => handleOrderChange('label', order === 'asc' ? 'desc' : 'asc') } sortable @@ -129,7 +129,7 @@ const DatabaseLandingTable = ({ Cluster Label + onSort={() => handleOrderChange('status', order === 'asc' ? 'desc' : 'asc') } sortable @@ -139,7 +139,7 @@ const DatabaseLandingTable = ({ {isNewDatabase && ( + onSort={() => handleOrderChange('type', order === 'asc' ? 'desc' : 'asc') } sortable @@ -150,7 +150,7 @@ const DatabaseLandingTable = ({ )} + onSort={() => handleOrderChange( 'cluster_size', order === 'asc' ? 'desc' : 'asc' @@ -163,7 +163,7 @@ const DatabaseLandingTable = ({ + onSort={() => handleOrderChange('engine', order === 'asc' ? 'desc' : 'asc') } sortable @@ -173,7 +173,7 @@ const DatabaseLandingTable = ({ + onSort={() => handleOrderChange( 'region', order === 'asc' ? 'desc' : 'asc' @@ -187,7 +187,7 @@ const DatabaseLandingTable = ({ + onSort={() => handleOrderChange( 'created', order === 'asc' ? 'desc' : 'asc' diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index decaf48b49a..5be9873086b 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -1,3 +1,4 @@ +import { TableCell, TableRow } from '@akamai/cds-components/react/Table'; import { useDatabaseTypesQuery, useProfile, @@ -5,14 +6,16 @@ import { } from '@linode/queries'; 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'; import { Link } from 'src/components/Link'; import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/DatabaseStatusDisplay'; import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVersion'; import { DatabaseActionMenu } from 'src/features/Databases/DatabaseLanding/DatabaseActionMenu'; -import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; +import { + getIsLinkInactive, + useIsDatabasesEnabled, +} from 'src/features/Databases/utilities'; import { isWithinDays, parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; @@ -64,11 +67,6 @@ export const DatabaseRow = ({ const plan = types?.find((t: DatabaseType) => t.id === type); const formattedPlan = plan && formatStorageUnits(plan.label); const actualRegion = regions?.find((r) => r.id === region); - const isLinkInactive = - status === 'suspended' || - status === 'suspending' || - status === 'resuming' || - status === 'migrated'; const { isDatabasesV2GA } = useIsDatabasesEnabled(); const configuration = @@ -97,7 +95,7 @@ export const DatabaseRow = ({ flex: '0 1 20.5%', }} > - {isDatabasesV2GA && isLinkInactive ? ( + {isDatabasesV2GA && getIsLinkInactive(status) ? ( label ) : ( {label} diff --git a/packages/manager/src/features/Databases/constants.ts b/packages/manager/src/features/Databases/constants.ts index bfb3e3b28dd..bd860397906 100644 --- a/packages/manager/src/features/Databases/constants.ts +++ b/packages/manager/src/features/Databases/constants.ts @@ -38,8 +38,8 @@ export const RESIZE_DISABLED_PREMIUM_PLAN_TAB_TEXT = export const RESIZE_DISABLED_DEDICATED_SHARED_PLAN_TABS_TEXT = 'Resizing to a Shared CPU or a Dedicated CPU plan is not available for database clusters on a Premium CPU plan.'; -export const RESIZE_DISABLED_SHARED_PLAN_TAB_LEGACY_TEXT = - 'Resizing a 2-node cluster is only allowed with Dedicated plans.'; +export const RESIZE_DISABLED_NON_G7_DEDICATED_SHARED_PLAN_TABS_TEXT = + 'Resizing to a Shared CPU or a non-G7 Dedicated CPU plan is not available for database clusters on a Premium CPU plan.'; export const BACKUPS_MAX_TIME_EXCEEDED_VALIDATON_TEXT = 'Select a time from the past.'; diff --git a/packages/manager/src/features/Databases/shared.styles.ts b/packages/manager/src/features/Databases/shared.styles.ts index fe33a83bd58..e8d5da0bd0e 100644 --- a/packages/manager/src/features/Databases/shared.styles.ts +++ b/packages/manager/src/features/Databases/shared.styles.ts @@ -1,5 +1,5 @@ +import { TableCell } from '@akamai/cds-components/react/Table'; 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'; diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 8760f8d392c..2248b19eca1 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -9,6 +9,7 @@ import type { DatabaseEngine, DatabaseFork, DatabaseInstance, + DatabaseStatus, Engine, PendingUpdates, } from '@linode/api-v4'; @@ -256,3 +257,6 @@ export const convertPrivateToPublicHostname = (host: string) => { const baseHostName = host.slice(privateStrIndex + 1); return `public-${baseHostName}`; }; + +export const getIsLinkInactive = (status: DatabaseStatus) => + ['migrated', 'resuming', 'suspended', 'suspending'].includes(status); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx index 4df080726c4..fbc73a5cd14 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -44,6 +44,8 @@ vi.mock('src/queries/object-storage/queries', async () => { }; }); +const user = userEvent.setup({ delay: null }); + const testConnectionButtonText = 'Test Connection'; const createDestinationButtonText = 'Create Destination'; const addCustomHeaderButtonText = 'Add Custom Header'; @@ -97,7 +99,7 @@ describe('DestinationCreate', () => { renderDestinationCreate(flags); const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test Destination'); + await user.type(destinationNameInput, 'Test Destination'); expect(destinationNameInput).toHaveValue('Test Destination'); }); @@ -112,11 +114,13 @@ describe('DestinationCreate', () => { it('should render Endpoint input and allow to type text in manual mode', async () => { renderDestinationCreate(flags); - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); const endpointInput = screen.getByLabelText('Endpoint'); - await userEvent.type(endpointInput, 'test-host.com'); + await user.type(endpointInput, 'test-host.com'); expect(endpointInput).toHaveValue('test-host.com'); }); @@ -124,11 +128,13 @@ describe('DestinationCreate', () => { it('should render Bucket input and allow to type text in manual mode', async () => { renderDestinationCreate(flags); - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test-bucket'); + await user.type(bucketInput, 'test-bucket'); expect(bucketInput).toHaveValue('test-bucket'); }); @@ -137,7 +143,7 @@ describe('DestinationCreate', () => { renderDestinationCreate(flags); const accessKeyIdInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIdInput, 'test-access-key'); + await user.type(accessKeyIdInput, 'test-access-key'); expect(accessKeyIdInput).toHaveValue('test-access-key'); }); @@ -146,7 +152,7 @@ describe('DestinationCreate', () => { renderDestinationCreate(flags); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'test-secret-key'); + await user.type(secretAccessKeyInput, 'test-secret-key'); expect(secretAccessKeyInput).toHaveValue('test-secret-key'); }); @@ -157,7 +163,7 @@ describe('DestinationCreate', () => { const logPathPrefixInput = screen.getByLabelText( 'Log Path Prefix (optional)' ); - await userEvent.type(logPathPrefixInput, 'test-path'); + await user.type(logPathPrefixInput, 'test-path'); expect(logPathPrefixInput).toHaveValue('test-path'); }); @@ -187,20 +193,20 @@ describe('DestinationCreate', () => { 'Log Path Prefix (optional)' ); - await userEvent.type(logPathPrefixInput, 'test'); + await user.type(logPathPrefixInput, 'test'); // sample path should be created based on *log path* value expect(samplePath!.textContent).toEqual( '/test/akamai_log-000166-1756015362-319597-login.gz' ); - await userEvent.clear(logPathPrefixInput); - await userEvent.type(logPathPrefixInput, '/test'); + await user.clear(logPathPrefixInput); + await user.type(logPathPrefixInput, '/test'); expect(samplePath!.textContent).toEqual( '/test/akamai_log-000166-1756015362-319597-login.gz' ); - await userEvent.clear(logPathPrefixInput); - await userEvent.type(logPathPrefixInput, '/'); + await user.clear(logPathPrefixInput); + await user.type(logPathPrefixInput, '/'); expect(samplePath!.textContent).toEqual( '/akamai_log-000166-1756015362-319597-login.gz' ); @@ -223,11 +229,13 @@ describe('DestinationCreate', () => { expect(endpointInput).toBeDisabled(); }); - it('should enable the Endpoint field when "Enter Bucket manually" is selected', async () => { + it('should enable the Endpoint field when "Enter Bucket details manually" is selected', async () => { renderDestinationCreate(flags); - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); const endpointInput = screen.getByLabelText('Endpoint'); expect(endpointInput).toBeEnabled(); @@ -237,22 +245,24 @@ describe('DestinationCreate', () => { renderDestinationCreate(flags); // Switch to manual mode and fill in values - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'my-manual-bucket'); + await user.type(bucketInput, 'my-manual-bucket'); expect(bucketInput).toHaveValue('my-manual-bucket'); const endpointInput = screen.getByLabelText('Endpoint'); - await userEvent.type(endpointInput, 'my-endpoint.com'); + await user.type(endpointInput, 'my-endpoint.com'); expect(endpointInput).toHaveValue('my-endpoint.com'); // Switch back to bucket_from_account const bucketFromAccountRadio = screen.getByLabelText( 'Select Bucket associated with the account' ); - await userEvent.click(bucketFromAccountRadio); + await user.click(bucketFromAccountRadio); // Both fields should be cleared const bucketAutocomplete = screen.getByLabelText('Bucket'); @@ -265,10 +275,10 @@ describe('DestinationCreate', () => { // Open the Bucket Autocomplete and select a bucket with only hostname const bucketAutocomplete = screen.getByLabelText('Bucket'); - await userEvent.click(bucketAutocomplete); + await user.click(bucketAutocomplete); const bucketOption = await screen.findByText('bucket-with-hostname'); - await userEvent.click(bucketOption); + await user.click(bucketOption); // Bucket should display the selected bucket label await waitFor(() => { @@ -286,12 +296,12 @@ describe('DestinationCreate', () => { // Open the Bucket Autocomplete and select a bucket with s3_endpoint const bucketAutocomplete = screen.getByLabelText('Bucket'); - await userEvent.click(bucketAutocomplete); + await user.click(bucketAutocomplete); const bucketOption = await screen.findByText( 'bucket-with-s3-endpoint' ); - await userEvent.click(bucketOption); + await user.click(bucketOption); // Bucket should display the selected bucket label await waitFor(() => { @@ -309,25 +319,27 @@ describe('DestinationCreate', () => { const fillOutAkamaiObjectStorageForm = async () => { const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test'); + await user.type(destinationNameInput, 'Test'); // Switch to manual bucket entry to allow typing - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); const endpointInput = screen.getByLabelText('Endpoint'); - await userEvent.type(endpointInput, 'test'); + await user.type(endpointInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test'); + await user.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); + await user.type(accessKeyIDInput, 'Test'); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); + await user.type(secretAccessKeyInput, 'Test'); const logPathPrefixInput = screen.getByLabelText( 'Log Path Prefix (optional)' ); - await userEvent.type(logPathPrefixInput, 'Test'); + await user.type(logPathPrefixInput, 'Test'); }; describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { @@ -360,14 +372,14 @@ describe('DestinationCreate', () => { await fillOutAkamaiObjectStorageForm(); expect(createDestinationButton).toBeDisabled(); - await userEvent.click(testConnectionButton); + await user.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); await waitFor(() => { expect(createDestinationButton).toBeEnabled(); }); - await userEvent.click(createDestinationButton); + await user.click(createDestinationButton); expect(createDestinationSpy).toHaveBeenCalled(); }); }); @@ -397,7 +409,7 @@ describe('DestinationCreate', () => { await fillOutAkamaiObjectStorageForm(); expect(createDestinationButton).toBeDisabled(); - await userEvent.click(testConnectionButton); + await user.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); expect(createDestinationButton).toBeDisabled(); }); @@ -424,9 +436,9 @@ describe('DestinationCreate', () => { expect(destinationTypeAutocomplete).toBeEnabled(); expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); - await userEvent.click(destinationTypeAutocomplete); + await user.click(destinationTypeAutocomplete); const customHttpsOption = await screen.findByText('Custom HTTPS'); - await userEvent.click(customHttpsOption); + await user.click(customHttpsOption); expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); }); @@ -437,16 +449,16 @@ describe('DestinationCreate', () => { const destinationTypeAutocomplete = screen.getByLabelText('Destination Type'); - await userEvent.click(destinationTypeAutocomplete); + await user.click(destinationTypeAutocomplete); const customHttpsOption = await screen.findByText('Custom HTTPS'); - await userEvent.click(customHttpsOption); + await user.click(customHttpsOption); }; it('should render Destination Name input and allow to type text', async () => { await selectCustomHttpsDestinationType(); const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test Destination'); + await user.type(destinationNameInput, 'Test Destination'); expect(destinationNameInput).toHaveValue('Test Destination'); }); @@ -460,9 +472,9 @@ describe('DestinationCreate', () => { expect(authenticationAutocomplete).toHaveValue('None'); - await userEvent.click(authenticationAutocomplete); + await user.click(authenticationAutocomplete); const basicAuthentication = await screen.findByText('Basic'); - await userEvent.click(basicAuthentication); + await user.click(basicAuthentication); expect(authenticationAutocomplete).toHaveValue('Basic'); }); @@ -474,12 +486,12 @@ describe('DestinationCreate', () => { const authenticationAutocomplete = screen.getByLabelText( 'Authentication Type' ); - await userEvent.click(authenticationAutocomplete); + await user.click(authenticationAutocomplete); const basicAuthentication = await screen.findByText('Basic'); - await userEvent.click(basicAuthentication); + await user.click(basicAuthentication); const usernameInput = screen.getByLabelText('Username'); - await userEvent.type(usernameInput, 'test-user'); + await user.type(usernameInput, 'test-user'); expect(usernameInput).toHaveValue('test-user'); }); @@ -490,12 +502,12 @@ describe('DestinationCreate', () => { const authenticationAutocomplete = screen.getByLabelText( 'Authentication Type' ); - await userEvent.click(authenticationAutocomplete); + await user.click(authenticationAutocomplete); const basicAuthentication = await screen.findByText('Basic'); - await userEvent.click(basicAuthentication); + await user.click(basicAuthentication); const passwordInput = screen.getByLabelText('Password'); - await userEvent.type(passwordInput, 'test-password'); + await user.type(passwordInput, 'test-password'); expect(passwordInput).toHaveValue('test-password'); }); @@ -505,7 +517,7 @@ describe('DestinationCreate', () => { await selectCustomHttpsDestinationType(); const endpointUrlInput = screen.getByLabelText('Endpoint URL'); - await userEvent.type(endpointUrlInput, 'https://test-endpoint.com'); + await user.type(endpointUrlInput, 'https://test-endpoint.com'); expect(endpointUrlInput).toHaveValue('https://test-endpoint.com'); }); @@ -515,7 +527,7 @@ describe('DestinationCreate', () => { await selectCustomHttpsDestinationType(); const tlsHostnameInput = screen.getByLabelText('TLS Hostname'); - await userEvent.type(tlsHostnameInput, 'test-tls-hostname'); + await user.type(tlsHostnameInput, 'test-tls-hostname'); expect(tlsHostnameInput).toHaveValue('test-tls-hostname'); }); @@ -524,7 +536,7 @@ describe('DestinationCreate', () => { await selectCustomHttpsDestinationType(); const caCertificateInput = screen.getByLabelText('CA Certificate'); - await userEvent.type(caCertificateInput, 'test-ca-certificate'); + await user.type(caCertificateInput, 'test-ca-certificate'); expect(caCertificateInput).toHaveValue('test-ca-certificate'); }); @@ -534,10 +546,7 @@ describe('DestinationCreate', () => { const clientCertificateInput = screen.getByLabelText('Client Certificate'); - await userEvent.type( - clientCertificateInput, - 'test-client-certificate' - ); + await user.type(clientCertificateInput, 'test-client-certificate'); expect(clientCertificateInput).toHaveValue('test-client-certificate'); }); @@ -546,7 +555,7 @@ describe('DestinationCreate', () => { await selectCustomHttpsDestinationType(); const clientKeyInput = screen.getByLabelText('Client Private Key'); - await userEvent.type(clientKeyInput, 'test-client-key'); + await user.type(clientKeyInput, 'test-client-key'); expect(clientKeyInput).toHaveValue('test-client-key'); }); @@ -559,9 +568,9 @@ describe('DestinationCreate', () => { const contentTypeAutocomplete = screen.getByLabelText('Content Type'); expect(contentTypeAutocomplete).toHaveValue(''); - await userEvent.click(contentTypeAutocomplete); + await user.click(contentTypeAutocomplete); const jsonOption = await screen.findByText('application/json'); - await userEvent.click(jsonOption); + await user.click(jsonOption); expect(contentTypeAutocomplete).toHaveValue('application/json'); }); @@ -571,11 +580,11 @@ describe('DestinationCreate', () => { const contentTypeAutocomplete = screen.getByLabelText('Content Type'); - await userEvent.click(contentTypeAutocomplete); + await user.click(contentTypeAutocomplete); const jsonUtf8Option = await screen.findByText( 'application/json; charset=utf-8' ); - await userEvent.click(jsonUtf8Option); + await user.click(jsonUtf8Option); expect(contentTypeAutocomplete).toHaveValue( 'application/json; charset=utf-8' @@ -589,7 +598,7 @@ describe('DestinationCreate', () => { const addCustomHeaderButton = screen.getByRole('button', { name: addCustomHeaderButtonText, }); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); const headerNameInput = screen.getByLabelText('Name'); expect(headerNameInput).toBeInTheDocument(); @@ -597,10 +606,10 @@ describe('DestinationCreate', () => { const headerValueInput = screen.getByLabelText('Value'); expect(headerValueInput).toBeInTheDocument(); - await userEvent.type(headerNameInput, 'X-Custom-Header'); + await user.type(headerNameInput, 'X-Custom-Header'); expect(headerNameInput).toHaveValue('X-Custom-Header'); - await userEvent.type(headerValueInput, 'custom-value'); + await user.type(headerValueInput, 'custom-value'); expect(headerValueInput).toHaveValue('custom-value'); }); @@ -610,12 +619,12 @@ describe('DestinationCreate', () => { const addCustomHeaderButton = screen.getByRole('button', { name: addCustomHeaderButtonText, }); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); screen.getByText('Custom Header 1'); const headerNameInput = screen.getByLabelText('Name'); - await userEvent.type(headerNameInput, 'Authorization'); + await user.type(headerNameInput, 'Authorization'); expect( screen.queryByText('Custom Header 1') @@ -629,13 +638,13 @@ describe('DestinationCreate', () => { const addCustomHeaderButton = screen.getByRole('button', { name: addCustomHeaderButtonText, }); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); const headerNameInput = screen.getByLabelText('Name'); expect(headerNameInput).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: '' }); - await userEvent.click(closeButton); + await user.click(closeButton); expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); }); @@ -647,10 +656,10 @@ describe('DestinationCreate', () => { name: addCustomHeaderButtonText, }); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); screen.getByText('Custom Header 1'); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); expect(screen.getByText('Custom Header 2')).toBeInTheDocument(); }); }); @@ -660,14 +669,14 @@ describe('DestinationCreate', () => { const fillOutCustomHttpsForm = async () => { const destinationTypeAutocomplete = screen.getByLabelText('Destination Type'); - await userEvent.click(destinationTypeAutocomplete); + await user.click(destinationTypeAutocomplete); const customHttpsOption = await screen.findByText('Custom HTTPS'); - await userEvent.click(customHttpsOption); + await user.click(customHttpsOption); const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test'); + await user.type(destinationNameInput, 'Test'); const endpointUrlInput = screen.getByLabelText('Endpoint URL'); - await userEvent.type(endpointUrlInput, 'https://test-endpoint.com'); + await user.type(endpointUrlInput, 'https://test-endpoint.com'); }; describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { @@ -700,14 +709,14 @@ describe('DestinationCreate', () => { await fillOutCustomHttpsForm(); expect(createDestinationButton).toBeDisabled(); - await userEvent.click(testConnectionButton); + await user.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); await waitFor(() => { expect(createDestinationButton).toBeEnabled(); }); - await userEvent.click(createDestinationButton); + await user.click(createDestinationButton); expect(createDestinationSpy).toHaveBeenCalled(); }); }); @@ -737,7 +746,7 @@ describe('DestinationCreate', () => { await fillOutCustomHttpsForm(); expect(createDestinationButton).toBeDisabled(); - await userEvent.click(testConnectionButton); + await user.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); expect(createDestinationButton).toBeDisabled(); }); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx index bceccd99968..14fbae6a67d 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx @@ -69,15 +69,17 @@ export const DestinationCreate = () => { ); }) .catch((errors) => { + let errorMessage = `There was an issue creating your destination`; for (const error of errors) { if (error.field) { form.setError(error.field, { message: error.reason }); } else { + errorMessage = error.reason; form.setError('root', { message: error.reason }); } } - return enqueueSnackbar('There was an issue creating your destination', { + return enqueueSnackbar(errorMessage, { variant: 'error', }); }); 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 4e24b0946cd..a8f81bcbe2c 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -114,10 +114,12 @@ describe('DestinationEdit', () => { await waitForElementToBeRemoved(loadingElement); }; - it('should default to "Enter Bucket manually" radio in edit mode', async () => { + it('should default to "Enter Bucket details manually" radio in edit mode', async () => { await renderEditWithMockDestination(); - const manualRadio = screen.getByLabelText('Enter Bucket manually'); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); expect(manualRadio).toBeChecked(); }); @@ -214,14 +216,14 @@ describe('DestinationEdit', () => { }); }); - describe('given Test Connection and Edit Destination buttons', () => { + describe('given Test Connection and Save Changes buttons', () => { const testConnectionButtonText = 'Test Connection'; const saveDestinationButtonText = 'Save Changes'; const editDestinationSpy = vi.fn(); const verifyDestinationSpy = vi.fn(); describe('when Test Connection button clicked and connection verified positively', () => { - it("should enable Edit Destination button and perform proper call when it's clicked", async () => { + it("should enable Save Changes button and perform proper call when it's clicked", async () => { server.use( http.get(`*/monitor/streams/destinations/${destinationId}`, () => { return HttpResponse.json(mockDestination); @@ -267,7 +269,7 @@ describe('DestinationEdit', () => { }); describe('when Test Connection button clicked and connection verified negatively', () => { - it('should not enable Edit Destination button', async () => { + it('should not enable Save Changes button', async () => { server.use( http.get(`*/monitor/streams/destinations/${destinationId}`, () => { return HttpResponse.json(mockDestination); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx index f29decb6f2e..eae84e41e46 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx @@ -25,7 +25,7 @@ import type { DestinationFormType } from 'src/features/Delivery/Shared/types'; export const DestinationEdit = () => { const navigate = useNavigate(); const { destinationId } = useParams({ - from: '/logs/delivery/destinations/$destinationId/edit', + from: '/logs/delivery/destinations/$destinationId/summary', }); const { mutateAsync: updateDestination, isPending: isUpdatingDestination } = useUpdateDestinationMutation(); @@ -37,7 +37,7 @@ export const DestinationEdit = () => { const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { - pathname: '/logs/delivery/destinations/edit', + pathname: '/logs/delivery/destinations/summary', crumbOverrides: [ { label: 'Delivery', @@ -48,7 +48,7 @@ export const DestinationEdit = () => { }, docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', removeCrumbX: [1, 2], - title: `Edit Destination ${destinationId}`, + title: `Destination ${destinationId}`, }; const form = useForm({ @@ -112,30 +112,39 @@ export const DestinationEdit = () => { }); }; + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + ); + } + return ( <> - + - {isLoading && ( - - - - )} - {error && ( - + - )} - {!isLoading && !error && ( - - - - )} + ); }; diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationEditLazyRoute.ts b/packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationEditLazyRoute.ts index 093277b3020..c127cc48a6a 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationEditLazyRoute.ts +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationEditLazyRoute.ts @@ -3,7 +3,7 @@ import { createLazyRoute } from '@tanstack/react-router'; import { DestinationEdit } from 'src/features/Delivery/Destinations/DestinationForm/DestinationEdit'; export const destinationEditLazyRoute = createLazyRoute( - '/logs/delivery/destinations/$destinationId/edit' + '/logs/delivery/destinations/$destinationId/summary' )({ component: DestinationEdit, }); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx index ec5e03ef2b0..a0fa0341b6e 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx @@ -25,7 +25,7 @@ export const DestinationTableRow = React.memo( {destination.label} diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx index 3921315e7f0..7405ba9d2c5 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx @@ -166,7 +166,7 @@ describe('Destinations Landing Table', () => { await clickOnActionMenuItem('Edit'); expect(mockNavigate).toHaveBeenCalledWith({ - to: '/logs/delivery/destinations/1/edit', + to: '/logs/delivery/destinations/1/summary', }); }); }); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx index 4576a0a2207..1aec785beb2 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx @@ -105,7 +105,7 @@ export const DestinationsLanding = () => { } const handleEdit = ({ id }: Destination) => { - navigate({ to: `/logs/delivery/destinations/${id}/edit` }); + navigate({ to: `/logs/delivery/destinations/${id}/summary` }); }; const openDeleteDialog = (destination: Destination) => { diff --git a/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx index 495dc28b776..dbb195eef1a 100644 --- a/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx +++ b/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx @@ -103,6 +103,17 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ Bucket + + Choose how to provide bucket details. Selecting a bucket associated with + your account will auto-fill the required settings. Entering a bucket + manually requires you to provide all connection details. + handleBucketConfigurationChange(value)} sx={{ '&[role="radiogroup"]': { mb: 0 } }} @@ -115,7 +126,7 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ /> } - label="Enter Bucket manually" + label="Enter Bucket details manually" value="bucket_entered_manually" /> @@ -155,6 +166,7 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ ); }} options={buckets} + placeholder="Select a Bucket" textFieldProps={{ inputProps: { 'data-pendo-id': `${pendoPageId}Bucket`, @@ -200,11 +212,12 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ 'data-pendo-id': `${pendoPageId}Host`, }} label="Endpoint" + labelTooltipText="The Object Storage service endpoint associated with your bucket's region" onBlur={field.onBlur} onChange={(value) => { field.onChange(value); }} - placeholder="Endpoint for the destination" + placeholder="https://us-ord-1.linodeobjects.com" value={field.value} /> )} @@ -220,6 +233,7 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ 'data-pendo-id': `${pendoPageId}Access Key ID`, }} label="Access Key ID" + labelTooltipText="The access key identifier used for authentication" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} value={field.value} @@ -237,6 +251,7 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ 'data-pendo-id': `${pendoPageId}Secret Access Key`, }} label="Secret Access Key" + labelTooltipText="The confidential security credential used with Access Key ID to access Object Storage" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} value={field.value} @@ -263,6 +278,7 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ 'data-pendo-id': `${pendoPageId}Log Path Prefix`, }} label="Log Path Prefix" + labelTooltipText="The path prefix used for organizing uploaded objects" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} optional diff --git a/packages/manager/src/features/Delivery/Shared/PathSample.tsx b/packages/manager/src/features/Delivery/Shared/PathSample.tsx index b18e11e85ab..446dec77306 100644 --- a/packages/manager/src/features/Delivery/Shared/PathSample.tsx +++ b/packages/manager/src/features/Delivery/Shared/PathSample.tsx @@ -92,6 +92,7 @@ export const PathSample = (props: PathSampleProps) => { Sample Destination Object Name ({ + useStreamQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useStreamQuery: queryMocks.useStreamQuery, + }; +}); + +const streamId = 123; +const mockDestinations = [ + akamaiObjectStorageDestinationFactory.build({ id: 1 }), +]; +const mockStream = streamFactory.build({ + id: streamId, + label: `Stream ${streamId}`, + destinations: mockDestinations, +}); + +describe('StreamLanding', () => { + const renderComponent = (flags: Partial) => { + renderWithTheme(, { + flags, + initialRoute: '/logs/delivery/streams/$streamId/summary', + }); + }; + + describe('and stream has loaded successfully', () => { + beforeEach(async () => { + queryMocks.useStreamQuery.mockReturnValue({ + data: mockStream, + isLoading: false, + }); + }); + + describe('and metrics are not enabled', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + metricsEnabled: false, + }, + }; + + it('should render the summary and not the metrics tab', async () => { + renderComponent(flags); + + screen.getByText('Summary'); + expect(screen.queryByText('Metrics')).not.toBeInTheDocument(); + }); + }); + + describe('and metrics are enabled', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + metricsEnabled: true, + }, + }; + + it('should render the summary tab and metrics tab', async () => { + renderComponent(flags); + + screen.getByText('Summary'); + expect(screen.queryByText('Metrics')).toBeInTheDocument(); + }); + }); + }); + + describe('and stream is loading', () => { + beforeEach(async () => { + queryMocks.useStreamQuery.mockReturnValue({ + isLoading: true, + }); + }); + + it('should render loading spinner', async () => { + renderComponent({}); + + expect(screen.queryByText('Summary')).not.toBeInTheDocument(); + expect(screen.queryByText('Metrics')).not.toBeInTheDocument(); + + const loadingElement = screen.queryByTestId('circle-progress'); + expect(loadingElement).toBeInTheDocument(); + }); + }); + + describe('and stream request threw error', () => { + const streamErrorMessage = 'Stream not found'; + beforeEach(async () => { + queryMocks.useStreamQuery.mockReturnValue({ + isLoading: false, + error: [{ reason: streamErrorMessage }], + }); + }); + + it('should render error state with message', async () => { + renderComponent({}); + + expect(screen.queryByText('Summary')).not.toBeInTheDocument(); + expect(screen.queryByText('Metrics')).not.toBeInTheDocument(); + + expect(screen.queryByText(streamErrorMessage)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/features/Delivery/Streams/Stream/StreamLanding.tsx b/packages/manager/src/features/Delivery/Streams/Stream/StreamLanding.tsx new file mode 100644 index 00000000000..788deb9b873 --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/Stream/StreamLanding.tsx @@ -0,0 +1,110 @@ +import { useStreamQuery } from '@linode/queries'; +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; +import * as React from 'react'; +import { useMemo } from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils'; +import { StreamMetrics } from 'src/features/Delivery/Streams/Stream/StreamMetrics'; +import { StreamEdit } from 'src/features/Delivery/Streams/StreamForm/StreamEdit'; +import { useTabs } from 'src/hooks/useTabs'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { Tab } from 'src/hooks/useTabs'; + +export const StreamLanding = () => { + const { streamId } = useParams({ + strict: false, + }); + const { isACLPLogsMetricsEnabled } = useIsACLPLogsEnabled(); + + const activeTabs = useMemo(() => { + const result: Tab[] = [ + { + title: 'Summary', + to: `/logs/delivery/streams/$streamId/summary`, + }, + ]; + + if (isACLPLogsMetricsEnabled) { + result.push({ + title: 'Metrics', + to: `/logs/delivery/streams/$streamId/metrics`, + }); + } + + return result; + }, [isACLPLogsMetricsEnabled]); + + const { handleTabChange, tabIndex, tabs } = useTabs(activeTabs); + + const { isLoading: isLoadingStream, error: errorStream } = useStreamQuery( + Number(streamId) + ); + const streamErrorDefaultMessage = + 'There was an error retrieving stream. Please reload and try again.'; + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: `/logs/delivery/streams/${streamId}`, + crumbOverrides: [ + { + label: 'Delivery', + linkTo: '/logs/delivery/streams', + position: 1, + }, + ], + }, + docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', + removeCrumbX: [1, 2], + title: `Stream ${streamId}`, + }; + + if (isLoadingStream) { + return ( + + + + ); + } + + if (errorStream) { + return ( + + ); + } + + return ( + <> + + + + + }> + + + + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Delivery/Streams/Stream/StreamMetrics.tsx b/packages/manager/src/features/Delivery/Streams/Stream/StreamMetrics.tsx new file mode 100644 index 00000000000..8dcec061d64 --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/Stream/StreamMetrics.tsx @@ -0,0 +1,14 @@ +import { useParams } from '@tanstack/react-router'; +import * as React from 'react'; + +import { CloudPulseDashboardWithFilters } from 'src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters'; + +export const StreamMetrics = () => { + const { streamId } = useParams({ + from: '/logs/delivery/streams/$streamId/metrics', + }); + + return ( + + ); +}; diff --git a/packages/manager/src/features/Delivery/Streams/Stream/streamLandingLazyRoute.ts b/packages/manager/src/features/Delivery/Streams/Stream/streamLandingLazyRoute.ts new file mode 100644 index 00000000000..df6a611184f --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/Stream/streamLandingLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { StreamLanding } from 'src/features/Delivery/Streams/Stream/StreamLanding'; + +export const streamLandingLazyRoute = createLazyRoute( + '/logs/delivery/streams/$streamId' +)({ + component: StreamLanding, +}); diff --git a/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx index 97078a82672..8b4c20396ff 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx @@ -17,6 +17,7 @@ interface StreamActionMenuProps extends StreamHandlers { export const StreamActionMenu = (props: StreamActionMenuProps) => { const { stream, onDelete, onDisableOrEnable, onEdit } = props; + const { status, label } = stream; const menuActions: Action[] = [ { @@ -30,9 +31,12 @@ export const StreamActionMenu = (props: StreamActionMenuProps) => { onClick: () => { onDisableOrEnable(stream); }, - title: stream.status === streamStatus.Active ? 'Deactivate' : 'Activate', - pendoId: `Logs Delivery Streams-${stream.status === streamStatus.Active ? 'Deactivate' : 'Activate'}`, - disabled: stream.status === streamStatus.Provisioning, + title: status === streamStatus.Active ? 'Deactivate' : 'Activate', + pendoId: `Logs Delivery Streams-${status === streamStatus.Active ? 'Deactivate' : 'Activate'}`, + disabled: + status === streamStatus.Deactivating || + status === streamStatus.Failed || + status === streamStatus.Provisioning, }, { onClick: () => { @@ -46,7 +50,7 @@ export const StreamActionMenu = (props: StreamActionMenuProps) => { return ( ); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx index ef5b7ace07a..cab0123f0ab 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx @@ -156,7 +156,7 @@ describe('StreamFormClusters', () => { it('should filter clusters by name', async () => { await renderComponentWithoutSelectedClusters(); - const input = screen.getByPlaceholderText('Search'); + const input = screen.getByPlaceholderText('Search for a cluster'); // Type test value inside the search await userEvent.click(input); @@ -169,7 +169,7 @@ describe('StreamFormClusters', () => { it('should filter clusters by region with search input', async () => { await renderComponentWithoutSelectedClusters(); - const input = screen.getByPlaceholderText('Search'); + const input = screen.getByPlaceholderText('Search for a cluster'); // Type test value inside the search await userEvent.click(input); @@ -185,7 +185,7 @@ describe('StreamFormClusters', () => { it('should filter clusters by log generation status with search input', async () => { await renderComponentWithoutSelectedClusters(); - const input = screen.getByPlaceholderText('Search'); + const input = screen.getByPlaceholderText('Search for a cluster'); // Type test value inside the search await userEvent.click(input); @@ -198,7 +198,7 @@ describe('StreamFormClusters', () => { it('should filter clusters by log generation status with autocomplete', async () => { await renderComponentWithoutSelectedClusters(); - const input = screen.getByPlaceholderText('Log Generation'); + const input = screen.getByPlaceholderText('Logging Status'); // Enabled filter option await userEvent.click(input); @@ -488,7 +488,7 @@ describe('StreamFormClusters', () => { it('should only display regions that have clusters and the required capability', async () => { await renderComponentWithoutSelectedClusters(); - const regionSelect = screen.getByPlaceholderText('Select Region'); + const regionSelect = screen.getByPlaceholderText('Select a Region'); await userEvent.click(regionSelect); const regionOptions = await screen.findAllByRole('option'); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx index 808f8c9d9a4..965139aeb76 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -234,12 +234,20 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { ) : ( <> + + Select the LKE clusters that will send audit logs to the configured + destination. Logging must be enabled for a cluster before it can be + selected. To enable logging for a cluster, use the Linode API to{' '} + + update the cluster + {' '} + to set audit_logs_enabled to true. +

- - Select the LKE clusters that will send audit logs to the configured - destination. Logging must be enabled for a cluster before it can be - selected. To enable logging for a cluster, use the Linode API{' '} - - update the cluster - {' '} - to set audit_logs_enabled to true. - { }} label="Search" onSearch={(value) => setSearchText(value)} - placeholder="Search" + placeholder="Search for a cluster" value={searchText} /> @@ -313,7 +313,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { onChange={(_, region) => { setRegionFilter(region?.id ?? ''); }} - placeholder="Select Region" + placeholder="Select a Region" regionFilter="core" regions={visibleRegions ?? []} sx={{ @@ -325,7 +325,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { label="" onChange={(_, option) => setLogGenerationFilter(option?.value)} options={logGenerationOptions} - placeholder="Log Generation" + placeholder="Logging Status" sx={{ width: '160px !important', }} diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx index 5fdfacccc4f..c9a5dca93bc 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx @@ -95,7 +95,7 @@ export const StreamFormClusterTableContent = ({ > Region - Log Generation + Logging Status
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index 232825b95f1..73e6a5f64d2 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -24,6 +24,8 @@ import type { Flags } from 'src/featureFlags'; const loadingTestId = 'circle-progress'; +const user = userEvent.setup({ delay: null }); + const mockDestinations = [ ...akamaiObjectStorageDestinationFactory.buildList(2), ...customHttpsDestinationFactory.buildList(2), @@ -113,9 +115,9 @@ describe('StreamFormDelivery', () => { screen.getByLabelText('Destination Type'); expect(destinationTypeAutocomplete).toBeEnabled(); - await userEvent.click(destinationTypeAutocomplete); + await user.click(destinationTypeAutocomplete); const customHttpsOption = await screen.findByText('Custom HTTPS'); - await userEvent.click(customHttpsOption); + await user.click(customHttpsOption); expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); } @@ -123,17 +125,17 @@ describe('StreamFormDelivery', () => { screen.getByLabelText('Destination Name'); // Open the dropdown - await userEvent.click(destinationNameAutocomplete); + await user.click(destinationNameAutocomplete); // Type in a new destination name - await userEvent.type(destinationNameAutocomplete, 'New test destination'); + await user.type(destinationNameAutocomplete, 'New test destination'); // Select the "Create New test destination" option const createNewTestDestination = await screen.findByText( 'New test destination', { exact: false } ); - await userEvent.click(createNewTestDestination); + await user.click(createNewTestDestination); }; describe('when customHttpsEnabled feature flag is set to false', () => { @@ -200,13 +202,13 @@ describe('StreamFormDelivery', () => { screen.getByLabelText('Destination Name'); // Open the dropdown - await userEvent.click(destinationNameAutocomplete); + await user.click(destinationNameAutocomplete); // Select the "Destination 1" option const firstDestination = await screen.findByText( 'Akamai Object Storage Destination 1' ); - await userEvent.click(firstDestination); + await user.click(firstDestination); expect(destinationNameAutocomplete).toHaveValue( 'Akamai Object Storage Destination 1' @@ -223,7 +225,7 @@ describe('StreamFormDelivery', () => { screen.getByLabelText('Destination Name'); // Move focus away from the dropdown - await userEvent.tab(); + await user.tab(); expect(destinationNameAutocomplete).toHaveValue('New test destination'); }); @@ -240,12 +242,14 @@ describe('StreamFormDelivery', () => { expect(endpointInput).toBeDisabled(); // Switch to manual mode - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); // Now Endpoint should be enabled expect(endpointInput).toBeEnabled(); - await userEvent.type(endpointInput, 'Test'); + await user.type(endpointInput, 'Test'); expect(endpointInput.getAttribute('value')).toEqual('Test'); }); @@ -256,12 +260,14 @@ describe('StreamFormDelivery', () => { ); // Switch to manual mode - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); // Type the test value inside the input const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test'); + await user.type(bucketInput, 'test'); expect(bucketInput.getAttribute('value')).toEqual('test'); }); @@ -274,7 +280,7 @@ describe('StreamFormDelivery', () => { // Type the test value inside the input const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); + await user.type(accessKeyIDInput, 'Test'); expect(accessKeyIDInput.getAttribute('value')).toEqual('Test'); }); @@ -288,7 +294,7 @@ describe('StreamFormDelivery', () => { // Type the test value inside the input const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); + await user.type(secretAccessKeyInput, 'Test'); expect(secretAccessKeyInput.getAttribute('value')).toEqual('Test'); }); @@ -303,7 +309,7 @@ describe('StreamFormDelivery', () => { const logPathPrefixInput = screen.getByLabelText( 'Log Path Prefix (optional)' ); - await userEvent.type(logPathPrefixInput, 'Test'); + await user.type(logPathPrefixInput, 'Test'); expect(logPathPrefixInput.getAttribute('value')).toEqual('Test'); }); @@ -332,14 +338,16 @@ describe('StreamFormDelivery', () => { expect(endpointInput).toBeDisabled(); }); - it('should enable the Endpoint field when "Enter Bucket manually" is selected', async () => { + it('should enable the Endpoint field when "Enter Bucket details manually" is selected', async () => { await renderComponentAndAddNewDestinationName( destinationType.AkamaiObjectStorage, flags ); - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); const endpointInput = screen.getByLabelText('Endpoint'); expect(endpointInput).toBeEnabled(); @@ -352,22 +360,24 @@ describe('StreamFormDelivery', () => { ); // Switch to manual mode and fill in values - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'my-manual-bucket'); + await user.type(bucketInput, 'my-manual-bucket'); expect(bucketInput).toHaveValue('my-manual-bucket'); const endpointInput = screen.getByLabelText('Endpoint'); - await userEvent.type(endpointInput, 'my-endpoint.com'); + await user.type(endpointInput, 'my-endpoint.com'); expect(endpointInput).toHaveValue('my-endpoint.com'); // Switch back to bucket_from_account const bucketFromAccountRadio = screen.getByLabelText( 'Select Bucket associated with the account' ); - await userEvent.click(bucketFromAccountRadio); + await user.click(bucketFromAccountRadio); // Both fields should be cleared const bucketAutocomplete = screen.getByLabelText('Bucket'); @@ -383,10 +393,10 @@ describe('StreamFormDelivery', () => { // Open the Bucket Autocomplete and select a bucket with only hostname const bucketAutocomplete = screen.getByLabelText('Bucket'); - await userEvent.click(bucketAutocomplete); + await user.click(bucketAutocomplete); const bucketOption = await screen.findByText('bucket-with-hostname'); - await userEvent.click(bucketOption); + await user.click(bucketOption); // Bucket should display the selected bucket label await waitFor(() => { @@ -407,12 +417,12 @@ describe('StreamFormDelivery', () => { // Open the Bucket Autocomplete and select a bucket with s3_endpoint const bucketAutocomplete = screen.getByLabelText('Bucket'); - await userEvent.click(bucketAutocomplete); + await user.click(bucketAutocomplete); const bucketOption = await screen.findByText( 'bucket-with-s3-endpoint' ); - await userEvent.click(bucketOption); + await user.click(bucketOption); // Bucket should display the selected bucket label await waitFor(() => { @@ -466,9 +476,9 @@ describe('StreamFormDelivery', () => { expect(destinationTypeAutocomplete).toBeEnabled(); expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); - await userEvent.click(destinationTypeAutocomplete); + await user.click(destinationTypeAutocomplete); const customHttpsOption = await screen.findByText('Custom HTTPS'); - await userEvent.click(customHttpsOption); + await user.click(customHttpsOption); expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); }); @@ -502,13 +512,13 @@ describe('StreamFormDelivery', () => { screen.getByLabelText('Destination Name'); // Open the dropdown - await userEvent.click(destinationNameAutocomplete); + await user.click(destinationNameAutocomplete); // Select the "Custom HTTPS Destination 2" option const customHttpsDestination = await screen.findByText( 'Custom HTTPS Destination 2' ); - await userEvent.click(customHttpsDestination); + await user.click(customHttpsDestination); expect(destinationNameAutocomplete).toHaveValue( 'Custom HTTPS Destination 2' @@ -525,7 +535,7 @@ describe('StreamFormDelivery', () => { screen.getByLabelText('Destination Name'); // Move focus away from the dropdown - await userEvent.tab(); + await user.tab(); expect(destinationNameAutocomplete).toHaveValue('New test destination'); }); @@ -544,11 +554,11 @@ describe('StreamFormDelivery', () => { expect(authenticationAutocomplete).toHaveValue('None'); // Open the dropdown - await userEvent.click(authenticationAutocomplete); + await user.click(authenticationAutocomplete); // Select the "Basic" option const basicAuthentication = await screen.findByText('Basic'); - await userEvent.click(basicAuthentication); + await user.click(basicAuthentication); expect(authenticationAutocomplete).toHaveValue('Basic'); }); @@ -564,15 +574,15 @@ describe('StreamFormDelivery', () => { const authenticationAutocomplete = screen.getByLabelText( 'Authentication Type' ); - await userEvent.click(authenticationAutocomplete); + await user.click(authenticationAutocomplete); const basicAuthentication = await screen.findByText('Basic'); - await userEvent.click(basicAuthentication); + await user.click(basicAuthentication); expect(authenticationAutocomplete).toHaveValue('Basic'); // Type the test value inside the input const usernameInput = screen.getByLabelText('Username'); - await userEvent.type(usernameInput, 'Test'); + await user.type(usernameInput, 'Test'); expect(usernameInput.getAttribute('value')).toEqual('Test'); }); @@ -587,15 +597,15 @@ describe('StreamFormDelivery', () => { const authenticationAutocomplete = screen.getByLabelText( 'Authentication Type' ); - await userEvent.click(authenticationAutocomplete); + await user.click(authenticationAutocomplete); const basicAuthentication = await screen.findByText('Basic'); - await userEvent.click(basicAuthentication); + await user.click(basicAuthentication); expect(authenticationAutocomplete).toHaveValue('Basic'); // Type the test value inside the input const passwordInput = screen.getByLabelText('Password'); - await userEvent.type(passwordInput, 'Test'); + await user.type(passwordInput, 'Test'); expect(passwordInput.getAttribute('value')).toEqual('Test'); }); @@ -609,7 +619,7 @@ describe('StreamFormDelivery', () => { // Type the test value inside the input const endpointUrlInput = screen.getByLabelText('Endpoint URL'); - await userEvent.type(endpointUrlInput, 'Test'); + await user.type(endpointUrlInput, 'Test'); expect(endpointUrlInput.getAttribute('value')).toEqual('Test'); }); @@ -622,7 +632,7 @@ describe('StreamFormDelivery', () => { ); const tlsHostnameInput = screen.getByLabelText('TLS Hostname'); - await userEvent.type(tlsHostnameInput, 'test'); + await user.type(tlsHostnameInput, 'test'); expect(tlsHostnameInput).toHaveValue('test'); }); @@ -634,7 +644,7 @@ describe('StreamFormDelivery', () => { ); const caCertificateInput = screen.getByLabelText('CA Certificate'); - await userEvent.type(caCertificateInput, 'test'); + await user.type(caCertificateInput, 'test'); expect(caCertificateInput).toHaveValue('test'); }); @@ -647,7 +657,7 @@ describe('StreamFormDelivery', () => { const clientCertificateInput = screen.getByLabelText('Client Certificate'); - await userEvent.type(clientCertificateInput, 'test'); + await user.type(clientCertificateInput, 'test'); expect(clientCertificateInput).toHaveValue('test'); }); @@ -659,7 +669,7 @@ describe('StreamFormDelivery', () => { ); const clientKeyInput = screen.getByLabelText('Client Private Key'); - await userEvent.type(clientKeyInput, 'test'); + await user.type(clientKeyInput, 'test'); expect(clientKeyInput).toHaveValue('test'); }); @@ -676,9 +686,9 @@ describe('StreamFormDelivery', () => { screen.getByLabelText('Content Type'); expect(contentTypeAutocomplete).toHaveValue(''); - await userEvent.click(contentTypeAutocomplete); + await user.click(contentTypeAutocomplete); const jsonOption = await screen.findByText('application/json'); - await userEvent.click(jsonOption); + await user.click(jsonOption); expect(contentTypeAutocomplete).toHaveValue('application/json'); }); @@ -692,11 +702,11 @@ describe('StreamFormDelivery', () => { const contentTypeAutocomplete = screen.getByLabelText('Content Type'); - await userEvent.click(contentTypeAutocomplete); + await user.click(contentTypeAutocomplete); const jsonUtf8Option = await screen.findByText( 'application/json; charset=utf-8' ); - await userEvent.click(jsonUtf8Option); + await user.click(jsonUtf8Option); expect(contentTypeAutocomplete).toHaveValue( 'application/json; charset=utf-8' @@ -715,7 +725,7 @@ describe('StreamFormDelivery', () => { const addCustomHeaderButton = screen.getByRole('button', { name: addCustomHeaderButtonText, }); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); const headerNameInput = screen.getByLabelText('Name'); expect(headerNameInput).toBeInTheDocument(); @@ -723,10 +733,10 @@ describe('StreamFormDelivery', () => { const headerValueInput = screen.getByLabelText('Value'); expect(headerValueInput).toBeInTheDocument(); - await userEvent.type(headerNameInput, 'X-Custom-Header'); + await user.type(headerNameInput, 'X-Custom-Header'); expect(headerNameInput).toHaveValue('X-Custom-Header'); - await userEvent.type(headerValueInput, 'custom-value'); + await user.type(headerValueInput, 'custom-value'); expect(headerValueInput).toHaveValue('custom-value'); }); @@ -739,13 +749,13 @@ describe('StreamFormDelivery', () => { const addCustomHeaderButton = screen.getByRole('button', { name: addCustomHeaderButtonText, }); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); // Verify default title is shown initially screen.getByText('Custom Header 1'); const headerNameInput = screen.getByLabelText('Name'); - await userEvent.type(headerNameInput, 'Authorization'); + await user.type(headerNameInput, 'Authorization'); // Verify default title is replaced with the typed name expect( @@ -763,13 +773,13 @@ describe('StreamFormDelivery', () => { const addCustomHeaderButton = screen.getByRole('button', { name: addCustomHeaderButtonText, }); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); const headerNameInput = screen.getByLabelText('Name'); expect(headerNameInput).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: '' }); - await userEvent.click(closeButton); + await user.click(closeButton); expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); }); @@ -784,10 +794,10 @@ describe('StreamFormDelivery', () => { name: addCustomHeaderButtonText, }); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); screen.getByText('Custom Header 1'); - await userEvent.click(addCustomHeaderButton); + await user.click(addCustomHeaderButton); expect(screen.getByText('Custom Header 2')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index 0b99c5b24e4..53eeb537c46 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -289,6 +289,8 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { inputProps: { 'data-pendo-id': `Logs Delivery Streams ${capitalizedMode}-Destination Name`, }, + labelTooltipText: + 'Select an existing destination from the list or create a new one by entering a name and clicking Create.', }} value={field.value ? { label: field.value } : null} /> @@ -303,7 +305,7 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { mode={mode} /> )} - {selectedDestinations?.[0] && ( + {findDestination(selectedDestinations?.[0])?.details && ( { mode={mode} /> )} - {selectedDestinations?.[0] && ( + {findDestination(selectedDestinations?.[0])?.details && ( { describe( 'given Test Connection and Create Stream buttons', - { timeout: 10000 }, + { timeout: 30000 }, // Increased for CI stability () => { const testConnectionButtonText = 'Test Connection'; const createStreamButtonText = 'Create Stream'; @@ -56,7 +56,9 @@ describe('StreamCreate', () => { ); await userEvent.click(createNewTestDestination); - const manualRadio = screen.getByLabelText('Enter Bucket manually'); + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); await userEvent.click(manualRadio); const endpointInput = screen.getByLabelText('Endpoint'); @@ -114,7 +116,11 @@ describe('StreamCreate', () => { // Test connection await userEvent.click(testConnectionButton); - expect(verifyDestinationSpy).toHaveBeenCalled(); + + // Wait for async verification to complete + await waitFor(() => { + expect(verifyDestinationSpy).toHaveBeenCalled(); + }); await waitFor(() => { expect(createStreamButton).toBeEnabled(); @@ -123,7 +129,11 @@ describe('StreamCreate', () => { // Create stream await userEvent.click(createStreamButton); - expect(createDestinationSpy).toHaveBeenCalled(); + // Wait for destination creation to complete + await waitFor(() => { + expect(createDestinationSpy).toHaveBeenCalled(); + }); + await waitFor(() => { expect(createStreamSpy).toHaveBeenCalled(); }); 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 84e1926b7c2..ae12000a3ae 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -1,3 +1,4 @@ +import { streamStatus } from '@linode/api-v4'; import { screen, waitFor, @@ -17,6 +18,8 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; +const user = userEvent.setup({ delay: null }); + const loadingTestId = 'circle-progress'; const streamId = 123; const mockDestinations = [ @@ -86,162 +89,178 @@ describe.skip('StreamEdit', () => { expect(screen.getByText('file')).toBeVisible(); }); - describe( - 'given Test Connection and Edit Stream buttons', - { timeout: 10000 }, - () => { - const testConnectionButtonText = 'Test Connection'; - const saveStreamButtonText = 'Save Changes'; - - const fillOutNewDestinationForm = async () => { - const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test destination name'); - const createNewTestDestination = await screen.findByText( - 'Test destination name', - { exact: false } - ); - await userEvent.click(createNewTestDestination); + describe('given Test Connection and Save Changes buttons', () => { + const testConnectionButtonText = 'Test Connection'; + const saveStreamButtonText = 'Save Changes'; + + const fillOutNewDestinationForm = async () => { + const destinationNameInput = screen.getByLabelText('Destination Name'); + await user.clear(destinationNameInput); + await user.type(destinationNameInput, 'Test destination name'); + const createNewTestDestination = await screen.findByText( + 'Test destination name', + { exact: false } + ); + await user.click(createNewTestDestination); + + // Switch to manual bucket entry mode + const manualRadio = screen.getByLabelText( + 'Enter Bucket details manually' + ); + await user.click(manualRadio); + + const endpointInput = screen.getByLabelText('Endpoint'); + await waitFor(() => { + expect(endpointInput).toBeDefined(); + }); + await user.type(endpointInput, 'test'); + const bucketInput = screen.getByLabelText('Bucket'); + await user.type(bucketInput, 'test'); + const accessKeyIDInput = screen.getByLabelText('Access Key ID'); + await user.type(accessKeyIDInput, 'Test'); + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await user.type(secretAccessKeyInput, 'Test'); + const logPathPrefixInput = screen.getByLabelText( + 'Log Path Prefix (optional)' + ); + await user.type(logPathPrefixInput, 'Test'); + }; + + describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { + describe('and creating new destination', () => { + const editStreamSpy = vi.fn(); + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); - // Switch to manual bucket entry mode - const manualRadio = screen.getByLabelText('Enter Bucket manually'); - await userEvent.click(manualRadio); + it("should enable Save Changes button and perform proper calls when it's clicked", async () => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json(mockDestinations[0]); + }), + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }), + http.put(`*/monitor/streams/${streamId}`, () => { + editStreamSpy(); + return HttpResponse.json({}); + }) + ); - const endpointInput = screen.getByLabelText('Endpoint'); - await waitFor(() => { - expect(endpointInput).toBeDefined(); - }); - await userEvent.type(endpointInput, 'test'); - const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test'); - const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); - const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); - const logPathPrefixInput = screen.getByLabelText( - 'Log Path Prefix (optional)' - ); - await userEvent.type(logPathPrefixInput, 'Test'); - }; - - describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { - describe('and creating new destination', () => { - const editStreamSpy = vi.fn(); - const createDestinationSpy = vi.fn(); - const verifyDestinationSpy = vi.fn(); - - it("should enable Edit Stream button and perform proper calls when it's clicked", async () => { - server.use( - http.get('*/monitor/streams/destinations', () => { - return HttpResponse.json(makeResourcePage(mockDestinations)); - }), - http.post('*/monitor/streams/destinations/verify', () => { - verifyDestinationSpy(); - return HttpResponse.json({}); - }), - http.post('*/monitor/streams/destinations', () => { - createDestinationSpy(); - return HttpResponse.json(mockDestinations[0]); - }), - http.get(`*/monitor/streams/${streamId}`, () => { - return HttpResponse.json(mockStream); - }), - http.put(`*/monitor/streams/${streamId}`, () => { - editStreamSpy(); - return HttpResponse.json({}); - }) - ); - - renderWithThemeAndHookFormContext({ - component: , - }); + renderWithThemeAndHookFormContext({ + component: , + }); - const loadingElement = screen.queryByTestId(loadingTestId); - await waitForElementToBeRemoved(loadingElement); - await fillOutNewDestinationForm(); + const loadingElement = screen.queryByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + await fillOutNewDestinationForm(); - const testConnectionButton = screen.getByRole('button', { - name: testConnectionButtonText, - }); - const saveStreamButton = screen.getByRole('button', { - name: saveStreamButtonText, - }); - expect(saveStreamButton).toBeDisabled(); + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const saveStreamButton = screen.getByRole('button', { + name: saveStreamButtonText, + }); + expect(saveStreamButton).toBeDisabled(); - // Test connection - await userEvent.click(testConnectionButton); + // Test connection + await user.click(testConnectionButton); + + // Wait for async verification to complete + await waitFor(() => { expect(verifyDestinationSpy).toHaveBeenCalled(); + }); - await waitFor(() => { - expect(saveStreamButton).toBeEnabled(); - }); + await waitFor(() => { + expect(saveStreamButton).toBeEnabled(); + }); - // Edit stream - await userEvent.click(saveStreamButton); + // Edit stream + await user.click(saveStreamButton); + // Wait for destination creation to complete + await waitFor(() => { expect(createDestinationSpy).toHaveBeenCalled(); - await waitFor(() => { - expect(editStreamSpy).toHaveBeenCalled(); - }); + }); + + await waitFor(() => { + expect(editStreamSpy).toHaveBeenCalled(); }); }); + }); - describe('and selected existing destination', () => { - const editStreamSpy = vi.fn(); - const createDestinationSpy = vi.fn(); - - it("should enable Edit Stream button and perform proper calls when it's clicked", async () => { - server.use( - http.get('*/monitor/streams/destinations', () => { - return HttpResponse.json(makeResourcePage(mockDestinations)); - }), - http.post('*/monitor/streams/destinations', () => { - createDestinationSpy(); - return HttpResponse.json(mockDestinations[0]); - }), - http.get(`*/monitor/streams/${streamId}`, () => { - return HttpResponse.json(mockStream); - }), - http.put(`*/monitor/streams/${streamId}`, () => { - editStreamSpy(); - return HttpResponse.json({}); - }) - ); - - renderWithThemeAndHookFormContext({ - component: , - }); - const loadingElement = screen.queryByTestId(loadingTestId); - await waitForElementToBeRemoved(loadingElement); + describe('and selected existing destination', () => { + const editStreamSpy = vi.fn(); + const createDestinationSpy = vi.fn(); - // Change name and leave existing destination - const streamNameInput = screen.getByLabelText('Stream Name'); - await userEvent.type(streamNameInput, 'Test'); + it("should enable Save Changes button and perform proper calls when it's clicked", async () => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json(mockDestinations[0]); + }), + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }), + http.put(`*/monitor/streams/${streamId}`, () => { + editStreamSpy(); + return HttpResponse.json({}); + }) + ); - const testConnectionButton = screen.getByRole('button', { - name: testConnectionButtonText, - }); - const editStreamButton = screen.getByRole('button', { - name: saveStreamButtonText, - }); + renderWithThemeAndHookFormContext({ + component: , + }); + const loadingElement = screen.queryByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + // Change name and leave existing destination + const streamNameInput = screen.getByLabelText('Stream Name'); + await user.type(streamNameInput, 'Test'); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const editStreamButton = screen.getByRole('button', { + name: saveStreamButtonText, + }); - // Edit stream button should not be disabled with existing destination selected - expect(editStreamButton).toBeEnabled(); + // Save Changes button should not be disabled with existing destination selected + expect(editStreamButton).toBeEnabled(); - // Test connection should be disabled when using existing destination - expect(testConnectionButton).toBeDisabled(); + // Test connection should be disabled when using existing destination + expect(testConnectionButton).toBeDisabled(); - // Edit stream - await userEvent.click(editStreamButton); + // Edit stream + await user.click(editStreamButton); - // New destination should not be created with existing destination selected - expect(createDestinationSpy).not.toHaveBeenCalled(); - await waitFor(() => { - expect(editStreamSpy).toHaveBeenCalled(); - }); + // New destination should not be created with existing destination selected + expect(createDestinationSpy).not.toHaveBeenCalled(); + await waitFor(() => { + expect(editStreamSpy).toHaveBeenCalled(); }); + }); - describe('and stream has status: provisioning', () => { - it('should have disabled Edit Stream button and show info tooltip', async () => { + const blockingStatuses = [ + streamStatus.Deactivating, + streamStatus.Failed, + streamStatus.Provisioning, + ]; + + describe.each(blockingStatuses)( + 'and stream has status: %status', + (status) => { + it('should have disabled Save Changes button and show info tooltip', async () => { server.use( http.get('*/monitor/streams/destinations', () => { return HttpResponse.json(makeResourcePage(mockDestinations)); @@ -249,7 +268,7 @@ describe.skip('StreamEdit', () => { http.get(`*/monitor/streams/${streamId}`, () => { return HttpResponse.json({ ...mockStream, - status: 'provisioning', + status, }); }) ); @@ -264,66 +283,75 @@ describe.skip('StreamEdit', () => { name: saveStreamButtonText, }); - // Edit stream button should be disabled + // Save Changes button should be disabled expect(editStreamButton).toBeDisabled(); // Edit stream - await userEvent.hover(editStreamButton); + await user.hover(editStreamButton); + await screen.findByRole('tooltip'); - await waitFor(() => { - expect(screen.getByRole('tooltip')).toBeInTheDocument(); - }); + screen.getByText((content) => + content.includes( + `You cannot save changes while the stream status is ${status}` + ) + ); - const disabledButtonTooltip = screen.getByText( - 'You cannot save changes while the stream is provisioning.' + const disabledButtonTooltip = screen.getByText((content) => + content.includes( + `You cannot save changes while the stream status is ${status}` + ) ); expect(disabledButtonTooltip).toBeInTheDocument(); }); - }); - }); + } + ); }); + }); - describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { - const verifyDestinationSpy = vi.fn(); - - it('should not enable Edit Stream button', async () => { - server.use( - http.get('*/monitor/streams/destinations', () => { - return HttpResponse.json(makeResourcePage(mockDestinations)); - }), - http.post('*/monitor/streams/destinations/verify', () => { - verifyDestinationSpy(); - return HttpResponse.error(); - }), - http.get(`*/monitor/streams/${streamId}`, () => { - return HttpResponse.json(mockStream); - }) - ); + describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { + const verifyDestinationSpy = vi.fn(); + + it('should not enable Save Changes button', async () => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.error(); + }), + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }) + ); - renderWithThemeAndHookFormContext({ - component: , - }); - const loadingElement = screen.queryByTestId(loadingTestId); - await waitForElementToBeRemoved(loadingElement); + renderWithThemeAndHookFormContext({ + component: , + }); + const loadingElement = screen.queryByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); - const testConnectionButton = screen.getByRole('button', { - name: testConnectionButtonText, - }); - const saveStreamButton = screen.getByRole('button', { - name: saveStreamButtonText, - }); + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const saveStreamButton = screen.getByRole('button', { + name: saveStreamButtonText, + }); - await fillOutNewDestinationForm(); + await fillOutNewDestinationForm(); - expect(saveStreamButton).toBeDisabled(); + expect(saveStreamButton).toBeDisabled(); - await userEvent.click(testConnectionButton); + await user.click(testConnectionButton); + // Wait for async verification to complete + await waitFor(() => { expect(verifyDestinationSpy).toHaveBeenCalled(); - expect(saveStreamButton).toBeDisabled(); }); + + expect(saveStreamButton).toBeDisabled(); }); - } - ); + }); + }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx index 24b74bf553d..f703269b37c 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx @@ -9,46 +9,22 @@ import * as React from 'react'; import { useEffect } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { - LandingHeader, - type LandingHeaderProps, -} from 'src/components/LandingHeader'; -import { CloudPulseDashboardWithFilters } from 'src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters'; import { StreamForm } from 'src/features/Delivery/Streams/StreamForm/StreamForm'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; export const StreamEdit = () => { const { streamId } = useParams({ - from: '/logs/delivery/streams/$streamId/edit', + from: '/logs/delivery/streams/$streamId/summary', }); const { data: destinations, isLoading: isLoadingDestinations, error: errorDestinations, } = useAllDestinationsQuery(); - const { - data: stream, - isLoading: isLoadingStream, - error: errorStream, - } = useStreamQuery(Number(streamId)); - - const landingHeaderProps: LandingHeaderProps = { - breadcrumbProps: { - pathname: '/logs/delivery/streams/edit', - crumbOverrides: [ - { - label: 'Delivery', - linkTo: '/logs/delivery/streams', - position: 1, - }, - ], - }, - docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', - removeCrumbX: [1, 2], - title: `Edit Stream ${streamId}`, - }; + const { data: stream, isLoading: isLoadingStream } = useStreamQuery( + Number(streamId) + ); const form = useForm({ defaultValues: { @@ -108,40 +84,22 @@ export const StreamEdit = () => { return ( <> - - {(isLoadingStream || isLoadingDestinations) && ( )} - {errorStream && ( - - )} {errorDestinations && ( )} - {!isLoadingStream && - !isLoadingDestinations && - !errorStream && - !errorDestinations && ( - <> - - - - - - - )} + {!isLoadingStream && !isLoadingDestinations && !errorDestinations && ( + + + + )} ); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx index b0eb935c4f3..c046a35c226 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx @@ -77,12 +77,23 @@ export const StreamForm = (props: StreamFormProps) => { control, name: 'stream.status', }); + + const isStreamStatusBlocking = + !!selectedStreamStatus && + ( + [ + streamStatus.Provisioning, + streamStatus.Deactivating, + streamStatus.Failed, + ] as StreamStatus[] + ).includes(selectedStreamStatus); + const submitButtonTooltip = useMemo( () => - selectedStreamStatus === streamStatus.Provisioning - ? 'You cannot save changes while the stream is provisioning.' + isStreamStatusBlocking + ? `You cannot save changes while the stream status is ${selectedStreamStatus}` : undefined, - [selectedStreamStatus] + [isStreamStatusBlocking, selectedStreamStatus] ); useEffect(() => { @@ -119,15 +130,17 @@ export const StreamForm = (props: StreamFormProps) => { ); form.setValue('stream.destinations', [id]); } catch (errors) { + let errorMessage = `There was an issue creating your destination`; for (const error of errors) { if (error.field) { form.setError(error.field, { message: error.reason }); } else { + errorMessage = error.reason; form.setError('root', { message: error.reason }); } } - enqueueSnackbar('There was an issue creating your destination', { + enqueueSnackbar(errorMessage, { variant: 'error', }); return; @@ -166,20 +179,19 @@ export const StreamForm = (props: StreamFormProps) => { navigate({ to: '/logs/delivery/streams' }); } catch (errors) { + let errorMessage = `There was an issue ${mode === 'create' ? 'creating' : 'editing'} your stream`; for (const error of errors) { if (error.field) { form.setError(error.field, { message: error.reason }); } else { + errorMessage = error.reason; form.setError('root', { message: error.reason }); } } - enqueueSnackbar( - `There was an issue ${mode === 'create' ? 'creating' : 'editing'} your stream`, - { - variant: 'error', - } - ); + enqueueSnackbar(errorMessage, { + variant: 'error', + }); } }; @@ -211,8 +223,7 @@ export const StreamForm = (props: StreamFormProps) => { { const capitalizedMode = capitalize(mode); const description = { audit_logs: - 'Audit logs record state-changing operations on cloud resources and authentication events, delivered in CloudEvents JSON format.', + 'Audit logs record state-changing operations on cloud resources and authentication events, and are delivered in CloudEvents JSON format.', lke_audit_logs: - 'Kubernetes API server audit logs capture state-changing operations on LKE-E cluster resources.', + 'Kubernetes API server audit logs capture state-changing operations on LKE-E cluster resources, and are delivered in native Kubernetes audit format.', }; const pendoIds = { audit_logs: `Logs Delivery Streams ${capitalizedMode}-Audit Logs`, diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/streamEditLazyRoute.ts b/packages/manager/src/features/Delivery/Streams/StreamForm/streamEditLazyRoute.ts deleted file mode 100644 index 818107451b4..00000000000 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/streamEditLazyRoute.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createLazyRoute } from '@tanstack/react-router'; - -import { StreamEdit } from 'src/features/Delivery/Streams/StreamForm/StreamEdit'; - -export const streamEditLazyRoute = createLazyRoute( - '/logs/delivery/streams/$streamId/edit' -)({ - component: StreamEdit, -}); diff --git a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx index 44b92c1fddc..f096349036d 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx @@ -23,16 +23,20 @@ interface StreamTableRowProps extends StreamHandlers { export const StreamTableRow = React.memo((props: StreamTableRowProps) => { const { stream, onDelete, onDisableOrEnable, onEdit } = props; const { id, status } = stream; - const iconStatus = ( - ['active', 'error', 'inactive'].includes(status) ? status : 'other' - ) as Status; + const iconStatus = ((): Status => { + if (status === 'failed') return 'error'; + if (['active', 'error', 'inactive'].includes(status)) { + return status as Status; + } + return 'other'; + })(); return ( {stream.label} @@ -72,6 +76,10 @@ const humanizeStreamStatus = (status: StreamStatus) => { switch (status) { case 'active': return 'Active'; + case 'deactivating': + return 'Deactivating'; + case 'failed': + return 'Failed'; case 'inactive': return 'Inactive'; case 'provisioning': diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx index 30f2a794027..b0151d047f5 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx @@ -172,7 +172,7 @@ describe('Streams Landing Table', () => { await clickOnActionMenuItem('Edit'); expect(mockNavigate).toHaveBeenCalledWith({ - to: '/logs/delivery/streams/1/edit', + to: '/logs/delivery/streams/1/summary', }); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx index 308e96def73..1c5ea913159 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx @@ -125,7 +125,7 @@ export const StreamsLanding = () => { } const handleEdit = ({ id }: Stream) => { - navigate({ to: `/logs/delivery/streams/${id}/edit` }); + navigate({ to: `/logs/delivery/streams/${id}/summary` }); }; const openDeleteDialog = (stream: Stream) => { diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts index 91c0be442dd..f08f8106708 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.ts @@ -39,6 +39,7 @@ export const useIsACLPLogsEnabled = (): { isACLPLogsBeta: boolean; isACLPLogsCustomHttpsEnabled: boolean; isACLPLogsEnabled: boolean; + isACLPLogsMetricsEnabled: boolean; isACLPLogsNew: boolean; } => { const { data: account } = useAccount(); @@ -55,6 +56,7 @@ export const useIsACLPLogsEnabled = (): { return { isACLPLogsBeta: !!flags.aclpLogs?.beta, isACLPLogsCustomHttpsEnabled: !!flags.aclpLogs?.customHttpsEnabled, + isACLPLogsMetricsEnabled: !!flags.aclpLogs?.metricsEnabled, isACLPLogsNew: !!flags.aclpLogs?.new, isACLPLogsEnabled, }; diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index ffb3b5d5d98..29c6fd4e0bc 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -1,4 +1,4 @@ -import { Chip, NewFeatureChip, styled } from '@linode/ui'; +import { NewFeatureChip } from '@linode/ui'; import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -20,8 +20,7 @@ import { IAM_DOCS_LINK, ROLES_LEARN_MORE_LINK } from './Shared/constants'; export const IdentityAccessLanding = React.memo(() => { const flags = useFlags(); const { isIAMEnabled } = useIsIAMEnabled(); - const showLimitedAvailabilityBadges = - flags.iamLimitedAvailabilityBadges && isIAMEnabled; + const showNewBadge = flags.iamNewBadge && isIAMEnabled; const location = useLocation(); const navigate = useNavigate(); const { isParentUserType } = useDelegationRole(); @@ -62,12 +61,7 @@ export const IdentityAccessLanding = React.memo(() => { {...landingHeaderProps} breadcrumbProps={{ labelOptions: { - suffixComponent: showLimitedAvailabilityBadges ? ( - <> - - - - ) : null, + suffixComponent: showNewBadge ? : null, }, removeCrumbX: 1, }} @@ -84,20 +78,3 @@ export const IdentityAccessLanding = React.memo(() => { ); }); - -const StyledLimitedAvailabilityChip = styled(Chip, { - label: 'StyledLimitedAvailabilityChip', - shouldForwardProp: (prop) => prop !== 'color', -})(({ theme }) => ({ - '& .MuiChip-label': { - padding: 0, - }, - background: theme.tokens.component.Badge.Informative.Subtle.Background, - color: theme.tokens.component.Badge.Informative.Subtle.Text, - font: theme.font.bold, - fontSize: '12px', - lineHeight: '12px', - height: 16, - letterSpacing: '.22px', - padding: theme.spacingFunction(4), -})); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx index 792dcc47ac4..12565aa714c 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx @@ -18,8 +18,7 @@ export const DefaultsLanding = () => { const navigate = useNavigate(); const flags = useFlags(); const { isIAMEnabled } = useIsIAMEnabled(); - const showLimitedAvailabilityBadges = - flags.iamLimitedAvailabilityBadges && isIAMEnabled; + const showNewBadge = flags.iamNewBadge && isIAMEnabled; const { tabs, tabIndex, handleTabChange } = useTabs([ { @@ -46,7 +45,7 @@ export const DefaultsLanding = () => { label: ( <> {IAM_LABEL} - {showLimitedAvailabilityBadges ? ( + {showNewBadge ? ( ) : null} diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index d16de2aa1a9..6248e4895d6 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -1,10 +1,4 @@ -import { Button, Hidden, Select, Typography } from '@linode/ui'; -import { capitalizeAllWords } from '@linode/utilities'; -import { useTheme } from '@mui/material'; -import Grid from '@mui/material/Grid'; -import Paper from '@mui/material/Paper'; -import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'; -import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { Pagination } from '@akamai/cds-components/react/Pagination'; import { sortRows, Table, @@ -14,7 +8,13 @@ import { TableHeaderCell, TableRow, TableRowExpanded, -} from 'akamai-cds-react-components/Table'; +} from '@akamai/cds-components/react/Table'; +import { Button, Hidden, Select, Typography } from '@linode/ui'; +import { capitalizeAllWords } from '@linode/utilities'; +import { useTheme } from '@mui/material'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'; import React, { useState } from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; @@ -37,8 +37,8 @@ import { } from '../../Shared/constants'; import type { RoleView } from '../../Shared/types'; +import type { Order } from '@akamai/cds-components/react/Table'; import type { SelectOption } from '@linode/ui'; -import type { Order } from 'akamai-cds-react-components/Table'; const ALL_ROLES_OPTION: SelectOption = { label: 'All Roles', @@ -247,7 +247,7 @@ export const RolesTable = ({ roles = [] }: Props) => { selected={areAllSelected} > handleSort(event, 'name')} + onSort={(event) => handleSort(event, 'name')} sortable sorted={sort?.column === 'name' ? sort.order : undefined} style={{ @@ -259,7 +259,7 @@ export const RolesTable = ({ roles = [] }: Props) => { handleSort(event, 'access')} + onSort={(event) => handleSort(event, 'access')} sortable sorted={sort?.column === 'access' ? sort.order : undefined} style={{ diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index c91e73df2d1..8ce17956649 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -75,7 +75,6 @@ export const ERROR_STATE_TEXT = // Delegation error messages export const NO_ITEMS_TO_DISPLAY_TEXT = 'No items to display.'; -export const NO_DELEGATED_USERS_TEXT = 'No users added.'; // Links export const IAM_DOCS_LINK = diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.tsx index 8716aa4bdb8..d9f37fe1428 100644 --- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.tsx +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.tsx @@ -20,7 +20,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; -import { NO_DELEGATED_USERS_TEXT } from 'src/features/IAM/Shared/constants'; +import { NO_ITEMS_TO_DISPLAY_TEXT } from 'src/features/IAM/Shared/constants'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; @@ -130,7 +130,7 @@ export const UserDelegationsTable = () => { {childAccounts?.data.length === 0 && ( - + )} {childAccounts?.data?.map((childAccount) => ( diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index efeecd12a4b..27b5c804ed6 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -26,8 +26,7 @@ export const UserDetailsLanding = () => { const flags = useFlags(); const theme = useTheme(); const { isIAMEnabled } = useIsIAMEnabled(); - const showLimitedAvailabilityBadges = - flags.iamLimitedAvailabilityBadges && isIAMEnabled; + const showNewBadge = flags.iamNewBadge && isIAMEnabled; const { username } = useParams({ from: '/iam/users/$username' }); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { isParentUserType } = useDelegationRole(); @@ -68,7 +67,7 @@ export const UserDetailsLanding = () => { label: ( <> {IAM_LABEL} - {showLimitedAvailabilityBadges ? ( + {showNewBadge ? ( void; onManageRegions?: (image: Image) => void; onRebuild?: (image: Image) => void; + onView?: (image: Image) => void; } interface Props { @@ -32,7 +33,8 @@ export const ImagesActionMenu = (props: Props) => { const [isOpen, setIsOpen] = React.useState(false); - const { onDelete, onDeploy, onEdit, onManageRegions, onRebuild } = handlers; + const { onDelete, onDeploy, onEdit, onManageRegions, onRebuild, onView } = + handlers; const { data: imagePermissions, isLoading: isImagePermissionsLoading } = usePermissions( @@ -81,7 +83,7 @@ export const ImagesActionMenu = (props: Props) => { return [ { title: 'View Image Details', - onClick: () => null, + onClick: () => onView?.(image), pendoId: pendoIDs?.actionMenu.viewImageDetails, }, { ...deployAction }, diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx index 796786111e8..229f29ec3b1 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx @@ -15,8 +15,10 @@ import { DeleteImageDialog } from '../../DeleteImageDialog'; import { EditImageDrawer } from '../../EditImageDrawer'; import { ManageImageReplicasForm } from '../../ImageRegions/ManageImageRegionsForm'; import { RebuildImageDrawer } from '../../RebuildImageDrawer'; +import { VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS } from '../constants'; import { imageLibrarySubTabs as subTabs } from './imageLibraryTabsConfig'; import { ImagesView } from './ImagesView'; +import { ViewImageDrawer } from './ViewImageDrawer'; import type { Handlers as ImageHandlers } from '../../ImagesActionMenu'; import type { Image } from '@linode/api-v4'; @@ -58,6 +60,10 @@ export const ImageLibraryTabs = () => { }); }; + const handleView = (image: Image) => { + actionHandler(image, 'view'); + }; + const handleEdit = (image: Image) => { actionHandler(image, 'edit'); }; @@ -106,6 +112,7 @@ export const ImageLibraryTabs = () => { onEdit: handleEdit, onManageRegions: handleManageRegions, onRebuild: handleRebuild, + onView: handleView, }; const subTabIndex = getSubTabIndex(subTabs, imageTypeParams?.imageType); @@ -149,6 +156,15 @@ export const ImageLibraryTabs = () => { + ({ + font: theme.font.bold, +})); + +export const StyledCloudInitIcon = styled(CloudInitIcon, { + label: 'StyledCloudInitIcon', +})(() => ({ + height: 16, + width: 16, +})); + +export const StyledCopyIcon = styled(CopyTooltip)(({ theme }) => ({ + '& svg': { + height: 12, + top: 1, + width: 12, + }, + marginLeft: theme.spacingFunction(4), +})); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ViewImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ViewImageDrawer.test.tsx new file mode 100644 index 00000000000..e91019af1a5 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ViewImageDrawer.test.tsx @@ -0,0 +1,223 @@ +import { regionFactory } from '@linode/utilities'; +import React from 'react'; + +import { imageFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ViewImageDrawer } from './ViewImageDrawer'; + +import type { VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS } from '../constants'; + +const mockRegions = regionFactory.buildList(2, { + id: 'us-east', + label: 'Newark, NJ', + country: 'us', +}); + +const queryMocks = vi.hoisted(() => ({ + useRegionsQuery: vi.fn(), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useRegionsQuery: queryMocks.useRegionsQuery, + }; +}); + +beforeEach(() => { + queryMocks.useRegionsQuery.mockReturnValue({ data: mockRegions }); +}); + +const onClose = vi.fn(); + +const baseImage = imageFactory.build({ + capabilities: ['distributed-sites'], + created: '2024-01-15T00:00:00', + description: 'A test image description', + id: 'private/123', + image_sharing: { + shared_by: { + sharegroup_id: 1, + sharegroup_label: 'my-share-group', + sharegroup_uuid: 'abc-uuid', + source_image_id: 123, + }, + shared_with: null, + }, + label: 'my-test-image', + regions: [{ region: 'us-east', status: 'available' }], + size: 1500, + total_size: 3000, +}); + +const defaultProps = { + image: baseImage, + imageError: null, + isFetching: false, + isSharedImage: true, + onClose, + open: true, + pendoIDs: {} as typeof VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS, +}; + +describe('ViewImageDrawer', () => { + it('renders the drawer title', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('View shared image details')).toBeVisible(); + }); + + it('renders image label', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('my-test-image')).toBeVisible(); + }); + + it('renders the image ID', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('private/123')).toBeVisible(); + }); + + it('renders the share group label', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText(/my-share-group/)).toBeVisible(); + }); + + it('renders original image size and total replica size', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText(/1500 MB/)).toBeVisible(); + expect(getByText(/3000 MB/)).toBeVisible(); + }); + + it('renders created date', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('2024-01-15T00:00:00')).toBeVisible(); + }); + + it('renders Encrypted when image has the distributed-sites capability', () => { + const { getByTestId, queryByText } = renderWithTheme( + + ); + + expect(getByTestId('encrypted-indicator')).toBeVisible(); + expect(queryByText('Not Encrypted')).toBeNull(); + }); + + it('renders Not Encrypted when image lacks the distributed-sites capability', () => { + const image = imageFactory.build({ + ...baseImage, + capabilities: [], + }); + + const { getByTestId, queryByText } = renderWithTheme( + + ); + + expect(getByTestId('not-encrypted-indicator')).toBeVisible(); + expect(queryByText('Encrypted')).toBeNull(); + }); + + it('renders the Cloud-Init metadata notice when image has the cloud-init capability', () => { + const image = imageFactory.build({ + ...baseImage, + capabilities: ['cloud-init'], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Supports Metadata service via Cloud-Init')).toBeVisible(); + }); + + it('does not render the Cloud-Init metadata notice when image lacks the cloud-init capability', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Supports Metadata service via Cloud-Init')).toBeNull(); + }); + + it('renders the description when present', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('A test image description')).toBeVisible(); + }); + + it('does not render the description section when description is absent', () => { + const image = imageFactory.build({ ...baseImage, description: null }); + + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Description')).toBeNull(); + }); + + it('renders the replicated region with flag and label', () => { + queryMocks.useRegionsQuery.mockReturnValue({ + data: [ + regionFactory.build({ + id: 'us-east', + label: 'Newark, NJ', + country: 'us', + }), + ], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Newark, NJ')).toBeVisible(); + }); + + it('renders Unknown for unrecognized region', () => { + queryMocks.useRegionsQuery.mockReturnValue({ data: [] }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Unknown')).toBeVisible(); + }); + + it('calls onClose when the Close button is clicked', async () => { + const { getByTestId } = renderWithTheme( + + ); + + getByTestId('cancel').click(); + + expect(onClose).toHaveBeenCalled(); + }); + + it('renders nothing in the drawer body when no image is provided', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('my-test-image')).toBeNull(); + expect(queryByText('private/123')).toBeNull(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ViewImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ViewImageDrawer.tsx new file mode 100644 index 00000000000..35d31a6739b --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ViewImageDrawer.tsx @@ -0,0 +1,135 @@ +import { useRegionsQuery } from '@linode/queries'; +import { ActionsPanel, Drawer, Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import Lock from 'src/assets/icons/lock.svg'; +import Unlock from 'src/assets/icons/unlock.svg'; +import { Flag } from 'src/components/Flag'; +import { getCountryAndLabelFromImageRegion } from 'src/features/Images/utils'; + +import { + StyledCloudInitIcon, + StyledCopyIcon, + StyledLabel, +} from './ViewImageDrawer.styles'; + +import type { VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS } from '../constants'; +import type { APIError, Image } from '@linode/api-v4'; + +interface Props { + image: Image | undefined; + imageError?: APIError[] | null; + isFetching?: boolean; + isSharedImage?: boolean; + onClose: () => void; + open: boolean; + pendoIDs: typeof VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS; +} + +export const ViewImageDrawer = (props: Props) => { + const { + imageError, + isFetching, + isSharedImage, + onClose, + open, + pendoIDs, + image, + } = props; + + const { data: regions } = useRegionsQuery(); + + return ( + + + + Label: {image?.label} + + + Image ID: {image?.id} + + + {isSharedImage && ( + + Share group:{' '} + {image?.image_sharing?.shared_by?.sharegroup_label} + + )} + + Original image size: {image?.size} MB + + + All replicas: {image?.total_size} MB + + + Created: {image?.created} + + {image?.capabilities?.includes('distributed-sites') ? ( + + + Encrypted + + ) : ( + + + + Not Encrypted + + + )} + {image?.capabilities?.includes('cloud-init') && ( + + + Supports Metadata service via Cloud-Init + + )} + {image?.description && ( + + Description + {image.description} + + )} + Replicated in the following regions:{' '} + {image?.regions.map((region) => { + const countryAndLabelObject = getCountryAndLabelFromImageRegion( + regions ?? [], + region + ); + + const imageCountry = countryAndLabelObject.country ?? 'us'; + const regionLabel = countryAndLabelObject.label ?? 'Unknown'; + + return ( + + + {regionLabel} + + ); + })} + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupRow.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupRow.tsx index 6ac9015479d..6cb475d919e 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupRow.tsx @@ -1,6 +1,7 @@ +import { TableCell, TableRow } from '@akamai/cds-components/react/Table'; import { usePreferences, useProfile } from '@linode/queries'; -import { Hidden, LinkButton } from '@linode/ui'; -import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; +import { Hidden, LinkButton, Tooltip } from '@linode/ui'; +import { truncateEnd } from '@linode/utilities'; import React from 'react'; import { getIsTableStripingEnabled } from 'src/features/Profile/Settings/TableStriping.utils'; @@ -42,18 +43,38 @@ export const ShareGroupRow = (props: Props) => { data-qa-sharegroup-row={id} key={id} rowborder={!isTableStripingEnabled} + style={{ padding: 0 }} zebra={isTableStripingEnabled} > - - {}}>{label} - - {description} - {members_count} + 32 ? label : ''}> + + {}} + sx={{ + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + display: 'block', + }} + > + {truncateEnd(label, 32)} + + + + 50 ? description : ''}> + + {truncateEnd(description, 50)} + + + {members_count} - {images_count} + {images_count} - + {created && formatDate(created, { timezone: profile?.timezone, @@ -61,7 +82,7 @@ export const ShareGroupRow = (props: Props) => { - + {updated !== null ? formatDate(updated, { timezone: profile?.timezone }) : '–'} diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupTable.styles.ts b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupTable.styles.ts index 947a50512ea..e8c96ca2d80 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupTable.styles.ts +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupTable.styles.ts @@ -1,5 +1,5 @@ +import { TableCell } from '@akamai/cds-components/react/Table'; import { styled } from '@mui/material/styles'; -import { TableCell } from 'akamai-cds-react-components/Table'; export const StyledActionMenuWrapper = styled(TableCell, { label: 'StyledActionMenuWrapper', @@ -7,7 +7,7 @@ export const StyledActionMenuWrapper = styled(TableCell, { justifyContent: 'flex-end', display: 'flex', alignItems: 'center', - maxWidth: 40, + maxWidth: '5%', '& button': { padding: 0, color: theme.tokens.alias.Content.Icon.Primary.Default, @@ -18,3 +18,61 @@ export const StyledActionMenuWrapper = styled(TableCell, { color: theme.tokens.alias.Content.Icon.Primary.Hover, }, })); + +const TABLE_CELL_BASE_STYLES: React.CSSProperties = { + boxSizing: 'border-box', +}; + +export const StyledShareGroupsTableContainer = styled('div', { + label: 'StyledShareGroupsTable', +})(({ theme }) => ({ + '& .group-column': { + minWidth: '20%', + ...TABLE_CELL_BASE_STYLES, + [theme.breakpoints.down('sm')]: { + minWidth: '30%', + }, + }, + '& .description-column': { + minWidth: '25%', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + display: 'block', + ...TABLE_CELL_BASE_STYLES, + [theme.breakpoints.down('lg')]: { + minWidth: '40%', + }, + [theme.breakpoints.down('sm')]: { + minWidth: '40%', + }, + }, + '& .membersCount-column': { + minWidth: '11%', + ...TABLE_CELL_BASE_STYLES, + [theme.breakpoints.down('lg')]: { + minWidth: '15%', + }, + }, + '& .imagesCount-column': { + minWidth: '9%', + ...TABLE_CELL_BASE_STYLES, + [theme.breakpoints.down('lg')]: { + minWidth: '15%', + }, + }, + '& .created-column': { + minWidth: '15%', + ...TABLE_CELL_BASE_STYLES, + whiteSpace: 'nowrap', + }, + '& .updated-column': { + minWidth: '15%', + ...TABLE_CELL_BASE_STYLES, + whiteSpace: 'nowrap', + }, + '& .action-column': { + maxWidth: '5%', + ...TABLE_CELL_BASE_STYLES, + }, +})); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.test.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.test.tsx new file mode 100644 index 00000000000..88df9069ac9 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.test.tsx @@ -0,0 +1,143 @@ +import { imageSharegroupFactory } from '@linode/utilities'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ShareGroupsCreate } from './ShareGroupsCreate'; + +const queryMocks = vi.hoisted(() => ({ + useCreateShareGroupMutation: vi.fn().mockReturnValue({}), + useNavigate: vi.fn().mockReturnValue(vi.fn()), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useCreateShareGroupMutation: queryMocks.useCreateShareGroupMutation, + }; +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +describe('ShareGroupsCreate', () => { + const shareGroupLabel = 'My Share Group'; + const shareGroupDescription = 'Test Description'; + + let mockNavigate: ReturnType; + let mockMutateAsync: ReturnType; + + beforeEach(() => { + mockNavigate = vi.fn(); + mockMutateAsync = vi.fn(); + + queryMocks.useNavigate.mockReturnValue(mockNavigate); + queryMocks.useCreateShareGroupMutation.mockReturnValue({ + mutateAsync: mockMutateAsync, + isLoading: false, + error: null, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render the form with all fields, titles, and buttons in their default state', () => { + const { getByRole, getByText } = renderWithTheme(); + + expect(getByText('Share group details')).toBeVisible(); + expect(getByText('Images')).toBeVisible(); + expect(getByText('Selected images (0)')).toBeVisible(); + + expect( + getByText( + 'Add a name and description for your share group. These details are visible to all group members.' + ) + ).toBeVisible(); + + const labelField = getByRole('textbox', { name: /Label/i }); + expect(labelField).toBeVisible(); + expect(labelField).toHaveValue(''); + + const descriptionField = getByRole('textbox', { name: /Description/i }); + expect(descriptionField).toBeVisible(); + expect(descriptionField).toHaveValue(''); + + const submitButton = getByRole('button', { name: /Create Share Group/i }); + expect(submitButton).toBeVisible(); + expect(submitButton).toBeEnabled(); + }); + + it('should submit the form with valid data', async () => { + const shareGroup = imageSharegroupFactory.build(); + + mockMutateAsync.mockResolvedValue(shareGroup); + + const { getByRole } = renderWithTheme(); + + const labelField = getByRole('textbox', { name: /Label/i }); + const descriptionField = getByRole('textbox', { name: /Description/i }); + const submitButton = getByRole('button', { name: /Create Share Group/i }); + + await userEvent.type(labelField, shareGroupLabel); + await userEvent.type(descriptionField, shareGroupDescription); + await userEvent.click(submitButton); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + label: shareGroupLabel, + description: shareGroupDescription, + }); + + expect(mockNavigate).toHaveBeenCalledWith({ + search: expect.any(Function), + to: '/images/share-groups', + }); + }); + + it('should submit the form with only label (description is optional)', async () => { + const shareGroup = imageSharegroupFactory.build(); + + mockMutateAsync.mockResolvedValue(shareGroup); + + const { getByRole } = renderWithTheme(); + + const labelField = getByRole('textbox', { name: /Label/i }); + const submitButton = getByRole('button', { name: /Create Share Group/i }); + + await userEvent.type(labelField, shareGroupLabel); + await userEvent.click(submitButton); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + label: shareGroupLabel, + }); + }); + + it('should display field-specific errors from API', async () => { + const apiError = [ + { + field: 'label', + reason: 'Label must be unique', + }, + ]; + + mockMutateAsync.mockRejectedValue(apiError); + + const { getByRole, getByText } = renderWithTheme(); + + const labelField = getByRole('textbox', { name: /Label/i }); + const submitButton = getByRole('button', { name: /Create Share Group/i }); + + await userEvent.type(labelField, 'Duplicate Label'); + await userEvent.click(submitButton); + + expect(getByText('Label must be unique')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.tsx new file mode 100644 index 00000000000..55bf18aecf0 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.tsx @@ -0,0 +1,157 @@ +import { useCreateShareGroupMutation } from '@linode/queries'; +import { + Box, + Button, + Divider, + Notice, + Paper, + Stack, + TextField, + Typography, +} from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { Controller, useController, useForm } from 'react-hook-form'; + +import { IMAGE_SELECT_TABLE_SHARE_GROUP_CREATE_PENDO_IDS } from 'src/components/ImageSelect/constants'; +import { ImageSelectTable } from 'src/components/ImageSelect/ImageSelectTable'; + +import { CREATE_SHARE_GROUP_PENDO_IDS } from '../../constants'; + +import type { CreateSharegroupPayload, Image } from '@linode/api-v4'; + +export const ShareGroupsCreate = () => { + const navigate = useNavigate(); + + const { mutateAsync: createShareGroup } = useCreateShareGroupMutation(); + + const { + control, + handleSubmit, + setError, + formState: { isSubmitting }, + } = useForm(); + + const { field: imagesController, fieldState } = useController({ + control, + name: 'images', + }); + + const onSubmit = handleSubmit(async (values) => { + try { + await createShareGroup(values); + + navigate({ + search: () => ({}), + to: '/images/share-groups', + }); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }); + + const onChange = (image: Image) => { + const selectedImages = imagesController.value ?? []; + + const { id, label, description } = image; + const imagePayload = { id, label, ...(description && { description }) }; + + if (!selectedImages.some((img) => img.id === id)) { + imagesController.onChange([...selectedImages, imagePayload]); + } else { + imagesController.onChange(selectedImages.filter((img) => img.id !== id)); + } + }; + + return ( +
+ + + Share group details + + Add a name and description for your share group. These details are + visible to all group members. + + ( + + field.onChange( + e.target.value === '' ? undefined : e.target.value + ) + } + value={field.value ?? ''} + /> + )} + /> + ( + + field.onChange( + e.target.value === '' ? undefined : e.target.value + ) + } + rows={1} + value={field.value ?? ''} + /> + )} + /> + + + + Images + img.id) ?? [] + } + selectionMode="multi" + /> + + + + + Selected images ({imagesController.value?.length ?? 0}) + + Selected images is coming soon... + + + + + +
+ ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateContainer.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateContainer.tsx new file mode 100644 index 00000000000..536704009c9 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateContainer.tsx @@ -0,0 +1,26 @@ +import Grid from '@mui/material/Grid'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader/LandingHeader'; + +import { CREATE_SHARE_GROUP_PENDO_IDS } from '../../constants'; +import { ShareGroupsCreate } from './ShareGroupsCreate'; + +export const ShareGroupsCreateContainer = () => { + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateLazyRoute.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateLazyRoute.tsx new file mode 100644 index 00000000000..5cd1ac095fe --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateLazyRoute.tsx @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { ShareGroupsCreateContainer } from './ShareGroupsCreateContainer'; + +export const shareGroupsCreateLazyRoute = createLazyRoute( + '/images/share-groups/create' +)({ + component: ShareGroupsCreateContainer, +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTable.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTable.tsx index 4c24d5aceaf..04d65a01d00 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTable.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTable.tsx @@ -1,3 +1,12 @@ +import { Pagination } from '@akamai/cds-components/react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@akamai/cds-components/react/Table'; import { Box, Button, @@ -7,15 +16,6 @@ import { useTheme, ZeroStateSearchNarrowIcon, } from '@linode/ui'; -import { Pagination } from 'akamai-cds-react-components'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, -} from 'akamai-cds-react-components/Table'; import React from 'react'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; @@ -28,6 +28,7 @@ import { StyledImageTableSubheader, } from '../ImageLibrary/ImagesTable.styles'; import { ShareGroupRow } from './ShareGroupRow'; +import { StyledShareGroupsTableContainer } from './ShareGroupTable.styles'; import type { ShareGroupsViewTableColConfig } from './shareGroupsTabsConfig'; import type { APIError, Sharegroup } from '@linode/api-v4'; @@ -132,93 +133,100 @@ export const ShareGroupsTable = (props: ShareGroupsTableProps) => { )} - - - - {columns.map((col, idx) => { - const cell = col.sortableProps ? ( - - handleOrderChange( - col.sortableProps?.label ?? col.name, - order === 'asc' ? 'desc' : 'asc' - ) - } - sortable - sorted={ - orderBy === col.sortableProps?.label ? order : undefined - } - style={{ ...col.style }} - > - {col.name} - - ) : ( - - {col.name} - - ); + +
+ + + {columns.map((col, idx) => { + const cell = col.sortableProps ? ( + + handleOrderChange( + col.sortableProps?.label ?? col.name, + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={ + orderBy === col.sortableProps?.label ? order : undefined + } + style={{ ...col.style }} + > + {col.name} + + ) : ( + + {col.name} + + ); - return col.hidden ? ( - - {cell} - - ) : ( - cell - ); - })} - - - - - {!error && shareGroups.length === 0 && ( - - - ({ + return col.hidden ? ( + + {cell} + + ) : ( + cell + ); + })} + + + + + {!error && shareGroups.length === 0 && ( + + - - {emptyMessage.main} - {!query && emptyMessage.instruction && ( - - {emptyMessage.instruction} - - )} - - - - )} - {error && query && ( - - - - - - )} + ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: theme.spacingFunction(4), + p: `${theme.spacingFunction(24)} ${theme.spacingFunction(32)}`, + width: '100%', + })} + > + + {emptyMessage.main} + {!query && emptyMessage.instruction && ( + + {emptyMessage.instruction} + + )} + + + + )} + {error && query && ( + + + + + + )} - {shareGroups.map((sharegroup) => ( - - ))} - -
+ {shareGroups.map((sharegroup) => ( + + ))} +
+ + {pagination.count > DEFAULT_PAGE_SIZES[0] && ( ; /* Column name */ name: string; - /* Provide sortableProps to enable sorting for this column. */ sortableProps?: { /* API field used for sorting this column */ @@ -62,29 +64,36 @@ export const shareGroupsSubTabs: ImageSubTab[] = [ ]; const OWNED_GROUPS_TABLE_COLUMNS: ShareGroupsViewTableColConfig[] = [ - { name: 'Group', sortableProps: { label: 'label' } }, + { + name: 'Group', + sortableProps: { label: 'label' }, + className: 'group-column', + }, { name: 'Description', sortableProps: { label: 'description' }, + className: 'description-column', }, { name: '# of members', + className: 'membersCount-column', }, { name: '# of images', hidden: 'smDown', + className: 'imagesCount-column', }, { name: 'Created', sortableProps: { label: 'created' }, hidden: 'lgDown', - style: { whiteSpace: 'nowrap' }, + className: 'created-column', }, { name: 'Updated', sortableProps: { label: 'updated' }, hidden: 'lgDown', - style: { whiteSpace: 'nowrap' }, + className: 'updated-column', }, ]; @@ -129,7 +138,7 @@ export const SHAREGROUPS_CONFIG: Record< }, columns: OWNED_GROUPS_TABLE_COLUMNS, emptyMessage: { - main: 'No Share groups to display', + main: 'No share groups to display', instruction: 'Click \u2018Create Share Group\u2019 to create your first share group and share your custom images with other accounts.', }, diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/constants.ts b/packages/manager/src/features/Images/ImagesLanding/v2/constants.ts index 1d50213647e..4584842f7cb 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/constants.ts +++ b/packages/manager/src/features/Images/ImagesLanding/v2/constants.ts @@ -1 +1,14 @@ export const DEFAULT_PAGE_SIZES = [25, 50, 75, 100]; + +export const CREATE_SHARE_GROUP_PENDO_IDS = { + landingHeader: 'Images Share Groups Create-Landing Header', + label: 'Share Groups Create Images-Label', + description: 'Share Groups Create Images-Description', + createButton: 'Share Groups Create Images-Create Button', +}; +// Shared Image drawer Pendo IDs +export const VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS = { + xButton: 'Images Library Shared View-X button', + closeButton: 'Images Library Shared View-Close', + copyImageIdIcon: 'Images Library Shared View-Copy ID', +}; diff --git a/packages/manager/src/features/Images/utils.test.tsx b/packages/manager/src/features/Images/utils.test.tsx index e7ab420a401..8be3bc05bee 100644 --- a/packages/manager/src/features/Images/utils.test.tsx +++ b/packages/manager/src/features/Images/utils.test.tsx @@ -1,10 +1,11 @@ -import { linodeFactory } from '@linode/utilities'; +import { linodeFactory, regionFactory } from '@linode/utilities'; import { renderHook, waitFor } from '@testing-library/react'; import { eventFactory, imageFactory } from 'src/factories'; import { wrapWithTheme } from 'src/utilities/testHelpers'; import { + getCountryAndLabelFromImageRegion, getEventsForImages, getImageLabelForLinode, getImageTypeToImageLibraryType, @@ -121,6 +122,41 @@ describe('getSubTabIndex', () => { }); }); +describe('getCountryAndLabelFromImageRegion', () => { + it('returns the country and label when a matching region is found', () => { + const regions = regionFactory.buildList(1, { + id: 'us-east', + label: 'Newark, NJ', + country: 'us', + }); + const imageRegion = { region: 'us-east', status: 'available' as const }; + + expect(getCountryAndLabelFromImageRegion(regions, imageRegion)).toEqual({ + country: 'us', + label: 'Newark, NJ', + }); + }); + + it('returns undefined for country and label when no matching region is found', () => { + const regions = regionFactory.buildList(1, { id: 'us-west' }); + const imageRegion = { region: 'us-east', status: 'available' as const }; + + expect(getCountryAndLabelFromImageRegion(regions, imageRegion)).toEqual({ + country: undefined, + label: undefined, + }); + }); + + it('returns undefined for country and label when regions array is empty', () => { + const imageRegion = { region: 'us-east', status: 'available' as const }; + + expect(getCountryAndLabelFromImageRegion([], imageRegion)).toEqual({ + country: undefined, + label: undefined, + }); + }); +}); + describe('getImageTypeToImageLibraryType', () => { it('returns "owned-by-me" when image type is "manual"', () => { expect(getImageTypeToImageLibraryType('manual')).toBe('owned-by-me'); diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index c989aa5c21d..2065ec7d32f 100644 --- a/packages/manager/src/features/Images/utils.ts +++ b/packages/manager/src/features/Images/utils.ts @@ -3,7 +3,7 @@ import { useRegionsQuery } from '@linode/queries'; import { DISALLOWED_IMAGE_REGIONS } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; -import type { Event, Image, Linode } from '@linode/api-v4'; +import type { Event, Image, ImageRegion, Linode, Region } from '@linode/api-v4'; export type ImageLibraryType = | 'owned-by-me' @@ -118,3 +118,15 @@ export const getImageTypeToImageLibraryType = ( return 'shared-with-me'; } }; + +export const getCountryAndLabelFromImageRegion = ( + regions: Region[], + imageRegion: ImageRegion +) => { + const matchingRegion = regions?.find((r) => r.id === imageRegion.region); + + return { + country: matchingRegion?.country, + label: matchingRegion?.label, + }; +}; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx index f6a8b3da7e4..d9bce4daaba 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx @@ -25,7 +25,7 @@ export interface HAControlPlaneProps { export const HACopy = () => ( Recommended for production workloads, a high availability (HA) control plane - is replicated on multiple master nodes to 99.99% uptime. + is replicated on multiple master nodes to 99.9% uptime.
Learn more about the HA control plane diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index c25df231353..a4a13882388 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -166,7 +166,7 @@ export const KubeSummaryPanel = React.memo((props: Props) => { window.open(dashboard?.url, '_blank'), title: 'Kubernetes Dashboard', }, @@ -183,7 +183,7 @@ export const KubeSummaryPanel = React.memo((props: Props) => { cluster.tier === 'enterprise' ? undefined : ( } onClick={() => window.open(dashboard?.url, '_blank')} diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index a3a7fcc59f2..b3ab0a4633a 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -307,31 +307,102 @@ describe('helper functions', () => { const result = getNextVersion(currentVersion, versions); expect(result).toEqual('2.00'); }); - }); - it('should get the next version when given a current enterprise version', () => { - const versions: KubernetesTieredVersion[] = [ - { id: 'v1.31.1+lke4', tier: 'enterprise' }, - { id: 'v1.31.6+lke2', tier: 'enterprise' }, - { id: 'v1.31.6+lke3', tier: 'enterprise' }, - { id: 'v1.31.8+lke1', tier: 'enterprise' }, - ]; - const currentVersion = 'v1.31.6+lke2'; - - const result = getNextVersion(currentVersion, versions); - expect(result).toEqual('v1.31.6+lke3'); - }); + it('should get the next version when given a current enterprise version', () => { + const versions: KubernetesTieredVersion[] = [ + { id: 'v1.31.1+lke4', tier: 'enterprise' }, + { id: 'v1.31.6+lke2', tier: 'enterprise' }, + { id: 'v1.31.6+lke3', tier: 'enterprise' }, + { id: 'v1.31.8+lke1', tier: 'enterprise' }, + ]; + const currentVersion = 'v1.31.6+lke2'; + + const result = getNextVersion(currentVersion, versions); + expect(result).toEqual('v1.31.6+lke3'); + }); + + it('should get the next version when given an obsolete current version', () => { + const versions: KubernetesVersion[] = [ + { id: '1.16' }, + { id: '1.17' }, + { id: '1.18' }, + ]; + const currentVersion = '1.15'; + + const result = getNextVersion(currentVersion, versions); + expect(result).toEqual('1.16'); + }); + + it('should correctly sort enterprise versions across different minor versions', () => { + const versions: KubernetesTieredVersion[] = [ + { id: 'v1.31.8+lke5', tier: 'enterprise' }, + { id: 'v1.32.9+lke1', tier: 'enterprise' }, + ]; + const currentVersion = 'v1.31.8+lke5'; + + const result = getNextVersion(currentVersion, versions); + expect(result).toEqual('v1.32.9+lke1'); + }); + + it('should not suggest a downgrade when cluster version is higher than all available versions', () => { + const versions: KubernetesTieredVersion[] = [ + { id: 'v1.31.8+lke5', tier: 'enterprise' }, + ]; + const currentVersion = 'v1.32.9+lke1'; + + const result = getNextVersion(currentVersion, versions); + expect(result).toBeNull(); + }); + + it('should handle multi-digit lke release numbers correctly', () => { + const versions: KubernetesTieredVersion[] = [ + { id: 'v1.31.8+lke2', tier: 'enterprise' }, + { id: 'v1.31.8+lke5', tier: 'enterprise' }, + { id: 'v1.31.8+lke10', tier: 'enterprise' }, + ]; + const currentVersion = 'v1.31.8+lke5'; - it('should get the next version when given an obsolete current version', () => { - const versions: KubernetesVersion[] = [ - { id: '1.16' }, - { id: '1.17' }, - { id: '1.18' }, - ]; - const currentVersion = '1.15'; + const result = getNextVersion(currentVersion, versions); + expect(result).toEqual('v1.31.8+lke10'); + }); - const result = getNextVersion(currentVersion, versions); - expect(result).toEqual('1.16'); + it('should handle multi-digit patch versions correctly', () => { + const versions: KubernetesTieredVersion[] = [ + { id: 'v1.32.9+lke1', tier: 'enterprise' }, + { id: 'v1.32.10+lke1', tier: 'enterprise' }, + ]; + const currentVersion = 'v1.32.9+lke1'; + + const result = getNextVersion(currentVersion, versions); + expect(result).toEqual('v1.32.10+lke1'); + }); + + it('should return null when the cluster is already on the latest version', () => { + const versions: KubernetesTieredVersion[] = [ + { id: 'v1.31.8+lke5', tier: 'enterprise' }, + { id: 'v1.32.9+lke1', tier: 'enterprise' }, + ]; + const currentVersion = 'v1.32.9+lke1'; + + const result = getNextVersion(currentVersion, versions); + expect(result).toBeNull(); + }); + + it('should return the correct next version for an obsolete enterprise version', () => { + const versions: KubernetesTieredVersion[] = [ + { id: 'v1.31.8+lke5', tier: 'enterprise' }, + { id: 'v1.32.9+lke1', tier: 'enterprise' }, + ]; + const currentVersion = 'v1.30.0+lke1'; + + const result = getNextVersion(currentVersion, versions); + expect(result).toEqual('v1.31.8+lke5'); + }); + + it('should return null when there are no versions available', () => { + const result = getNextVersion('v1.32.9+lke1', []); + expect(result).toBeNull(); + }); }); }); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index eb5570f3927..36ed44ed23e 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -137,26 +137,23 @@ export const getNextVersion = ( if (versions.length === 0) { return null; } - const versionStrings = versions.map((v) => v.id).sort(); + const versionStrings = versions + .map((v) => v.id) + .sort((a, b) => compareByKubernetesVersion(a, b, 'asc')); const currentIdx = versionStrings.findIndex( (thisVersion) => currentVersion === thisVersion ); if (currentIdx < 0) { - // For now, assume that if nothing matches the user is on an obsolete version. - // According to the LKE team's deprecation policy, there will only ever be - // one such obsolete version, so this is safe. However, we'll eventually - // have a version.deprecated field to work with, which will be cleaner - // and safer. - // - // Example: - // API returns [1.16, 1.17, 1.18]. - // You haven't upgraded in ages and your cluster is on 1.15. - // The next available upgrade would be 1.16, which is the first item in the list. - // Return that. - // - return versionStrings[0]; + // The current version is not in the available versions list. + // This typically means the user is on an obsolete/deprecated version. + // Return the first available version that is actually newer than the current version + // to avoid suggesting a downgrade. + const nextHigherVersion = versionStrings.find( + (v) => compareByKubernetesVersion(v, currentVersion, 'asc') > 0 + ); + return nextHigherVersion ?? null; } - if (currentIdx === versions.length - 1) { + if (currentIdx === versionStrings.length - 1) { return null; } return versionStrings[currentIdx + 1]; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.test.tsx index 94f6f00b35f..11066c066e3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.test.tsx @@ -131,7 +131,7 @@ describe('Linode Create Firewall', () => { firewall.label ); }, - { timeout: 5_000 } + { timeout: 30000 } ); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security/Password.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security/Password.tsx new file mode 100644 index 00000000000..e3f1692d4d5 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Security/Password.tsx @@ -0,0 +1,67 @@ +import { Notice, Typography } from '@linode/ui'; +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Skeleton } from 'src/components/Skeleton'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useIsPasswordLessLinodesEnabled } from 'src/utilities/linodes'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +const PasswordInput = React.lazy(() => + import('src/components/PasswordInput/PasswordInput').then((module) => ({ + default: module.PasswordInput, + })) +); + +export const Password = () => { + const { control, formState } = useFormContext(); + const { data: permissions } = usePermissions('account', ['create_linode']); + const { isPasswordLessLinodesEnabled } = useIsPasswordLessLinodesEnabled(); + + return ( + <> + {isPasswordLessLinodesEnabled && ( + <> + + Authentication Method + + {formState.errors.root_pass?.message && ( + + )} + + )} + ({ height: '89px', maxWidth: theme.inputMaxWidth })} + /> + } + > + ( + + )} + /> + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security/SSHKeys.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security/SSHKeys.tsx new file mode 100644 index 00000000000..8f2cb4bbf69 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Security/SSHKeys.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { UserSSHKeyPanel } from 'src/components/AccessPanel/UserSSHKeyPanel'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useIsPasswordLessLinodesEnabled } from 'src/utilities/linodes'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +export const SSHKeys = () => { + const { control, trigger } = useFormContext(); + const { data: permissions } = usePermissions('account', ['create_linode']); + const { isPasswordLessLinodesEnabled } = useIsPasswordLessLinodesEnabled(); + + return ( + ( + { + field.onChange(values); + if (isPasswordLessLinodesEnabled) { + trigger('root_pass'); + } + }} + /> + )} + /> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security/Security.test.tsx similarity index 98% rename from packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx rename to packages/manager/src/features/Linodes/LinodeCreate/Security/Security.test.tsx index 63dcad2b00d..4df3a38730f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Security/Security.test.tsx @@ -13,7 +13,7 @@ import { import { Security } from './Security'; -import type { LinodeCreateFormValues } from './utilities'; +import type { LinodeCreateFormValues } from '../utilities'; const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(), @@ -59,7 +59,7 @@ describe('Security', () => { expect(rootPasswordInput).toBeVisible(); expect(rootPasswordInput).toBeDisabled(); }, - { timeout: 5_000 } + { timeout: 30000 } ); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security/Security.tsx similarity index 63% rename from packages/manager/src/features/Linodes/LinodeCreate/Security.tsx rename to packages/manager/src/features/Linodes/LinodeCreate/Security/Security.tsx index 781fe44396a..8df1c86c6be 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Security/Security.tsx @@ -3,7 +3,6 @@ import { Divider, Paper, Typography } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; -import { UserSSHKeyPanel } from 'src/components/AccessPanel/UserSSHKeyPanel'; import { DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES, DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION, @@ -13,16 +12,12 @@ import { import { Encryption } from 'src/components/Encryption/Encryption'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; -import { Skeleton } from 'src/components/Skeleton'; -import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useIsPasswordLessLinodesEnabled } from 'src/utilities/linodes'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import { Password } from './Password'; +import { SSHKeys } from './SSHKeys'; -const PasswordInput = React.lazy(() => - import('src/components/PasswordInput/PasswordInput').then((module) => ({ - default: module.PasswordInput, - })) -); +import type { CreateLinodeRequest } from '@linode/api-v4'; export const Security = () => { const { control } = useFormContext(); @@ -45,52 +40,26 @@ export const Security = () => { selectedRegion?.id ?? '' ); - const { data: permissions } = usePermissions('account', ['create_linode']); + const { isPasswordLessLinodesEnabled } = useIsPasswordLessLinodesEnabled(); return ( Security - ({ height: '89px', maxWidth: theme.inputMaxWidth })} - /> - } - > - ( - - )} - /> - - - ( - - )} - /> + {!isPasswordLessLinodesEnabled ? ( + <> + + + + + ) : ( + <> + + + + + )} {isDiskEncryptionFeatureEnabled && ( <> diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx index e24870c6780..041d3ec676e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx @@ -101,7 +101,8 @@ export const Images = () => { errorText={fieldState.error?.message} onSelect={onChange} pendoIDs={IMAGE_SELECT_TABLE_LINODE_CREATE_PENDO_IDS} - selectedImageId={field.value} + selectedImageIds={field.value ? [field.value] : []} + selectionMode="single" /> ) : ( diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx index 9d03ef374bd..1961f161fb5 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx @@ -59,7 +59,7 @@ describe('Marketplace', () => { () => { expect(getByPlaceholderText('Select category')).not.toBeDisabled(); }, - { timeout: 5_000 } + { timeout: 30000 } ); const select = getByPlaceholderText('Select category'); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.tsx index dbfa4b981d2..a14d55952c4 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.tsx @@ -6,6 +6,7 @@ import { useController, useFormContext, useWatch } from 'react-hook-form'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useIsPasswordLessLinodesEnabled } from 'src/utilities/linodes'; import { Region } from '../Region'; import { getGeneratedLinodeLabel } from '../utilities'; @@ -17,9 +18,14 @@ export const OperatingSystems = () => { const { formState: { dirtyFields: { label: isLabelFieldDirty }, + touchedFields: { + authorized_users: isAuthorizedUsersFieldTouched, + root_pass: isRootPassFieldTouched, + }, }, getValues, setValue, + trigger, } = useFormContext(); const queryClient = useQueryClient(); @@ -31,6 +37,7 @@ export const OperatingSystems = () => { const regionId = useWatch({ name: 'region', }); + const { isPasswordLessLinodesEnabled } = useIsPasswordLessLinodesEnabled(); const { data: region } = useRegionQuery(regionId); @@ -39,6 +46,13 @@ export const OperatingSystems = () => { const onChange = async (image: Image | null) => { field.onChange(image?.id ?? null); + if ( + isRootPassFieldTouched || + (isPasswordLessLinodesEnabled && isAuthorizedUsersFieldTouched) + ) { + trigger('root_pass'); + } + if (!isLabelFieldDirty) { const label = await getGeneratedLinodeLabel({ queryClient, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx index 40d879cc64a..22cbd5c3e3b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx @@ -45,6 +45,7 @@ import { import { useIsLinodeCloneFirewallEnabled, useIsLinodeInterfacesEnabled, + useIsPasswordLessLinodesEnabled, } from 'src/utilities/linodes'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; @@ -60,7 +61,7 @@ import { Networking } from './Networking/Networking'; import { transformLegacyInterfaceErrorsToLinodeInterfaceErrors } from './Networking/utilities'; import { Plan } from './Plan'; import { getLinodeCreateResolver } from './resolvers'; -import { Security } from './Security'; +import { Security } from './Security/Security'; import { SMTP } from './SMTP'; import { Summary } from './Summary/Summary'; import { UserData } from './UserData/UserData'; @@ -69,6 +70,7 @@ import { defaultValues, EMPTY_ACLP_ALERTS, getLinodeCreatePayload, + transformPasswordLessCreateErrors, useHandleLinodeCreateAnalyticsFormError, } from './utilities'; import { VLAN } from './VLAN/VLAN'; @@ -86,6 +88,7 @@ export const LinodeCreate = () => { }); const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + const { isPasswordLessLinodesEnabled } = useIsPasswordLessLinodesEnabled(); const { data: profile } = useProfile(); const { isLinodeCloneFirewallEnabled } = useIsLinodeCloneFirewallEnabled(); const { isVMHostMaintenanceEnabled } = useVMHostMaintenanceEnabled(); @@ -103,7 +106,12 @@ export const LinodeCreate = () => { const { isDualStackEnabled } = useVPCDualStack(); const form = useForm({ - context: { isLinodeInterfacesEnabled, profile, secureVMNoticesEnabled }, + context: { + isPasswordLessLinodesEnabled, + isLinodeInterfacesEnabled, + profile, + secureVMNoticesEnabled, + }, defaultValues: () => defaultValues(linodeCreateType, search, queryClient, { isLinodeInterfacesEnabled, @@ -185,6 +193,7 @@ export const LinodeCreate = () => { isShowingNewNetworkingUI: isLinodeInterfacesEnabled, isAclpAlertsEnabled: aclpServices?.linode?.alerts?.enabled, isAclpAlertsMode: isAclpAlertsModeCreateFlow, + isPasswordLessLinodesEnabled, }); try { @@ -227,6 +236,9 @@ export const LinodeCreate = () => { if (isLinodeInterfacesEnabled) { transformLegacyInterfaceErrorsToLinodeInterfaceErrors(errors); } + if (isPasswordLessLinodesEnabled) { + transformPasswordLessCreateErrors(errors); + } for (const error of errors) { if (error.field) { form.setError(error.field, { message: error.reason }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts b/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts index dd798b23e45..3ab7d62c08c 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts @@ -10,6 +10,8 @@ import { CreateLinodeFromMarketplaceAppSchema, CreateLinodeFromStackScriptSchema, CreateLinodeSchema, + withRootPassOptional, + withRootPassRequired, } from './schemas'; import { getDoesEmployeeNeedToAssignFirewall, @@ -27,12 +29,15 @@ export const getLinodeCreateResolver = ( tab: LinodeCreateType | undefined, queryClient: QueryClient ): Resolver => { - const schema = linodeCreateResolvers[tab ?? 'OS']; return async (rawValues, context, options) => { + const linodeCreateSchemas = linodeCreateResolvers( + context?.isPasswordLessLinodesEnabled ?? false + ); + const schema = linodeCreateSchemas[tab ?? 'OS']; const values = structuredClone(rawValues); // Because `interfaces` are so complex, we need to perform some transformations before - // we even try to valiate them with our vaidation schema. + // we even try to validate them with our vaidation schema. if (context?.isLinodeInterfacesEnabled) { values.interfaces = []; values.linodeInterfaces = values.linodeInterfaces.map( @@ -136,11 +141,28 @@ export const getLinodeCreateResolver = ( }; }; -export const linodeCreateResolvers = { - Backups: CreateLinodeFromBackupSchema, - 'Clone Linode': CreateLinodeSchema, - Images: CreateLinodeSchema, - OS: CreateLinodeSchema, - 'One-Click': CreateLinodeFromMarketplaceAppSchema, - StackScripts: CreateLinodeFromStackScriptSchema, +// This function returns the appropriate validation schema based on whether or not passwordLess Linodes are enabled. If passwordLess Linodes are enabled, then the root_pass field is optional. If passwordLess Linodes are not enabled, then the root_pass field is required. +// Creating Linodes from Backups and Cloning Linodes do not require a root password, so they are unaffected by the passwordLess Linodes feature and their schemas remain the same regardless of whether or not passwordLess Linodes are enabled. +export const linodeCreateResolvers = ( + isPasswordLessLinodesEnabled: boolean +) => { + if (!isPasswordLessLinodesEnabled) { + return { + Backups: CreateLinodeFromBackupSchema, + 'Clone Linode': CreateLinodeSchema, + Images: withRootPassRequired(CreateLinodeSchema), + OS: withRootPassRequired(CreateLinodeSchema), + 'One-Click': withRootPassRequired(CreateLinodeFromMarketplaceAppSchema), + StackScripts: withRootPassRequired(CreateLinodeFromStackScriptSchema), + }; + } + + return { + Backups: CreateLinodeFromBackupSchema, + 'Clone Linode': CreateLinodeSchema, + Images: withRootPassOptional(CreateLinodeSchema), + OS: withRootPassOptional(CreateLinodeSchema), + 'One-Click': withRootPassOptional(CreateLinodeFromMarketplaceAppSchema), + StackScripts: withRootPassOptional(CreateLinodeFromStackScriptSchema), + }; }; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts b/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts index f51432af0fd..61940e61bc5 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts @@ -68,3 +68,50 @@ export const CreateLinodeFromMarketplaceAppSchema = CreateLinodeSchema.concat( stackscript_id: number().required('You must select a Marketplace App.'), }) ); + +/** + * root_pass is set to be optional when authorized_users are provided. + * Applied to each schema variant when the passwordLessLinodes feature is enabled + */ +export const withRootPassOptional = ( + schema: ObjectSchema +): ObjectSchema => + schema.shape({ + root_pass: string().when('image', { + is: (value: any) => Boolean(value), + then: (schema) => + schema.test({ + name: 'root-pass-or-ssh-key-required', + message: + 'An SSH Key or a Root Password is required to create an instance. We recommend using an SSH Key for better security.', + test(value, context) { + const { authorized_users, authorized_keys } = context.parent; + const hasAuthorizedUsers = + Array.isArray(authorized_users) && authorized_users.length > 0; + const hasAuthorizedKeys = + Array.isArray(authorized_keys) && authorized_keys.length > 0; + return Boolean(value) || hasAuthorizedUsers || hasAuthorizedKeys; + }, + }), + otherwise: (schema) => schema.notRequired(), + }), + }) as ObjectSchema; + +/** + * root_pass is set to be required when deploying from an image. + * Applied to each schema variant when the passwordLessLinodes feature is + * disabled enforcing the root password requirement. + */ +export const withRootPassRequired = ( + schema: ObjectSchema +): ObjectSchema => + schema.shape({ + root_pass: string().when('image', { + is: (value: any) => Boolean(value), + then: (schema) => + schema.required( + 'You must provide a root password when deploying from an image.' + ), + otherwise: (schema) => schema.notRequired(), + }), + }) as ObjectSchema; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx index 8dc0a441f17..c54789c1923 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx @@ -102,6 +102,42 @@ describe('getLinodeCreatePayload', () => { }).interfaces ).toEqual([{ public: {}, vpc: null, vlan: null }]); }); + + it('should remove the root_pass and authorized_keys properties when passwordLessLinodes is enabled and no root password or SSH keys are provided', () => { + const values = { + ...createLinodeRequestFactory.build({ + root_pass: '', + authorized_keys: [], + }), + } as LinodeCreateFormValues; + + expect( + getLinodeCreatePayload(values, { + isShowingNewNetworkingUI: false, + isPasswordLessLinodesEnabled: true, + }) + ).toEqual({ + ...values, + root_pass: undefined, + authorized_users: undefined, + }); + }); + + it('should not remove the root_pass and authorized_keys properties when passwordLessLinodes is enabled but a root password and SSH keys are provided', () => { + const values = { + ...createLinodeRequestFactory.build({ + root_pass: 'password', + authorized_keys: ['ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD...'], + }), + } as LinodeCreateFormValues; + + expect( + getLinodeCreatePayload(values, { + isShowingNewNetworkingUI: false, + isPasswordLessLinodesEnabled: true, + }) + ).toEqual(values); + }); }); describe('getInterfacesPayload', () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 3a13c6feb75..b02dee93f3a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -25,6 +25,7 @@ import { getDefaultUDFData } from './Tabs/StackScripts/UserDefinedFields/utiliti import type { LinodeCreateInterface } from './Networking/utilities'; import type { AccountSettings, + APIError, CloudPulseAlertsPayload, CreateLinodeRequest, FirewallSettings, @@ -57,6 +58,7 @@ interface LinodeCreatePayloadOptions { isAclpAlertsEnabled?: boolean; isAclpAlertsMode?: boolean; isDualStackEnabled?: boolean; + isPasswordLessLinodesEnabled?: boolean; isShowingNewNetworkingUI: boolean; } @@ -76,6 +78,7 @@ export const getLinodeCreatePayload = ( isAclpAlertsEnabled, isAclpAlertsMode, isDualStackEnabled, + isPasswordLessLinodesEnabled, } = options; const values: CreateLinodeRequest = omitProps(formValues, [ @@ -128,9 +131,37 @@ export const getLinodeCreatePayload = ( ); } + if (isPasswordLessLinodesEnabled) { + if (!values.root_pass) { + values.root_pass = undefined; + } + if (values.authorized_keys?.length === 0) { + values.authorized_users = undefined; + } + } + return values; }; +const missingAuthenticationMethodErrorReason = + 'Must provide valid root_pass, authorized_keys, or authorized_users'; +const missingAuthenticationMethodMessage = + 'Provide at least one authentication method. This can be a Root Password or an SSH Key, for added security.'; + +/* + * Transforms the error message about invalid/missing authentication methods into a more user-friendly message. + */ + +export const transformPasswordLessCreateErrors = (errors: APIError[]) => { + for (const error of errors) { + if (error.reason.includes(missingAuthenticationMethodErrorReason)) { + error.reason = missingAuthenticationMethodMessage; + } + } + + return errors; +}; + /** * Transforms and orders the Linode Create "interfaces" form data. * @@ -246,13 +277,19 @@ export interface LinodeCreateFormContext { * Is the form using the new Interfaces UI? */ isLinodeInterfacesEnabled: boolean; + /** + * Is passwordLess Linode creation enabled? + * When true, root_pass is optional if authorized_users are provided. + * When false, root_pass is required. + */ + isPasswordLessLinodesEnabled: boolean; /** * Profile data is used in the Linode Create resolver because * restricted users are subject to different validation. */ profile: Profile | undefined; /** - * Used for dispaying warnings to internal Akamai employees. + * Used for displaying warnings to internal Akamai employees. */ secureVMNoticesEnabled: boolean; } diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx index 0f7d2aba767..0a0975ec2ca 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx @@ -110,7 +110,7 @@ describe('Linode Entity Detail', () => { 'test-vpc' ); }, - { timeout: 15_000 } + { timeout: 30000 } ); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx index 53b582e89c6..b823533ee5f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx @@ -48,10 +48,12 @@ export const Image = (props: Props) => { : '/linodes') as LinkProps['to'] } errorText={fieldState.error?.message} + filter={getImageSelectFilter(stackscript)} onSelect={(image) => field.onChange(image?.id ?? null)} pendoIDs={IMAGE_SELECT_TABLE_LINODE_REBUILD_PENDO_IDS} queryParamsPrefix="images" - selectedImageId={field.value} + selectedImageIds={field.value ? [field.value] : []} + selectionMode="single" /> ) : ( { (preferences) => preferences?.type_to_confirm ?? true ); + const { isPasswordLessLinodesEnabled } = useIsPasswordLessLinodesEnabled(); const queryClient = useQueryClient(); const { mutateAsync: rebuildLinode } = useRebuildLinodeMutation(linode.id); const { checkForNewEvents } = useEventsPollingActions(); const form = useForm({ context: { + isPasswordLessLinodesEnabled, isTypeToConfirmEnabled, linodeLabel: linode.label, queryClient, @@ -107,6 +111,9 @@ export const LinodeRebuildForm = (props: Props) => { checkForNewEvents(); onSuccess(); } catch (errors) { + if (isPasswordLessLinodesEnabled) { + transformPasswordLessCreateErrors(errors); + } for (const error of errors) { form.setError(error.field ?? 'root', { message: error.reason }); } @@ -170,8 +177,29 @@ export const LinodeRebuildForm = (props: Props) => { )} {type.includes('StackScript') && } - - + {isPasswordLessLinodesEnabled ? ( + <> + + + Authentication Method + {form.formState.errors.root_pass?.message && ( + + )} + + + ) : ( + <> + + + + + )} { @@ -20,7 +21,9 @@ export const Password = (props: Props) => { { - const { control } = useFormContext(); + const { control, trigger } = useFormContext(); + const { isPasswordLessLinodesEnabled } = useIsPasswordLessLinodesEnabled(); return ( { authorizedUsers={field.value ?? []} disabled={props.disabled} headingVariant="h3" - setAuthorizedUsers={field.onChange} + setAuthorizedUsers={(values) => { + field.onChange(values); + if (isPasswordLessLinodesEnabled) { + trigger('root_pass'); + } + }} /> )} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts index 29d07efcec8..75c5f9bca7a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts @@ -27,34 +27,74 @@ export interface RebuildLinodeFormValues extends RebuildRequest { } export interface Context { + isPasswordLessLinodesEnabled: boolean; isTypeToConfirmEnabled: ManagerPreferences['type_to_confirm']; linodeLabel: string | undefined; queryClient: QueryClient; type: LinodeRebuildType; } -const RebuildLinodeFromImageSchema = RebuildLinodeSchema.concat( +const RebuildLinodeFromImageWithoutPasswordSchema = RebuildLinodeSchema.concat( object({ confirmationText: string(), reuseUserData: boolean().required(), + root_pass: string().when(['authorized_users', 'authorized_keys'], { + is: (authorizedUsers: unknown, authorizedKeys: unknown) => { + const hasAuthorizedUsers = + Array.isArray(authorizedUsers) && authorizedUsers.length > 0; + const hasAuthorizedKeys = + Array.isArray(authorizedKeys) && authorizedKeys.length > 0; + + return !hasAuthorizedUsers && !hasAuthorizedKeys; + }, + then: (schema) => + schema.required( + 'An SSH Key or a Root Password is required to rebuild a Linode. We recommend using an SSH Key for better security.' + ), + otherwise: (schema) => schema.notRequired(), + }), }) ); -const RebuildLinodeFromStackScriptSchema = RebuildLinodeFromImageSchema.concat( - object({ - stackscript_id: number().required('You must select a StackScript.'), - }) -); +const RebuildLinodeFromImageSchema = + RebuildLinodeFromImageWithoutPasswordSchema.shape({ + root_pass: string().required('Password is required.'), + }); + +const RebuildLinodeFromStackScriptWithoutPasswordSchema = + RebuildLinodeFromImageWithoutPasswordSchema.concat( + object({ + stackscript_id: number().required('You must select a StackScript.'), + }) + ); + +const RebuildLinodeFromStackScriptSchema = + RebuildLinodeFromStackScriptWithoutPasswordSchema.shape({ + root_pass: string().required('Password is required.'), + }); + +const REBUILD_SCHEMAS = { + image: { + standard: RebuildLinodeFromImageSchema, + passwordless: RebuildLinodeFromImageWithoutPasswordSchema, + }, + stackscript: { + standard: RebuildLinodeFromStackScriptSchema, + passwordless: RebuildLinodeFromStackScriptWithoutPasswordSchema, + }, +} as const; export const resolver: Resolver = async ( values, context, options ) => { - const schema = - context?.type === 'Image' - ? RebuildLinodeFromImageSchema - : RebuildLinodeFromStackScriptSchema; + const typeKey = context?.type === 'Image' ? 'image' : 'stackscript'; + const passwordKey = context?.isPasswordLessLinodesEnabled + ? 'passwordless' + : 'standard'; + + const schema = REBUILD_SCHEMAS[typeKey][passwordKey]; const { errors } = await yupResolver(schema, {}, {})( values, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx index 90bc1e2c763..bc2a67f0513 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx @@ -41,7 +41,7 @@ describe('ImageAndPassword', () => { const { findByText } = renderWithTheme(component); const passwordError = await findByText(errorMessage, undefined, { - timeout: 2500, + timeout: 30000, }); expect(passwordError).toBeVisible(); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx index 54359596747..3e87891ea04 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx @@ -1,9 +1,10 @@ -import { Divider } from '@linode/ui'; +import { Divider, Notice, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { UserSSHKeyPanel } from 'src/components/AccessPanel/UserSSHKeyPanel'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; import { PasswordInput } from 'src/components/PasswordInput/PasswordInput'; +import { useIsPasswordLessLinodesEnabled } from 'src/utilities/linodes'; import type { Image } from '@linode/api-v4'; @@ -32,6 +33,8 @@ export const ImageAndPassword = (props: Props) => { setAuthorizedUsers, } = props; + const { isPasswordLessLinodesEnabled } = useIsPasswordLessLinodesEnabled(); + return ( { value={selectedImage} variant="all" /> - onPasswordChange(e.target.value)} - value={password || ''} - /> - - + {isPasswordLessLinodesEnabled ? ( + + + + Security + + + + Authentication Method + {passwordError && ( + + )} + onPasswordChange(e.target.value)} + value={password || ''} + /> + + + ) : ( + + onPasswordChange(e.target.value)} + value={password || ''} + /> + + + + )} ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx index 1a7069b54cf..dffc2ccb41f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx @@ -14,6 +14,7 @@ import { } from '@linode/ui'; import { CreateLinodeDiskFromImageSchema, + CreateLinodeDiskFromImageWithoutPasswordSchema, CreateLinodeDiskSchema, } from '@linode/validation'; import { useFormik } from 'formik'; @@ -23,6 +24,7 @@ import * as React from 'react'; import { ModeSelect } from 'src/components/ModeSelect/ModeSelect'; import { useEventsPollingActions } from 'src/queries/events/events'; import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; +import { useIsPasswordLessLinodesEnabled } from 'src/utilities/linodes'; import { LinodePermissionsError } from '../LinodePermissionsError'; import { ImageAndPassword } from '../LinodeSettings/ImageAndPassword'; @@ -58,6 +60,7 @@ export const CreateDiskDrawer = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { checkForNewEvents } = useEventsPollingActions(); + const { isPasswordLessLinodesEnabled } = useIsPasswordLessLinodesEnabled(); const [selectedMode, setSelectedMode] = React.useState('empty'); @@ -80,9 +83,11 @@ export const CreateDiskDrawer = (props: Props) => { }; const validationSchema = - selectedMode === 'from_image' - ? CreateLinodeDiskFromImageSchema - : CreateLinodeDiskSchema; + selectedMode === 'empty' + ? CreateLinodeDiskSchema + : isPasswordLessLinodesEnabled + ? CreateLinodeDiskFromImageWithoutPasswordSchema + : CreateLinodeDiskFromImageSchema; const formik = useFormik({ enableReinitialize: true, @@ -96,7 +101,13 @@ export const CreateDiskDrawer = (props: Props) => { label: values.label, size: values.size, } - : values; + : { + ...values, + ...(values.root_pass === '' && { root_pass: undefined }), + ...(values.authorized_users.length === 0 && { + authorized_users: undefined, + }), + }; await createDisk(cleanedValues); checkForNewEvents(); diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.test.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.test.tsx index 447e74fb41a..d14d7e781e0 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.test.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.test.tsx @@ -133,7 +133,7 @@ describe('LongviewPlans', () => { renderWithTheme(); await waitForElementToBeRemoved(screen.getByTestId('circle-progress'), { - timeout: 5000, + timeout: 30000, }); screen.getByText(/don't have permission/i); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx index 59130fd6c7b..9a8437fe824 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx @@ -1,12 +1,24 @@ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; import NodeBalancerCreate from './NodeBalancerCreate'; +const createNodeBalancerMock = vi.fn().mockResolvedValue({ + id: 1, + region: 'us-east', +}); +const createNodeBalancerBetaMock = vi.fn().mockResolvedValue({ + id: 2, + region: 'us-east', +}); + const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => vi.fn()), useFlags: vi.fn().mockReturnValue({}), + useIsReserveIpEnabled: vi.fn(() => ({ isReserveIpEnabled: true })), useParams: vi.fn().mockReturnValue({ id: undefined }), userPermissions: vi.fn(() => ({ data: { @@ -16,6 +28,45 @@ const queryMocks = vi.hoisted(() => ({ })), })); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useNodebalancerCreateMutation: vi.fn(() => ({ + error: null, + isPending: false, + mutateAsync: createNodeBalancerMock, + })), + useNodebalancerCreateBetaMutation: vi.fn(() => ({ + error: null, + isPending: false, + mutateAsync: createNodeBalancerBetaMock, + })), + useMutateAccountAgreements: vi.fn(() => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + })), + }; +}); + +vi.mock('../ReservedIps/IPAddressSelection/IPAddressSelection', () => ({ + IPAddressSelection: ({ onIPModeChange, onReservedIPSelect }: any) => ( +
+ + +
+ ), +})); + vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); return { @@ -37,12 +88,37 @@ vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.userPermissions, })); +vi.mock('src/features/ReservedIps/utils', () => ({ + useIsReserveIpEnabled: queryMocks.useIsReserveIpEnabled, +})); + // Note: see nodeblaancers-create-in-complex-form.spec.ts for an e2e test of this flow describe('NodeBalancerCreate', () => { queryMocks.useFlags.mockReturnValue({ nodebalancerVpc: true, }); queryMocks.useParams.mockReturnValue({ id: undefined }); + + beforeEach(() => { + createNodeBalancerMock.mockClear(); + createNodeBalancerBetaMock.mockClear(); + queryMocks.useIsReserveIpEnabled.mockReturnValue({ + isReserveIpEnabled: true, + }); + queryMocks.userPermissions.mockReturnValue({ + data: { + create_firewall: true, + create_nodebalancer: true, + }, + }); + }); + + const getCreatePayload = () => { + const [createCall] = createNodeBalancerMock.mock.calls; + const [createBetaCall] = createNodeBalancerBetaMock.mock.calls; + return createCall?.[0] ?? createBetaCall?.[0]; + }; + it('renders all parts of the NodeBalancerCreate page', () => { const { getAllByText, getByLabelText, getByText } = renderWithTheme( @@ -91,4 +167,64 @@ describe('NodeBalancerCreate', () => { const createButton = getByText('Create NodeBalancer'); expect(createButton).toBeDisabled(); }); + + it('includes ipv4 in the create payload when reserve IP feature is enabled and reserved mode is selected', async () => { + const { getByText } = renderWithTheme(); + + await userEvent.click(getByText('Switch to Reserved IP')); + await userEvent.click(getByText('Select Reserved IP')); + await userEvent.click(getByText('Create NodeBalancer')); + + await waitFor(() => { + expect( + createNodeBalancerMock.mock.calls.length + + createNodeBalancerBetaMock.mock.calls.length + ).toBe(1); + }); + + expect(getCreatePayload()).toEqual( + expect.objectContaining({ + ipv4: '192.0.2.123', + }) + ); + }); + + it('does not include ipv4 in the create payload when reserve IP feature is enabled and mode remains auto', async () => { + const { getByText } = renderWithTheme(); + + await userEvent.click(getByText('Create NodeBalancer')); + + await waitFor(() => { + expect( + createNodeBalancerMock.mock.calls.length + + createNodeBalancerBetaMock.mock.calls.length + ).toBe(1); + }); + + const createPayload = getCreatePayload(); + expect(createPayload).not.toHaveProperty('ipv4'); + }); + + it('does not include ipv4 in the create payload when reserve IP feature is disabled', async () => { + queryMocks.useIsReserveIpEnabled.mockReturnValue({ + isReserveIpEnabled: false, + }); + + const { getByText, queryByText } = renderWithTheme(); + + expect(queryByText('Switch to Reserved IP')).not.toBeInTheDocument(); + expect(queryByText('Select Reserved IP')).not.toBeInTheDocument(); + + await userEvent.click(getByText('Create NodeBalancer')); + + await waitFor(() => { + expect( + createNodeBalancerMock.mock.calls.length + + createNodeBalancerBetaMock.mock.calls.length + ).toBe(1); + }); + + const createPayload = getCreatePayload(); + expect(createPayload).not.toHaveProperty('ipv4'); + }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 99c071b464e..f6842239da0 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -39,6 +39,7 @@ import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperT import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { FIREWALL_GET_STARTED_LINK } from 'src/constants'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useIsReserveIpEnabled } from 'src/features/ReservedIps/utils'; import { useFlags } from 'src/hooks/useFlags'; import { sendCreateNodeBalancerEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -56,6 +57,7 @@ import { reportAgreementSigningError } from 'src/utilities/reportAgreementSignin import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; import { usePermissions } from '../IAM/hooks/usePermissions'; +import { IPAddressSelection } from '../ReservedIps/IPAddressSelection/IPAddressSelection'; import { NodeBalancerConfigPanel } from './NodeBalancerConfigPanel'; import { createNewNodeBalancerConfig, @@ -66,7 +68,12 @@ import { import { VPCPanel } from './VPCPanel'; import type { NodeBalancerConfigFieldsWithStatus } from './types'; -import type { APIError, NodeBalancerVpcPayload, VPC } from '@linode/api-v4'; +import type { + APIError, + IPAddress, + NodeBalancerVpcPayload, + VPC, +} from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; import type { TagOption } from 'src/components/TagsInput/TagsInput'; @@ -78,6 +85,7 @@ interface NodeBalancerConfigFieldsWithStatusAndErrors interface NodeBalancerFieldsState { configs: NodeBalancerConfigFieldsWithStatusAndErrors[]; firewall_id?: number; + ipv4?: string; label?: string; region?: string; tags?: string[]; @@ -171,6 +179,10 @@ const NodeBalancerCreate = () => { setVPCErrors([]); }, [vpcSelected]); + const { isReserveIpEnabled } = useIsReserveIpEnabled(); + const [ipMode, setIpMode] = React.useState<'auto' | 'reserved'>('auto'); + const [selectedIP, setSelectedIP] = React.useState(null); + const addNodeBalancer = () => { if (!permissions.create_nodebalancer) { return; @@ -353,6 +365,9 @@ const NodeBalancerCreate = () => { : vpc ); } + if (isReserveIpEnabled && ipMode === 'reserved' && selectedIP?.address) { + nodeBalancerRequestData.ipv4 = selectedIP.address; + } nodeBalancerRequestData.configs = transformConfigsForRequest( nodeBalancerRequestData.configs ); @@ -727,6 +742,29 @@ const NodeBalancerCreate = () => { vpcSelected={vpcSelected} /> )} + {isReserveIpEnabled && ( + + setIpMode(mode)} + onReservedIPSelect={(ip) => setSelectedIP(ip)} + regionId={nodeBalancerFields.region ?? ''} + selectedIP={selectedIP} + tooltipText={{ + auto: "A public IPv4 address automatically assigned to your NodeBalancer’s \ + frontend to serve as the entry point for incoming traffic. Use this for \ + standard web traffic that doesn't require a permanent, static IP. \ + This address is included at no additional cost but will be released if the NodeBalancer is deleted.", + reserved: + "A static public IPv4 address assigned to your NodeBalancer’s \ + frontend to serve as a permanent gateway for incoming traffic. \ + Use this for services requiring a consistent IP for DNS records or security allowlisting. \ + Charges apply while the IP is reserved, even if it's not assigned to a NodeBalancer.", + }} + /> + + )} {nodeBalancerFields.configs.map((nodeBalancerConfig, idx) => { diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index 53e3f848a36..25d9ddb57ca 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -57,7 +57,7 @@ describe('Create API Token Drawer', () => { it( 'Should see secret modal with secret when you type a label and submit the form successfully', - { timeout: 15000 }, + { timeout: 30000 }, async () => { server.use( http.post('*/profile/tokens', () => { diff --git a/packages/manager/src/features/ReservedIps/IPAddressSelection/IPAddressSelection.test.tsx b/packages/manager/src/features/ReservedIps/IPAddressSelection/IPAddressSelection.test.tsx new file mode 100644 index 00000000000..28798e5a03f --- /dev/null +++ b/packages/manager/src/features/ReservedIps/IPAddressSelection/IPAddressSelection.test.tsx @@ -0,0 +1,329 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { reservedIPsFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { IPAddressSelection } from './IPAddressSelection'; + +import type { IPAddress } from '@linode/api-v4'; + +describe('IPAddressSelection', () => { + describe('Component Rendering', () => { + it('should render the IP Address label', () => { + const { getByText } = renderWithTheme(); + expect(getByText('IP Address')).toBeInTheDocument(); + }); + + it('should render Auto-assigned radio button by default', () => { + const { getAllByRole } = renderWithTheme(); + const autoRadio = getAllByRole('radio', { name: /Auto-assigned/i })[0]; + expect(autoRadio).toBeInTheDocument(); + expect(autoRadio).toBeChecked(); + }); + + it('should render Reserved radio button', () => { + const { getAllByRole } = renderWithTheme(); + const reservedRadio = getAllByRole('radio', { name: /Reserved/i })[0]; + expect(reservedRadio).toBeInTheDocument(); + expect(reservedRadio).not.toBeChecked(); + }); + + it('should render tooltips for both radio options', async () => { + const { findByText, getAllByTestId } = renderWithTheme( + + ); + const tooltipIcons = getAllByTestId('tooltip-info-icon'); + expect(tooltipIcons).toHaveLength(2); + await userEvent.hover(tooltipIcons[0]); + expect( + await findByText( + "A public IPv4 address automatically assigned to your Linode. Use this for standard web traffic that doesn't require a permanent, static IP." + ) + ).toBeInTheDocument(); + await userEvent.unhover(tooltipIcons[0]); + await userEvent.hover(tooltipIcons[1]); + expect( + await findByText( + "A reserved IPv4 address is a static public IP that can be assigned to Linodes in the same region. Use it for services that require a consistent IP address. Charges apply while the IP is reserved, even if it's not assigned to a Linode." + ) + ).toBeInTheDocument(); + }); + + it('should not show reserved IP dropdown by default', () => { + const { queryByLabelText } = renderWithTheme(); + expect(queryByLabelText('Reserved IP Address')).not.toBeInTheDocument(); + }); + }); + + describe('Mode Selection', () => { + it('should call onIPModeChange when mode changes', async () => { + const onIPModeChange = vi.fn(); + const { getAllByRole } = renderWithTheme( + + ); + + const reservedRadio = getAllByRole('radio', { name: /Reserved/i })[0]; + await userEvent.click(reservedRadio); + + expect(onIPModeChange).toHaveBeenCalledWith('reserved'); + }); + + it('should call onReservedIPSelect with null when switching to auto mode', async () => { + const onReservedIPSelect = vi.fn(); + let currentValue: 'auto' | 'reserved' = 'reserved'; + const handleIPModeChange = (mode: 'auto' | 'reserved') => { + currentValue = mode; + // Simulate clearing the selected IP when switching to auto + onReservedIPSelect(null); + }; + + const { getAllByRole, rerender } = renderWithTheme( + + ); + + const autoRadio = getAllByRole('radio', { name: /Auto-assigned/i })[0]; + await userEvent.click(autoRadio); + + // Re-render with updated value + rerender( + + ); + + expect(onReservedIPSelect).toHaveBeenCalledWith(null); + }); + }); + + describe('Reserved IP Dropdown', () => { + it('should show helper text when no region is selected', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const dropdown = getByLabelText('Reserved IP Address'); + expect(dropdown).toBeInTheDocument(); + expect( + getByText('Select a region to see available reserved IPs.') + ).toBeInTheDocument(); + }); + + it('should fetch and display unassigned reserved IPs for the selected region', async () => { + const reservedIPs: IPAddress[] = [ + reservedIPsFactory.build({ + address: '192.0.2.1', + assigned_entity: null, + region: 'us-east', + }), + reservedIPsFactory.build({ + address: '192.0.2.2', + assigned_entity: null, + region: 'us-east', + }), + reservedIPsFactory.build({ + address: '192.0.2.3', + assigned_entity: { + id: 123, + label: 'test-linode', + type: 'linode', + url: '/linodes/123', + }, + region: 'us-east', + }), + ]; + + server.use( + http.get('*/v4beta/networking/reserved/ips', () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getByLabelText, getByText, queryByText } = renderWithTheme( + + ); + + const dropdown = getByLabelText('Reserved IP Address'); + await userEvent.click(dropdown); + + // Should show unassigned IPs + expect(getByText('192.0.2.1')).toBeInTheDocument(); + expect(getByText('192.0.2.2')).toBeInTheDocument(); + + // Should not show assigned IP + expect(queryByText('192.0.2.3')).not.toBeInTheDocument(); + }); + + it('should filter reserved IPs by region', async () => { + const reservedIPs: IPAddress[] = [ + reservedIPsFactory.build({ + address: '192.0.2.1', + assigned_entity: null, + region: 'us-east', + }), + reservedIPsFactory.build({ + address: '192.0.2.2', + assigned_entity: null, + region: 'us-west', + }), + ]; + + server.use( + http.get('*/v4beta/networking/reserved/ips', () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getByLabelText, getByText, queryByText } = renderWithTheme( + + ); + + const dropdown = getByLabelText('Reserved IP Address'); + await userEvent.click(dropdown); + + // Should only show IPs in us-east + expect(getByText('192.0.2.1')).toBeInTheDocument(); + expect(queryByText('192.0.2.2')).not.toBeInTheDocument(); + }); + + it('should show "no options" message when no reserved IPs are available', async () => { + server.use( + http.get('*/v4beta/networking/reserved/ips', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const dropdown = getByLabelText('Reserved IP Address'); + await userEvent.click(dropdown); + + expect( + getByText('There are no available reserved IPs in the selected region.') + ).toBeInTheDocument(); + }); + + it('should call onReservedIPSelect when an IP is selected', async () => { + const onReservedIPSelect = vi.fn(); + const reservedIP = reservedIPsFactory.build({ + address: '192.0.2.1', + assigned_entity: null, + region: 'us-east', + }); + + server.use( + http.get('*/v4beta/networking/reserved/ips', () => { + return HttpResponse.json(makeResourcePage([reservedIP])); + }) + ); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const dropdown = getByLabelText('Reserved IP Address'); + await userEvent.click(dropdown); + + const ipOption = getByText('192.0.2.1'); + await userEvent.click(ipOption); + + expect(onReservedIPSelect).toHaveBeenCalledWith({ + ...reservedIP, + label: '192.0.2.1', + }); + }); + }); + + describe('Reserve IP Button', () => { + it('should show Reserve IP button when in reserved mode', async () => { + const { getAllByRole, getByText } = renderWithTheme( + + ); + + const reservedRadio = getAllByRole('radio', { name: /Reserved/i })[0]; + await userEvent.click(reservedRadio); + + expect(getByText('Reserve IP')).toBeInTheDocument(); + }); + + it('should not show Reserve IP button in auto mode', () => { + const { queryByText } = renderWithTheme(); + expect(queryByText('Reserve IP')).not.toBeInTheDocument(); + }); + + it('should open Reserve IP drawer when button is clicked', async () => { + const { getByText, getByRole } = renderWithTheme( + + ); + + const reserveButton = getByText('Reserve IP'); + await userEvent.click(reserveButton); + + expect( + getByRole('heading', { name: 'Reserve an IP Address' }) + ).toBeInTheDocument(); + }); + }); + + describe('Loading States', () => { + it('should show loading state while fetching reserved IPs', async () => { + server.use( + http.get('*/v4beta/networking/reserved/ips', async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByLabelText } = renderWithTheme( + + ); + + const dropdown = getByLabelText('Reserved IP Address'); + await userEvent.click(dropdown); + + // Component should show loading indicator (exact implementation may vary) + expect(dropdown).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('should have proper ARIA labels', () => { + const { getByRole } = renderWithTheme( + + ); + + const radioGroup = getByRole('radiogroup', { name: 'IP Address' }); + expect(radioGroup).toBeInTheDocument(); + }); + + it('should have proper radio group labeling', () => { + const { getByRole } = renderWithTheme(); + + const radioGroup = getByRole('radiogroup', { name: 'IP Address' }); + expect(radioGroup).toBeInTheDocument(); + }); + + it('should have accessible radio buttons', () => { + const { getAllByRole } = renderWithTheme(); + + const radios = getAllByRole('radio'); + expect(radios).toHaveLength(2); + expect(radios[0]).toHaveAccessibleName(/Auto-assigned/i); + expect(radios[1]).toHaveAccessibleName(/Reserved/i); + }); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/IPAddressSelection/IPAddressSelection.tsx b/packages/manager/src/features/ReservedIps/IPAddressSelection/IPAddressSelection.tsx new file mode 100644 index 00000000000..cdb6ac5bf36 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/IPAddressSelection/IPAddressSelection.tsx @@ -0,0 +1,223 @@ +import { useReservedIPsQuery } from '@linode/queries'; +import { + Autocomplete, + Box, + FormControl, + FormControlLabel, + LinkButton, + Radio, + RadioGroup, + Stack, + TooltipIcon, + Typography, +} from '@linode/ui'; +import React, { ChangeEvent } from 'react'; + +import { FormLabel } from 'src/components/FormLabel'; +import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; +import { ReserveIPDrawer } from 'src/features/ReservedIps/ReserveIPDrawer'; + +import type { IPAddress } from '@linode/api-v4'; + +type IPAddressMode = 'auto' | 'reserved'; + +// Thin wrapper to make IPAddress compatible with Autocomplete's label requirement +type IPAddressOption = IPAddress & { label: string }; + +export interface IPAddressSelectionProps { + // Header label for the selection component + label?: { + fontSize?: string; + text: string; + }; + /** + * Controlled value for IP mode - drives the radio selection + */ + mode?: IPAddressMode; + /** + * Callback fired when the IP mode changes (auto vs reserved) + */ + onIPModeChange?: (mode: IPAddressMode) => void; + /** + * Callback fired when a reserved IP is selected + */ + onReservedIPSelect?: (ip: IPAddress | null) => void; + /** + * The currently selected region ID + */ + regionId?: string; + /** + * The currently selected reserved IP address (controlled) + * Used to restore selection when component remounts + */ + selectedIP?: IPAddress | null; + /** + * Custom tooltip text for auto and reserved options + */ + tooltipText?: { + auto: string; + reserved: string; + }; +} + +/** + * Reusable IP Address Selection component + * + * Allows users to choose between auto-assigned and reserved IP addresses. + * Can be used in Linode Create, NodeBalancer Create, or any other flow + * that supports reserved IPs. + */ +export const IPAddressSelection = ({ + label = { fontSize: '14px', text: 'IP Address' }, + mode = 'auto', + onIPModeChange, + onReservedIPSelect, + regionId, + selectedIP = null, + tooltipText = { + auto: "A public IPv4 address automatically assigned to your Linode. \ + Use this for standard web traffic that doesn't require a permanent, static IP.", + reserved: + "A reserved IPv4 address is a static public IP that can be assigned to \ + Linodes in the same region. Use it for services that require a consistent IP address. \ + Charges apply while the IP is reserved, even if it's not assigned to a Linode.", + }, +}: IPAddressSelectionProps) => { + const [isReserveIPDrawerOpen, setIsReserveIPDrawerOpen] = + React.useState(false); + + const { + data: reservedIPs, + isLoading, + refetch, + } = useReservedIPsQuery({}, {}, mode === 'reserved' && Boolean(regionId)); + + const unassignedReservedIPs = React.useMemo(() => { + if (!reservedIPs?.data) { + return []; + } + + return reservedIPs.data + .filter( + (ip: IPAddress) => ip.assigned_entity === null && ip.region === regionId + ) + .map((ip: IPAddress) => ({ ...ip, label: ip.address })); + }, [reservedIPs, regionId]); + + const handleModeChange = (newMode: IPAddressMode) => { + onIPModeChange?.(newMode); + }; + + return ( + + + + {label.text} + + + + handleModeChange(value) + } + value={mode} + > + } + data-qa-ip-mode-option="auto" + key="auto" + label={ + + Auto-assigned + + + } + sx={{ alignItems: 'flex-start' }} + value="auto" + /> + } + data-qa-ip-mode-option="reserved" + key="reserved" + label={ + + Reserved + + + } + sx={{ alignItems: 'flex-start' }} + value="reserved" + /> + + {mode === 'reserved' && ( + + option.address} + helperText={ + !regionId + ? 'Select a region to see available reserved IPs.' + : undefined + } + isOptionEqualToValue={( + option: IPAddressOption, + value: IPAddressOption + ) => option.address === value.address} + label="Reserved IP Address" + loading={isLoading} + noOptionsText={ + 'There are no available reserved IPs in the selected region.' + } + onChange={( + _: React.SyntheticEvent, + selectedOption: IPAddressOption | null + ) => { + onReservedIPSelect?.(selectedOption); + }} + options={unassignedReservedIPs} + placeholder="Select" + sx={{ width: 300 }} + textFieldProps={{ + hideLabel: true, + }} + value={ + selectedIP ? { ...selectedIP, label: selectedIP.address } : null + } + /> + + + setIsReserveIPDrawerOpen(true)}> + Reserve IP + + + + + )} + + setIsReserveIPDrawerOpen(false)} + onSuccess={(ip: IPAddress) => { + // Refetch the reserved IPs list to include the newly created IP + refetch(); + // Auto-select the newly reserved IP if it's in the current region + if (ip.region === regionId) { + onReservedIPSelect?.(ip); + } + }} + open={isReserveIPDrawerOpen} + region={regionId} + /> + + ); +}; diff --git a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx new file mode 100644 index 00000000000..5327e0f983d --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx @@ -0,0 +1,440 @@ +import { regionFactory } from '@linode/utilities'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { ipAddressFactory, reservedIPsTypeFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ReserveIPDrawer } from './ReserveIPDrawer'; + +const queryMocks = vi.hoisted(() => ({ + useFlags: vi.fn().mockReturnValue({}), + useIsGeckoEnabled: vi.fn().mockReturnValue({ isGeckoLAEnabled: false }), + useRegionsQuery: vi.fn(), + useReserveIPMutation: vi.fn(), + useReservedIPTypesQuery: vi.fn(), + useUpdateReservedIPMutation: vi.fn(), + useUpdateIPMutation: vi.fn(), +})); + +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), + useRegionsQuery: queryMocks.useRegionsQuery, + useReserveIPMutation: queryMocks.useReserveIPMutation, + useReservedIPTypesQuery: queryMocks.useReservedIPTypesQuery, + useUpdateReservedIPMutation: queryMocks.useUpdateReservedIPMutation, + useUpdateIPMutation: queryMocks.useUpdateIPMutation, +})); + +vi.mock('@linode/shared', async (importOriginal) => ({ + ...(await importOriginal()), + useIsGeckoEnabled: queryMocks.useIsGeckoEnabled, +})); + +vi.mock('src/hooks/useFlags', async (importOriginal) => ({ + ...(await importOriginal()), + useFlags: queryMocks.useFlags, +})); + +const regions = regionFactory.buildList(2); +const reservedIPType = reservedIPsTypeFactory.build(); +const mockReserveIP = vi + .fn() + .mockResolvedValue( + ipAddressFactory.build({ address: '192.0.2.1', reserved: true }) + ); +const mockUpdateReservedIP = vi.fn().mockResolvedValue({}); +const mockUpdateIP = vi.fn().mockResolvedValue({}); +const mockOnClose = vi.fn(); + +const defaultMocks = () => { + queryMocks.useRegionsQuery.mockReturnValue({ + data: regions, + isLoading: false, + }); + queryMocks.useReservedIPTypesQuery.mockReturnValue({ + data: [reservedIPType], + isLoading: false, + }); + queryMocks.useReserveIPMutation.mockReturnValue({ + mutateAsync: mockReserveIP, + }); + queryMocks.useUpdateReservedIPMutation.mockReturnValue({ + mutateAsync: mockUpdateReservedIP, + }); + queryMocks.useUpdateIPMutation.mockReturnValue({ + mutateAsync: mockUpdateIP, + }); +}; + +beforeEach(() => { + defaultMocks(); + mockOnClose.mockClear(); + mockReserveIP.mockClear(); + mockUpdateReservedIP.mockClear(); + mockUpdateIP.mockClear(); +}); + +const RESERVE_IP_TITLE = 'Reserve an IP Address'; +const REGION_SELECT_TEST_ID = 'region-select'; +const RESERVE_BUTTON_LABEL = 'Reserve IP Address'; +const REGION_OPEN_BUTTON_LABEL = 'Open'; + +describe('ReserveIPDrawer - loading state', () => { + it('shows a loading spinner while data is loading', () => { + queryMocks.useRegionsQuery.mockReturnValue({ + data: null, + isLoading: true, + }); + + renderWithTheme( + + ); + + expect(screen.getByRole('progressbar')).toBeVisible(); + expect( + screen.queryByRole('button', { name: RESERVE_BUTTON_LABEL }) + ).toBeNull(); + }); + + it('shows the form once all data has loaded', () => { + renderWithTheme( + + ); + + expect(screen.queryByRole('progressbar')).toBeNull(); + expect( + screen.getByRole('button', { name: RESERVE_BUTTON_LABEL }) + ).toBeVisible(); + }); +}); + +describe('ReserveIPDrawer - create mode', () => { + it('renders the correct title', () => { + renderWithTheme( + + ); + + expect(screen.getByText(RESERVE_IP_TITLE)).toBeVisible(); + }); + + it('renders the description and docs link', () => { + renderWithTheme( + + ); + + expect(screen.getByText(/Reserve a public IPv4 address/i)).toBeVisible(); + expect(screen.getByText('Learn more')).toBeVisible(); + }); + + it('does not show the IP address field', () => { + renderWithTheme( + + ); + + expect(screen.queryByLabelText('IP Address')).toBeNull(); + }); + + it('Reserve button is disabled when no region is selected', () => { + renderWithTheme( + + ); + + expect( + screen.getByRole('button', { name: RESERVE_BUTTON_LABEL }) + ).toBeDisabled(); + }); + + it('calls reserveIP mutation and closes on successful submit', async () => { + renderWithTheme( + + ); + + const regionSelect = screen.getByTestId(REGION_SELECT_TEST_ID); + await userEvent.click( + within(regionSelect).getByRole('button', { + name: REGION_OPEN_BUTTON_LABEL, + }) + ); + await userEvent.click( + await screen.findByRole('option', { + name: new RegExp(regions[0].label, 'i'), + }) + ); + + const reserveButton = screen.getByRole('button', { + name: RESERVE_BUTTON_LABEL, + }); + await waitFor(() => expect(reserveButton).toBeEnabled()); + + await userEvent.click(reserveButton); + + await waitFor(() => { + expect(mockReserveIP).toHaveBeenCalledWith({ + region: regions[0].id, + tags: [], + }); + }); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('shows root error notice when API returns an error', async () => { + mockReserveIP.mockRejectedValueOnce([{ reason: 'Region is unavailable.' }]); + + renderWithTheme( + + ); + + const regionSelect = screen.getByTestId(REGION_SELECT_TEST_ID); + await userEvent.click( + within(regionSelect).getByRole('button', { + name: REGION_OPEN_BUTTON_LABEL, + }) + ); + await userEvent.click( + await screen.findByRole('option', { + name: new RegExp(regions[0].label, 'i'), + }) + ); + + const reserveButton = screen.getByRole('button', { + name: RESERVE_BUTTON_LABEL, + }); + await waitFor(() => expect(reserveButton).toBeEnabled()); + await userEvent.click(reserveButton); + + await waitFor(() => { + expect(screen.getByText('Region is unavailable.')).toBeVisible(); + }); + }); +}); + +describe('ReserveIPDrawer - edit mode', () => { + const existingIP = ipAddressFactory.build({ + address: '198.51.100.5', + region: regions[0].id, + reserved: true, + tags: ['prod'], + }); + + it('renders the correct title', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Edit Reserved IP')).toBeVisible(); + }); + + it('shows the IP address field', () => { + renderWithTheme( + + ); + + expect(screen.getByText('IP Address')).toBeVisible(); + }); + + it('region select is disabled', async () => { + renderWithTheme( + + ); + + const regionCombobox = screen.getByRole('combobox', { name: /region/i }); + expect(regionCombobox).toBeDisabled(); + }); + + it('Save button is disabled until the form is dirtied', async () => { + renderWithTheme( + + ); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('calls updateReservedIP mutation and closes on successful submit', async () => { + renderWithTheme( + + ); + + // Add a new tag to dirty the form + const tagsInput = screen.getByRole('combobox', { name: /tags/i }); + await userEvent.type(tagsInput, 'staging{enter}'); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + await waitFor(() => expect(saveButton).toBeEnabled()); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(mockUpdateReservedIP).toHaveBeenCalled(); + }); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); + +describe('ReserveIPDrawer - reserve mode', () => { + const existingIP = ipAddressFactory.build({ + address: '203.0.113.10', + region: regions[1].id, + reserved: false, + tags: [], + }); + + it('renders the correct title', () => { + renderWithTheme( + + ); + + expect(screen.getByText(RESERVE_IP_TITLE)).toBeVisible(); + }); + + it('shows the IP address as a disabled field', () => { + renderWithTheme( + + ); + + expect(screen.getByText('203.0.113.10')).toBeVisible(); + }); + + it('region select is disabled', () => { + renderWithTheme( + + ); + + expect(screen.getByRole('combobox', { name: /region/i })).toBeDisabled(); + }); + + it('Reserve button is enabled without any user interaction', () => { + renderWithTheme( + + ); + + expect( + screen.getByRole('button', { name: RESERVE_BUTTON_LABEL }) + ).toBeEnabled(); + }); + + it('calls updatedIP mutation and closes on submit', async () => { + renderWithTheme( + + ); + + await userEvent.click( + screen.getByRole('button', { name: RESERVE_BUTTON_LABEL }) + ); + + await waitFor(() => { + expect(mockUpdateIP).toHaveBeenCalled(); + }); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); + +describe('ReserveIPDrawer - cancel button', () => { + it('calls onClose when Cancel is clicked', async () => { + renderWithTheme( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(mockOnClose).toHaveBeenCalled(); + }); +}); + +describe('ReserveIPDrawer - pricing', () => { + it('shows the monthly price in create mode once a region is selected', async () => { + renderWithTheme( + + ); + + const regionSelect = screen.getByTestId(REGION_SELECT_TEST_ID); + await userEvent.click( + within(regionSelect).getByRole('button', { + name: REGION_OPEN_BUTTON_LABEL, + }) + ); + await userEvent.click( + await screen.findByRole('option', { + name: new RegExp(regions[0].label, 'i'), + }) + ); + + await waitFor(() => { + expect( + screen.getByText( + (text) => text.startsWith('$') && text.endsWith(' / mo.') + ) + ).toBeVisible(); + }); + }); + + it('does not show the price in edit mode', () => { + const editIP = ipAddressFactory.build({ + region: regions[0].id, + reserved: true, + }); + + renderWithTheme( + + ); + + expect( + screen.queryByText((text) => text.startsWith('$') && text.endsWith('/mo')) + ).toBeNull(); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx new file mode 100644 index 00000000000..feb55bc3497 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx @@ -0,0 +1,303 @@ +/* Reserve IP Drawer +* +* Usage: + + +// Edit reserved IP (allows editing tags, but not region or IP address) + + +// Reserve an existing IP (shows IP as readonly) + +*/ +import { + useRegionsQuery, + useReservedIPTypesQuery, + useReserveIPMutation, + useUpdateIPMutation, + useUpdateReservedIPMutation, +} from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; +import { + ActionsPanel, + Box, + CircleProgress, + Drawer, + Notice, + Stack, + Typography, +} from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; + +import { Link } from 'src/components/Link'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; +import { useFlags } from 'src/hooks/useFlags'; +import { + getDCSpecificPriceByType, + renderMonthlyPriceToCorrectDecimalPlace, +} from 'src/utilities/pricing/dynamicPricing'; + +import { RESERVE_IP_DESCRIPTION, RESERVED_IPS_DOCS_LINK } from './constants'; + +import type { APIError, IPAddress } from '@linode/api-v4'; +import type { TagOption } from 'src/components/TagsInput/TagsInput'; + +export type ReserveIPDrawerMode = 'create' | 'edit' | 'reserve'; + +export interface ReserveIPDrawerProps { + /** + * The IPAddress object to pre-populate the form. Provides the address (shown + * readonly in reserve mode), region, and tags. Required when mode is 'edit' or 'reserve'. + */ + ipAddress?: IPAddress; + mode: ReserveIPDrawerMode; + onClose: () => void; + onSuccess?: (ip: IPAddress) => void; + open: boolean; + // Optional region text to display when mode is 'create'. This is to show region + // selected and disabled while reserving an IP during create Linode flow. + region?: string; +} + +interface ReserveIPFormValues { + region: string; + tags: TagOption[]; +} + +const reserveIPDrawerConfig: Record< + ReserveIPDrawerMode, + { submitLabel: string; title: string } +> = { + create: { + submitLabel: 'Reserve IP Address', + title: 'Reserve an IP Address', + }, + edit: { + submitLabel: 'Save', + title: 'Edit Reserved IP', + }, + reserve: { + submitLabel: 'Reserve IP Address', + title: 'Reserve an IP Address', + }, +}; + +export const ReserveIPDrawer = (props: ReserveIPDrawerProps) => { + const { ipAddress, mode, onClose, open } = props; + + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); + const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); + const { data: reservedIPTypes, isLoading: isReservedIPTypesLoading } = + useReservedIPTypesQuery(); + + const isLoading = isRegionsLoading || isReservedIPTypesLoading; + const { mutateAsync: reserveIP } = useReserveIPMutation(); + const { mutateAsync: updateReservedIP } = useUpdateReservedIPMutation( + ipAddress?.address ?? '' + ); + const { mutateAsync: updateIP } = useUpdateIPMutation( + ipAddress?.address ?? '' + ); + const { enqueueSnackbar } = useSnackbar(); + + const isRegionDisabled = + mode === 'edit' || mode === 'reserve' || Boolean(props.region); + + const { + control, + formState: { errors, isDirty, isSubmitting, isValid }, + handleSubmit, + reset, + setError, + } = useForm({ + mode: 'onChange', + values: { + region: props.region ?? ipAddress?.region ?? '', + tags: (ipAddress?.tags ?? []).map((t) => ({ label: t, value: t })), + }, + }); + + const isSubmitDisabled = + isSubmitting || + (mode === 'create' ? !isValid : mode === 'edit' ? !isDirty : false); + + const selectedRegion = useWatch({ control, name: 'region' }); + + const reservedIPPrice = getDCSpecificPriceByType({ + regionId: selectedRegion, + type: reservedIPTypes?.[0], + }); + + const handleClose = () => { + onClose(); + }; + + const onSubmit = async (values: ReserveIPFormValues) => { + try { + const tags = values.tags.map((tag) => tag.value); + + switch (mode) { + case 'create': + const created = await reserveIP({ region: values.region, tags }); + enqueueSnackbar(`${created.address} has been reserved`, { + variant: 'success', + }); + props.onSuccess?.(created); + break; + case 'edit': + const edited = await updateReservedIP({ + address: ipAddress?.address ?? '', + tags, + }); + enqueueSnackbar(`${ipAddress?.address} has been updated`, { + variant: 'success', + }); + props.onSuccess?.(edited); + break; + case 'reserve': + const reserved = await updateIP({ + address: ipAddress?.address ?? '', + rdns: ipAddress?.rdns ?? null, + reserved: true, + }); + enqueueSnackbar(`${ipAddress?.address} has been reserved`, { + variant: 'success', + }); + props.onSuccess?.(reserved); + break; + default: + return; + } + handleClose(); + } catch (apiErrors) { + for (const error of apiErrors as APIError[]) { + if (error?.field === 'region' || error?.field === 'tags') { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }; + + return ( + reset()} + open={open} + title={reserveIPDrawerConfig[mode].title} + > + {isLoading ? ( + + ) : ( +
+ + {mode !== 'edit' && ( + + {RESERVE_IP_DESCRIPTION} +
+ Learn more. +
+ )} + + {errors.root?.message && ( + + {errors.root.message} + + )} + + {(mode === 'reserve' || mode === 'edit') && ipAddress?.address && ( + + + IP Address + + ({ + color: theme.palette.text.primary, + display: 'block', + marginTop: theme.spacingFunction(8), + })} + > + {ipAddress.address} + + + )} + + ( + field.onChange(region?.id ?? '')} + regions={regions ?? []} + value={field.value} + /> + )} + rules={ + mode === 'create' ? { required: 'Region is required.' } : {} + } + /> + + ( + + )} + /> + {reservedIPPrice && mode !== 'edit' && ( + ({ color: theme.palette.text.secondary })} + variant="body1" + > + {`$${renderMonthlyPriceToCorrectDecimalPlace( + Number(reservedIPPrice) + )} / mo.`} + + )} +
+ + + + )} +
+ ); +}; diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.test.tsx new file mode 100644 index 00000000000..ed126684a21 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.test.tsx @@ -0,0 +1,62 @@ +import { userEvent } from '@testing-library/user-event'; +import * as React from 'react'; + +import { reservedIPsFactory } from 'src/factories/networking'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ReservedIpsActionMenu } from './ReservedIpsActionMenu'; + +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; + +describe('ReservedIpsActionMenu', () => { + const mockHandlers: ReservedIpsActionHandlers = { + onEdit: vi.fn(), + onUnreserve: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the action menu with the correct aria-label', () => { + const ip = reservedIPsFactory.build({ address: '203.0.113.5' }); + + const { getByLabelText } = renderWithTheme( + + ); + + expect( + getByLabelText('Action menu for Reserved IP 203.0.113.5') + ).toBeVisible(); + }); + + it('calls onEdit when Edit is clicked', async () => { + const ip = reservedIPsFactory.build(); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + await userEvent.click( + getByLabelText(`Action menu for Reserved IP ${ip.address}`) + ); + await userEvent.click(getByText('Edit')); + + expect(mockHandlers.onEdit).toHaveBeenCalledWith(ip); + }); + + it('calls onUnreserve when Unreserve is clicked', async () => { + const ip = reservedIPsFactory.build(); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + await userEvent.click( + getByLabelText(`Action menu for Reserved IP ${ip.address}`) + ); + await userEvent.click(getByText('Unreserve')); + + expect(mockHandlers.onUnreserve).toHaveBeenCalledWith(ip); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.tsx new file mode 100644 index 00000000000..e631032b233 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { IPAddress } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +export interface ReservedIpsActionHandlers { + onEdit: (ip: IPAddress) => void; + onUnreserve: (ip: IPAddress) => void; +} + +interface Props { + handlers: ReservedIpsActionHandlers; + ip: IPAddress; +} + +export const ReservedIpsActionMenu = ({ handlers, ip }: Props) => { + const actions: Action[] = [ + { + onClick: () => handlers.onEdit(ip), + title: 'Edit', + }, + { + onClick: () => handlers.onUnreserve(ip), + title: 'Unreserve', + }, + ]; + + return ( + + ); +}; diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx new file mode 100644 index 00000000000..f4e5aa4c5b7 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx @@ -0,0 +1,207 @@ +import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; +import * as React from 'react'; + +import { reservedIPsFactory } from 'src/factories/networking'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { ReservedIpsLanding } from './ReservedIpsLanding'; +import { headers } from './ReservedIpsLandingEmptyStateData'; + +const queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({ data: { restricted: false } }), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useProfile: queryMocks.useProfile, + }; +}); + +beforeAll(() => mockMatchMedia()); + +const loadingTestId = 'circle-progress'; +const reservedIPsEndpoint = '*/networking/reserved/ips'; + +describe('Reserved IPs Landing', () => { + it('renders loading state initially', async () => { + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId } = renderWithTheme(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 10000, + }); + }); + + it('renders the empty state when there are no reserved IPs', async () => { + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + expect(getByText(headers.description)).toBeInTheDocument(); + }); + + it('renders the table with reserved IPs', async () => { + const reservedIPs = reservedIPsFactory.buildList(3, { + region: 'us-east', + reserved: true, + }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getAllByText, getByTestId, queryAllByText } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 10000, + }); + + // Table column headers + getAllByText('IP Address'); + getAllByText('Assigned Resource'); + getAllByText('Region'); + getAllByText('Tags'); + + // Check mocked IP addresses rendered in the table + queryAllByText(reservedIPs[0].address); + }); + + it('renders the "Reserve an IP Address" button', async () => { + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { container, getByTestId } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const reserveIPButton = container.querySelector('button'); + + expect(reserveIPButton).toBeInTheDocument(); + expect(reserveIPButton).toHaveTextContent('Reserve an IP Address'); + }); + + it('renders a row with action menu for each reserved IP', async () => { + const reservedIPs = reservedIPsFactory.buildList(3, { + assigned_entity: null, + reserved: true, + }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 10000, + }); + + const actionMenu = getByLabelText( + `Action menu for Reserved IP ${reservedIPs[0].address}` + ); + expect(actionMenu).toBeInTheDocument(); + }); + + it('opens the action menu with correct options', async () => { + const reservedIPs = reservedIPsFactory.buildList(1, { + assigned_entity: null, + reserved: true, + }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 10000, + }); + + const actionMenu = getByLabelText( + `Action menu for Reserved IP ${reservedIPs[0].address}` + ); + + await fireEvent.click(actionMenu); + + getByText('Edit'); + getByText('Unreserve'); + }); + + describe('Restricted users', () => { + it('should have the "Reserve an IP Address" button disabled for restricted users', async () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: true } }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { container, getByTestId } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const reserveIPButton = container.querySelector('button'); + + expect(reserveIPButton).toBeInTheDocument(); + expect(reserveIPButton).toHaveTextContent('Reserve an IP Address'); + }); + + it('should have the "Reserve an IP Address" button enabled for users with full access', async () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: false } }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { container, getByTestId } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const reserveIPButton = container.querySelector('button'); + + expect(reserveIPButton).toBeInTheDocument(); + expect(reserveIPButton).toHaveTextContent('Reserve an IP Address'); + expect(reserveIPButton).toBeEnabled(); + }); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx index a51b489f1a3..b9f083b1f1b 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx @@ -1,28 +1,151 @@ -import { Notice } from '@linode/ui'; +import { useReservedIPsQuery } from '@linode/queries'; +import { CircleProgress, ErrorState } from '@linode/ui'; import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { RESERVED_IPS_DOCS_LINK } from '../constants'; +import { ReserveIPDrawer } from '../ReserveIPDrawer'; import { ReservedIpsLandingEmptyState } from './ReservedIpsLandingEmptyState'; +import { ReservedIpsLandingTable } from './ReservedIpsLandingTable'; + +import type { ReserveIPDrawerMode } from '../ReserveIPDrawer'; +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; +import type { IPAddress } from '@linode/api-v4'; + +const preferenceKey = 'reserved-ips'; +import { UnreserveIPDialog } from './UnreserveIPDialog'; export const ReservedIpsLanding = () => { - // TODO: Replace with actual data check once API queries are implemented - const showEmptyState = true; + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + const [drawerMode, setDrawerMode] = + React.useState('create'); + const [selectedIP, setSelectedIP] = React.useState(); + + const [isUnreserveDialogOpen, setIsUnreserveDialogOpen] = React.useState(false); + + const pagination = usePaginationV2({ + currentRoute: '/reserved-ips', + preferenceKey, + }); + + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'address', + }, + from: '/reserved-ips', + }, + preferenceKey: `${preferenceKey}-order`, + }); + + const filter = { + ['+order']: order, + ['+order_by']: orderBy, + }; + + const { + data: reservedIps, + error, + isLoading, + } = useReservedIPsQuery( + { + page: pagination.page, + page_size: pagination.pageSize, + }, + filter + ); + + const openDrawer = (mode: ReserveIPDrawerMode, ip?: IPAddress) => { + setSelectedIP(ip); + setDrawerMode(mode); + setIsDrawerOpen(true); + }; + + const closeDrawer = () => { + setIsDrawerOpen(false); + setSelectedIP(undefined); + }; - if (showEmptyState) { - return ; + const handlers: ReservedIpsActionHandlers = { + onEdit: (ip) => openDrawer('edit', ip), + onUnreserve: (ip) => { + setSelectedIP(ip); + setIsUnreserveDialogOpen(true); + }, + }; + + if (isLoading) { + return ; } + + if (error) { + return ( + + ); + } + + if (reservedIps?.results === 0) { + return ( + <> + openDrawer('create')} + /> + + + ); + } + return ( <> openDrawer('create')} spacingBottom={16} - title="Reserved IPs" + title="Reserved IP Addresses" + /> + + - Reserved IPs is coming soon... + {selectedIP && ( + { + setIsUnreserveDialogOpen(false); + setSelectedIP(undefined); + }} + open={isUnreserveDialogOpen} + /> + )} ); }; diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx index c842234ce89..00eb698c836 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx @@ -10,7 +10,13 @@ import { linkAnalyticsEvent, } from './ReservedIpsLandingEmptyStateData'; -export const ReservedIpsLandingEmptyState = () => { +interface Props { + openReserveIPDrawer: () => void; +} + +export const ReservedIpsLandingEmptyState = ({ + openReserveIPDrawer, +}: Props) => { return ( @@ -18,9 +24,7 @@ export const ReservedIpsLandingEmptyState = () => { buttonProps={[ { children: 'Reserve an IP Address', - onClick: () => { - // TODO: Open Reserve IP create drawer once ready - }, + onClick: openReserveIPDrawer, }, ]} descriptionMaxWidth={500} diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx new file mode 100644 index 00000000000..041e32a4a0a --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx @@ -0,0 +1,176 @@ +import * as React from 'react'; + +import { reservedIPsFactory } from 'src/factories/networking'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ReservedIpsLandingRow } from './ReservedIpsLandingRow'; + +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; + +vi.mock('@mui/material', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMediaQuery: vi.fn().mockReturnValue(true), + }; +}); + +const mockHandlers: ReservedIpsActionHandlers = { + onEdit: vi.fn(), + onUnreserve: vi.fn(), +}; + +describe('ReservedIpsLandingRow', () => { + it('renders the IP address', () => { + const ip = reservedIPsFactory.build({ address: '203.0.113.10' }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('203.0.113.10')).toBeVisible(); + }); + + it('renders the assigned entity label as a link for a linode', () => { + const ip = reservedIPsFactory.build({ + assigned_entity: { + id: 123, + label: 'my-linode', + type: 'linode', + url: '/v4/linode/instances/123', + }, + }); + + const { getByRole } = renderWithTheme( + + ); + + const link = getByRole('link', { name: /my-linode/i }); + expect(link).toBeVisible(); + expect(link).toHaveAttribute('href', '/linodes/123'); + }); + + it('renders the assigned entity label as a link for a nodebalancer', () => { + const ip = reservedIPsFactory.build({ + assigned_entity: { + id: 456, + label: 'my-nodebalancer', + type: 'nodebalancer', + url: '/v4/nodebalancers/456', + }, + }); + + const { getByRole } = renderWithTheme( + + ); + + const link = getByRole('link', { name: /my-nodebalancer/i }); + expect(link).toBeVisible(); + expect(link).toHaveAttribute('href', '/nodebalancers/456'); + }); + + it('renders "Unassigned" when there is no assigned entity', () => { + const ip = reservedIPsFactory.build({ assigned_entity: null }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Unassigned')).toBeVisible(); + }); + + it('renders the region label', () => { + const ip = reservedIPsFactory.build(); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('PL, Krakow')).toBeVisible(); + }); + + it('renders visible tags as chips', () => { + const ip = reservedIPsFactory.build({ + tags: ['web', 'production'], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('web')).toBeInTheDocument(); + expect(getByText('production')).toBeInTheDocument(); + }); + + it('renders a ShowMore chip when there are more than 2 tags', () => { + const ip = reservedIPsFactory.build({ + tags: ['web', 'production', 'staging', 'db', 'internal'], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('web')).toBeVisible(); + expect(getByText('production')).toBeVisible(); + expect(getByText('+3')).toBeVisible(); + }); + + it('renders no tags when the tags array is empty', () => { + const ip = reservedIPsFactory.build({ tags: [] }); + + const { queryByTestId } = renderWithTheme( + + ); + + expect(queryByTestId('show-more')).not.toBeInTheDocument(); + }); + + it('renders the action menu', () => { + const ip = reservedIPsFactory.build(); + + const { getByLabelText } = renderWithTheme( + + ); + + expect( + getByLabelText(`Action menu for Reserved IP ${ip.address}`) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx new file mode 100644 index 00000000000..522d4021e4a --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx @@ -0,0 +1,123 @@ +import { Badge } from '@akamai/cds-components/react/Badge'; +import { TableCell, TableRow } from '@akamai/cds-components/react/Table'; +import { Hidden, Stack, styled } from '@linode/ui'; +import { splitAt } from '@linode/utilities'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { ShowMore } from 'src/components/ShowMore/ShowMore'; + +import { ReservedIpsActionMenu } from './ReservedIpsActionMenu'; + +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; +import type { IPAddress } from '@linode/api-v4'; + +interface Props { + handlers: ReservedIpsActionHandlers; + ip: IPAddress; + regionLabel: string; +} + +/** + * Derives the Cloud Manager route from the assigned entity. + * API URLs (e.g. `/v4/linode/instances/123`) are not valid app routes. + */ +const getEntityRoute = ( + entity: IPAddress['assigned_entity'] +): null | string => { + if (!entity) { + return null; + } + + switch (entity.type) { + case 'linode': + return `/linodes/${entity.id}`; + case 'nodebalancer': + return `/nodebalancers/${entity.id}`; + default: + return null; + } +}; + +export const ReservedIpsLandingRow = ({ handlers, ip, regionLabel }: Props) => { + const { address, assigned_entity, tags } = ip; + const entityRoute = getEntityRoute(assigned_entity); + + return ( + + {address} + + {assigned_entity && entityRoute ? ( + + {assigned_entity.label} + + ) : ( + 'Unassigned' + )} + + + {regionLabel} + + + {tags?.length > 0 ? : ''} + + + + + + + + ); +}; + +/** + * Displays up to 3 non-clickable tag chips, with a "+N" ShowMore popover + * for any overflow tags. + */ +const MAX_VISIBLE_TAGS = 2; + +const TagsList = ({ tags }: { tags: string[] }) => { + const [visible, overflow] = splitAt(MAX_VISIBLE_TAGS, tags); + + return ( + <> + {visible.map((tag) => ( + {tag} + ))} + {overflow.length > 0 && ( + ( + + {items.map((tag) => ( + {tag} + ))} + + )} + /> + )} + + ); +}; + +const StyledActionMenuCell = styled(TableCell, { + label: 'StyledActionMenuCell', +})(({ theme }) => ({ + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-end', + maxWidth: 40, + '& button': { + backgroundColor: 'transparent', + color: theme.tokens.alias.Content.Icon.Primary.Default, + padding: 0, + }, + '& button:hover': { + backgroundColor: 'transparent', + color: theme.tokens.alias.Content.Icon.Primary.Hover, + }, +})); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx new file mode 100644 index 00000000000..7e88fcf87b5 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx @@ -0,0 +1,146 @@ +import { Pagination } from '@akamai/cds-components/react/Pagination'; +import { + Table, + TableBody, + TableHead, + TableHeaderCell, + TableRow, +} from '@akamai/cds-components/react/Table'; +import { useRegionsQuery } from '@linode/queries'; +import { Hidden } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; + +import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; + +import { ReservedIpsLandingRow } from './ReservedIpsLandingRow'; + +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; +import type { IPAddress } from '@linode/api-v4'; +import type { Order } from '@linode/utilities'; +import type { PaginationPropsV2 } from 'src/hooks/usePaginationV2'; + +const DEFAULT_PAGE_SIZES = [25, 50, 100]; + +interface Props { + data: IPAddress[] | undefined; + handleOrderChange: (newOrderBy: string, newOrder: Order) => void; + handlers: ReservedIpsActionHandlers; + order: 'asc' | 'desc'; + orderBy: string; + pagination: PaginationPropsV2; + results: number | undefined; +} + +export const ReservedIpsLandingTable = ({ + data, + handleOrderChange, + handlers, + order, + orderBy, + pagination, + results, +}: Props) => { + const theme = useTheme(); + + const { data: regions } = useRegionsQuery(); + + const regionLabelMap = React.useMemo(() => { + const map = new Map(); + regions?.forEach((r) => map.set(r.id, r.label)); + return map; + }, [regions]); + + return ( + <> +
+ + + + + handleOrderChange('address', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'address' ? order : undefined} + > + IP Address + + Assigned Resource + + + handleOrderChange( + 'region', + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={orderBy === 'region' ? order : undefined} + > + Region + + + Tags + + + + + + + {data?.length === 0 ? ( + + ) : ( + data?.map((ip: IPAddress) => ( + + )) + )} + +
+
+ {(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={DEFAULT_PAGE_SIZES} + style={{ + borderLeft: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderTop: 0, + }} + /> + )} + + ); +}; diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/UnreserveIPDialog.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/UnreserveIPDialog.test.tsx new file mode 100644 index 00000000000..b9423c38091 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/UnreserveIPDialog.test.tsx @@ -0,0 +1,152 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { ipAddressFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UnreserveIPDialog } from './UnreserveIPDialog'; + +const mockMutateAsync = vi.fn(); +const mockReset = vi.fn(); +const mockEnqueueSnackbar = vi.fn(); +const mockOnClose = vi.fn(); + +const queryMocks = vi.hoisted(() => ({ + useUnReserveIPMutation: vi.fn(), +})); + +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), + useUnReserveIPMutation: queryMocks.useUnReserveIPMutation, +})); + +vi.mock('notistack', async (importOriginal) => ({ + ...(await importOriginal()), + useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }), +})); + +const ipAddress = ipAddressFactory.build({ address: '203.0.113.10' }); + +const defaultProps = { + ipAddress, + onClose: mockOnClose, + open: true, +}; + +beforeEach(() => { + mockMutateAsync.mockReset(); + mockReset.mockReset(); + mockEnqueueSnackbar.mockReset(); + mockOnClose.mockReset(); + + queryMocks.useUnReserveIPMutation.mockReturnValue({ + isPending: false, + mutateAsync: mockMutateAsync, + reset: mockReset, + }); +}); + +describe('UnreserveIPDialog', () => { + it('renders the dialog with the correct title', () => { + renderWithTheme(); + + expect(screen.getByText('Unreserve 203.0.113.10')).toBeVisible(); + }); + + it('renders the confirmation message', () => { + renderWithTheme(); + + expect( + screen.getByText( + /Unreserving this IP will remove it from your reserved list/i + ) + ).toBeVisible(); + }); + + it('renders the Unreserve and Cancel buttons', () => { + renderWithTheme(); + + expect(screen.getByRole('button', { name: 'Unreserve' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeVisible(); + }); + + it('does not render when open is false', () => { + renderWithTheme(); + + expect(screen.queryByText('Unreserve 203.0.113.10?')).toBeNull(); + }); + + it('calls onClose when Cancel is clicked', async () => { + renderWithTheme(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('shows success snackbar, and closes on successful submit', async () => { + mockMutateAsync.mockResolvedValueOnce({}); + + renderWithTheme(); + + await userEvent.click(screen.getByRole('button', { name: 'Unreserve' })); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled(); + }); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith( + '203.0.113.10 has been unreserved.', + { variant: 'success' } + ); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('shows an error notice when the API call fails', async () => { + mockMutateAsync.mockRejectedValueOnce([ + { reason: 'IP address could not be unreserved.' }, + ]); + + renderWithTheme(); + + await userEvent.click(screen.getByRole('button', { name: 'Unreserve' })); + + await waitFor(() => { + expect( + screen.getByText('IP address could not be unreserved.') + ).toBeVisible(); + }); + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('clears the error when the retry succeeds after a prior failure', async () => { + mockMutateAsync + .mockRejectedValueOnce([{ reason: 'Temporary network error.' }]) + .mockResolvedValueOnce({}); + + renderWithTheme(); + + // First attempt fails — error appears + await userEvent.click(screen.getByRole('button', { name: 'Unreserve' })); + await waitFor(() => + expect(screen.getByText('Temporary network error.')).toBeVisible() + ); + + // Retry — should succeed and call onClose + await userEvent.click(screen.getByRole('button', { name: 'Unreserve' })); + await waitFor(() => expect(mockOnClose).toHaveBeenCalled()); + }); + + it('disables both buttons while the request is pending', () => { + queryMocks.useUnReserveIPMutation.mockReturnValue({ + isPending: true, + mutateAsync: mockMutateAsync, + reset: mockReset, + }); + + renderWithTheme(); + + expect(screen.getByRole('button', { name: 'Unreserve' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/UnreserveIPDialog.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/UnreserveIPDialog.tsx new file mode 100644 index 00000000000..72abcd76225 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/UnreserveIPDialog.tsx @@ -0,0 +1,78 @@ +import { useUnReserveIPMutation } from '@linode/queries'; +import { ActionsPanel, Notice, Typography } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { IPAddress } from '@linode/api-v4'; + +interface Props { + ipAddress: IPAddress; + onClose: () => void; + open: boolean; +} + +export const UnreserveIPDialog = (props: Props) => { + const { ipAddress, onClose, open } = props; + const { enqueueSnackbar } = useSnackbar(); + + const { isPending, mutateAsync, reset } = useUnReserveIPMutation( + ipAddress.address + ); + + const [error, setError] = React.useState(null); + + // Reset mutation state when dialog opens + React.useEffect(() => { + if (open) { + reset(); + setError(null); + } + }, [open, reset]); + + const handleSubmit = async () => { + try { + await mutateAsync(); + enqueueSnackbar(`${ipAddress.address} has been unreserved.`, { + variant: 'success', + }); + onClose(); + } catch (err) { + setError( + getAPIErrorOrDefault(err, 'Failed to unreserve IP address.')[0]?.reason + ); + } + }; + + return ( + + } + onClose={onClose} + open={open} + title={`Unreserve ${ipAddress.address}`} + > + {error && } + + Unreserving this IP will remove it from your reserved list and make it + unavailable to assign. This action can't be undone. + + + ); +}; diff --git a/packages/manager/src/features/ReservedIps/constants.ts b/packages/manager/src/features/ReservedIps/constants.ts new file mode 100644 index 00000000000..b00f6e680b4 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/constants.ts @@ -0,0 +1,5 @@ +export const RESERVE_IP_DESCRIPTION = + 'Reserve a public IPv4 address from the selected region. It will be added to your reserved IPs and ready to assign to resources, such as Linodes or NodeBalancers. Reserved IPs and resources must be in the same region.'; + +export const RESERVED_IPS_DOCS_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/reserved-ips'; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index d5b6997d94e..6c1eeb7abf3 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -51,7 +51,7 @@ interface MenuLink { export const UserMenuPopover = (props: UserMenuPopoverProps) => { const { anchorEl, isDrawerOpen, onClose, onDrawerOpen } = props; const sessionContext = React.useContext(switchAccountSessionContext); - const { limitsEvolution, iamLimitedAvailabilityBadges } = useFlags(); + const { limitsEvolution, iamNewBadge } = useFlags(); const { isProxyOrDelegateUserType, isParentUserType, @@ -142,7 +142,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { { display: isIAMEnabled ? 'Identity & Access' : 'Users & Grants', to: isIAMEnabled ? '/iam' : '/users', - isNew: isIAMEnabled && iamLimitedAvailabilityBadges, + isNew: isIAMEnabled && iamNewBadge, }, { display: 'Quotas', @@ -166,7 +166,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { to: '/account-settings', }, ], - [isIAMEnabled, limitsEvolution, iamLimitedAvailabilityBadges] + [isIAMEnabled, limitsEvolution, iamNewBadge] ); const renderLink = (link: MenuLink) => { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx index c459d13cdab..874a824c33e 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx @@ -52,6 +52,7 @@ const props = { label: 'subnet-1', linodes: [], nodebalancers: [], + databases: [], created: '', updated: '', } as Subnet, diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx new file mode 100644 index 00000000000..d11fa0c02a8 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx @@ -0,0 +1,132 @@ +import * as React from 'react'; + +import { + databaseInstanceFactory, + subnetAssignedDatabaseDataFactory, +} from 'src/factories'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { SubnetDatabaseRow } from './SubnetDatabaseRow'; + +import type { DatabaseInstance } from '@linode/api-v4'; + +const mockIpv6Range = '0000:db1::/32'; +const databaseLabel = 'test-database-1'; +const mockDatabase = databaseInstanceFactory.build({ + id: 1, + label: databaseLabel, +}); + +const mockAssignedDatabase = subnetAssignedDatabaseDataFactory.build({ + id: 1, + ipv4_range: '1.1.1.1/32', + ipv6_ranges: [{ range: mockIpv6Range }], +}); + +describe('SubnetDatabaseRow', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should render SubnetDatabaseRow', () => { + const dbWithPrimary: DatabaseInstance = { + ...mockDatabase, + members: { '2.2.2.2': 'primary' }, + }; + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText(mockAssignedDatabase.ipv4_range); + getByText(mockIpv6Range); + getByText('2.2.2.2'); + }); + + it('should render SubnetDatabaseRow with multiple failover IPs', () => { + const dbWithFailovers: DatabaseInstance = { + ...mockDatabase, + members: { + '2.2.2.2': 'primary', + '2.2.2.3': 'failover', + '2.2.2.4': 'failover', + }, + }; + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText(mockAssignedDatabase.ipv4_range); + getByText(mockIpv6Range); + getByText('2.2.2.2, 2.2.2.3, 2.2.2.4'); + }); + + it('should render SubnetDatabaseRow with no members', () => { + const dbWithNoMembers = { + ...mockDatabase, + members: {}, + }; + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText(mockAssignedDatabase.ipv4_range); + getByText(mockIpv6Range); + getByText('—'); + }); + + it('should render SubnetDatabaseRow with no ipv6 ranges', () => { + const assignedDatabaseWithNoIpv6 = { + ...mockAssignedDatabase, + ipv6_ranges: null, + }; + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText(mockAssignedDatabase.ipv4_range); + getByText('—'); + }); + + it('should render SubnetDatabaseRow with HA cluster', () => { + const haDatabase = databaseInstanceFactory.build({ + id: 1, + label: databaseLabel, + cluster_size: 3, + }); + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText('HA'); + }); +}); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx new file mode 100644 index 00000000000..b4844d22940 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx @@ -0,0 +1,97 @@ +import { Box, Chip } from '@linode/ui'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { getIsLinkInactive } from 'src/features/Databases/utilities'; +import { determineNoneSingleOrMultipleWithChip } from 'src/utilities/noneSingleOrMultipleWithChip'; + +import type { + DatabaseInstance, + SubnetAssignedDatabaseData, +} from '@linode/api-v4'; + +interface Props { + assignedDatabase: SubnetAssignedDatabaseData; + database: DatabaseInstance; +} + +export const SubnetDatabaseRow = ({ assignedDatabase, database }: Props) => { + const ipv6Ranges = + assignedDatabase?.ipv6_ranges + ?.map((rangeObj) => rangeObj.range) + .filter((range) => range !== undefined) ?? []; + + const ipv6RangeContent = assignedDatabase?.ipv6_ranges + ? determineNoneSingleOrMultipleWithChip(ipv6Ranges) + : '—'; + + // For IPv4 addresses column, we display the primary and failover IPs for the database instance. + const getIPv4AddressesContent = () => { + const memberKeys = Object.keys(database.members); + + if (memberKeys.length === 0) { + return '—'; + } + // If there's only one key in members, it only contains the primary IPv4 which should be returned. + if (memberKeys.length === 1) { + return memberKeys[0]; + } + + // Retrieve primary and failover IPv4 addresses since there can be up to 2 failover IPv4 addresses for multi-node HA clusters. + const primaryIPv4 = memberKeys.find( + (key) => database.members[key] === 'primary' + ); + const failoverIPv4s = memberKeys.filter( + (key) => database.members[key] === 'failover' + ); + + return [primaryIPv4, ...failoverIPv4s].join(', '); + }; + + return ( + + + ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacingFunction(8), + })} + > + {getIsLinkInactive(database.status) ? ( + database?.label + ) : ( + + {database?.label} + + )} + {database.cluster_size > 1 && ( + ({ borderColor: theme.color.green, mx: 0, my: 0 })} + variant="outlined" + /> + )} + + + {getIPv4AddressesContent()} + {assignedDatabase?.ipv4_range} + {ipv6RangeContent} + + ); +}; + +export const SubnetDatabasesTableRowHead = ( + + Database Cluster + IPv4 Address(es) + VPC IPv4 Range + VPC IPv6 Range + +); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx new file mode 100644 index 00000000000..a58575783f1 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; + +import { + databaseInstanceFactory, + subnetAssignedDatabaseDataFactory, +} from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { SubnetDatabasesTable } from './SubnetDatabasesTable'; + +const queryMocks = vi.hoisted(() => ({ + useDatabasesQuery: vi.fn().mockReturnValue({ + data: [], + }), +})); + +const mockSubnetDatabasesData = [ + subnetAssignedDatabaseDataFactory.build({ id: 1 }), +]; + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDatabasesQuery: queryMocks.useDatabasesQuery, + }; +}); + +describe('SubnetDatabasesTable', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should render table for SubnetDatabasesTable when there are assigned databases', async () => { + queryMocks.useDatabasesQuery.mockReturnValue({ + data: makeResourcePage([ + databaseInstanceFactory.build({ id: 1, label: 'test-database-1' }), + ]), + isLoading: false, + error: null, + }); + const { getByText } = renderWithTheme( + + ); + getByText('test-database-1'); + getByText('Database Cluster'); + }); + + it('should render loading state for SubnetDatabasesTable', async () => { + queryMocks.useDatabasesQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: true, + error: null, + }); + + const { getByTestId } = renderWithTheme( + + ); + getByTestId('circle-progress'); + }); + + it('should render empty state for SubnetDatabasesTable when no databases are returned', async () => { + queryMocks.useDatabasesQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: false, + error: null, + }); + + const { getByTestId } = renderWithTheme( + + ); + getByTestId('table-row-empty'); + }); + + it('should render error state for SubnetDatabasesTable', async () => { + const expectedErrorMessage = 'Failed to fetch databases'; + queryMocks.useDatabasesQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: false, + error: [{ reason: expectedErrorMessage }], + }); + + const { getByText } = renderWithTheme( + + ); + getByText(expectedErrorMessage); + }); +}); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx new file mode 100644 index 00000000000..2a3272408d4 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx @@ -0,0 +1,134 @@ +import { useDatabasesQuery } from '@linode/queries'; +import { CircleProgress } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; + +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { + SubnetDatabaseRow, + SubnetDatabasesTableRowHead, +} from './SubnetDatabaseRow'; + +import type { SubnetAssignedDatabaseData } from '@linode/api-v4'; +interface Props { + subnetDatabasesData: SubnetAssignedDatabaseData[]; +} + +export const SubnetDatabasesTable = ({ subnetDatabasesData }: Props) => { + const theme = useTheme(); + + const [pageSize, setPageSize] = React.useState(25); + const [page, setPage] = React.useState(1); + + const assignedSubnetDatabasesMap: Record = + {}; // Store assigned databases in map for easy lookup when rendering subnet database rows + const databaseIDsToFilter = subnetDatabasesData.map((database) => { + assignedSubnetDatabasesMap[database.id] = database; + return { + id: database.id, + }; + }); + + const { + data: databases, + error: databasesError, + isLoading, + } = useDatabasesQuery( + { + page_size: pageSize, + page, + }, + { + '+or': databaseIDsToFilter, + }, + true + ); + + const DatabasesTableWrapper = ({ + children, + }: { + children: React.ReactNode; + }) => ( + + + {SubnetDatabasesTableRowHead} + + {children} +
+ ); + + if (isLoading) { + return ( + + + + + + + + ); + } + + if (databasesError) { + return ( + + + + ); + } + + if (databases && databases.data.length === 0) { + return ( + + + + ); + } + + return ( + <> + + {databases?.data.map((database) => ( + + ))} + + setPage(page)} + handleSizeChange={(pageSize: number) => setPageSize(pageSize)} + page={page} + pageSize={pageSize} + sx={{ + border: 'none', + borderBottom: `1px solid ${theme.tokens.component.Table.Row.Border}`, + borderTop: `1px solid ${theme.tokens.component.Table.Row.Border}`, + }} + /> + + ); +}; diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx index 92e34e6e2a4..8d2eb2fd512 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx @@ -114,14 +114,14 @@ describe('VPC Detail Summary section', () => { }); const { getByText } = renderWithTheme(, { - flags: { nodebalancerVpc: true }, + flags: { nodebalancerVpc: true, vpcDbaasResources: true }, }); - // there is 1 subnet with 8 resources (5 Linodes, 3 nbs) + // there is 1 subnet with 11 resources (5 Linodes, 3 nbs, 3 dbs) expect(getByText('Subnets')).toBeVisible(); expect(getByText('1')).toBeVisible(); expect(getByText('Resources')).toBeVisible(); - expect(getByText('8')).toBeVisible(); + expect(getByText('11')).toBeVisible(); expect(getByText('Region')).toBeVisible(); expect(getByText('US, Newark, NJ')).toBeVisible(); diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index db6c3f55310..98deb622af5 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx @@ -19,6 +19,7 @@ import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { LKE_ENTERPRISE_AUTOGEN_VPC_WARNING } from 'src/features/Kubernetes/constants'; import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils'; import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants'; +import { useFlags } from 'src/hooks/useFlags'; import { getIsVPCLKEEnterpriseCluster, @@ -51,7 +52,9 @@ const VPCDetail = () => { isLoading, } = useVPCQuery(Number(vpcId) || -1, Boolean(vpcId)); - const flags = useIsNodebalancerVPCEnabled(); + const flags = useFlags(); + + const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); const { data: regions } = useRegionsQuery(); @@ -104,8 +107,11 @@ const VPCDetail = () => { const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? vpc.region; - const numResources = flags.isNodebalancerVPCEnabled - ? getUniqueResourcesFromSubnets(vpc.subnets) + const numResources = isNodebalancerVPCEnabled + ? getUniqueResourcesFromSubnets( + vpc.subnets, + Boolean(flags.vpcDbaasResources) + ) : getUniqueLinodesFromSubnets(vpc.subnets); const summaryData = [ @@ -115,7 +121,7 @@ const VPCDetail = () => { value: vpc.subnets.length, }, { - label: flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes', + label: isNodebalancerVPCEnabled ? 'Resources' : 'Linodes', value: numResources, }, ], diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx index d885bc3bb61..b44a0634be2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx @@ -122,7 +122,7 @@ describe('VPC Subnets table', () => { vpcRegion="" />, { - flags: { nodebalancerVpc: true }, + flags: { nodebalancerVpc: true, vpcDbaasResources: true }, } ); @@ -137,7 +137,11 @@ describe('VPC Subnets table', () => { expect(getByText('Resources')).toBeVisible(); expect( - getByText(subnet.linodes.length + subnet.nodebalancers.length) + getByText( + subnet.linodes.length + + subnet.nodebalancers.length + + subnet.databases.length + ) ).toBeVisible(); const actionMenuButton = getByLabelText( @@ -264,7 +268,7 @@ describe('VPC Subnets table', () => { it( 'should show Nodebalancer table head data when table is expanded', - { timeout: 15_000 }, + { timeout: 30000 }, async () => { const subnet = subnetFactory.build(); @@ -294,6 +298,37 @@ describe('VPC Subnets table', () => { } ); + it( + 'should show Databases table head data when table is expanded', + { timeout: 30000 }, + async () => { + const subnet = subnetFactory.build(); + + queryMocks.useSubnetsQuery.mockReturnValue({ + data: { + data: [subnet], + }, + }); + + const { getByLabelText, findByText } = renderWithTheme( + , + { flags: { nodebalancerVpc: true, vpcDbaasResources: true } } + ); + + const expandTableButton = getByLabelText(`expand ${subnet.label} row`); + await userEvent.click(expandTableButton); + + await findByText('Database Cluster'); + await findByText('IPv4 Address(es)'); + await findByText('VPC IPv4 Range'); + await findByText('VPC IPv6 Range'); + } + ); + it('should disable "Create Subnet" button when user does not have create_vpc_subnet permission', async () => { queryMocks.userPermissions.mockReturnValue({ data: { diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx index 37fea394caa..e2bf7fddf95 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx @@ -29,6 +29,7 @@ import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer'; import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils'; import { SubnetActionMenu } from 'src/features/VPCs/VPCDetail/SubnetActionMenu'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useVPCDualStack } from 'src/hooks/useVPCDualStack'; @@ -37,6 +38,7 @@ import { SUBNET_ACTION_PATH } from '../constants'; import { VPC_DETAILS_ROUTE } from '../constants'; import { SubnetAssignLinodesDrawer } from './SubnetAssignLinodesDrawer'; import { SubnetCreateDrawer } from './SubnetCreateDrawer'; +import { SubnetDatabasesTable } from './SubnetDatabasesTable'; import { SubnetDeleteDialog } from './SubnetDeleteDialog'; import { SubnetEditDrawer } from './SubnetEditDrawer'; import { SubnetLinodeRow, SubnetLinodeTableRowHead } from './SubnetLinodeRow'; @@ -90,6 +92,7 @@ export const VPCSubnetsTable = (props: Props) => { const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); const { isDualStackEnabled } = useVPCDualStack(); + const flags = useFlags(); const { data: permissions } = usePermissions( 'vpc', @@ -333,7 +336,7 @@ export const VPCSubnetsTable = (props: Props) => { )} - {`${isNodebalancerVPCEnabled ? subnet.linodes.length + uniqueNodebalancers.length : subnet.linodes.length}`} + {`${isNodebalancerVPCEnabled ? subnet.linodes.length + uniqueNodebalancers.length + (flags.vpcDbaasResources ? subnet.databases.length : 0) : subnet.linodes.length}`} @@ -403,6 +406,9 @@ export const VPCSubnetsTable = (props: Props) => {
)} + {flags.vpcDbaasResources && subnet.databases?.length > 0 && ( + + )} ); diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx index df02ebdebef..79837dd642c 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx @@ -21,6 +21,7 @@ import { VPC_LANDING_TABLE_PREFERENCE_KEY, } from 'src/features/VPCs/constants'; import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -98,7 +99,8 @@ const VPCLanding = () => { error: selectedVPCError, } = useVPCQuery(params.vpcId ?? -1, !!params.vpcId); - const flags = useIsNodebalancerVPCEnabled(); + const flags = useFlags(); + const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); if (error) { return ( @@ -168,7 +170,7 @@ const VPCLanding = () => { Subnets - {`${flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'}`} + {`${isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'}`}
@@ -176,9 +178,10 @@ const VPCLanding = () => { {vpcs?.data.map((vpc: VPC) => ( handleDeleteVPC(vpc)} handleEditVPC={() => handleEditVPC(vpc)} - isNodebalancerVPCEnabled={flags.isNodebalancerVPCEnabled} + isNodebalancerVPCEnabled={isNodebalancerVPCEnabled} key={vpc.id} vpc={vpc} /> diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx index 32f2f31e0cd..98d0d9f47ab 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx @@ -32,6 +32,7 @@ describe('VPC Table Row', () => { const { getByText, getByLabelText } = renderWithTheme( wrapWithTableBody( { const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( void; handleEditVPC: () => void; isNodebalancerVPCEnabled: boolean; @@ -27,6 +28,7 @@ export const VPCRow = ({ handleDeleteVPC, handleEditVPC, isNodebalancerVPCEnabled, + displayVPCDBaaSResources, vpc, }: Props) => { const { id, label, subnets } = vpc; @@ -36,7 +38,7 @@ export const VPCRow = ({ const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? ''; const numResources = isNodebalancerVPCEnabled - ? getUniqueResourcesFromSubnets(vpc.subnets) + ? getUniqueResourcesFromSubnets(vpc.subnets, displayVPCDBaaSResources) : getUniqueLinodesFromSubnets(vpc.subnets); const { data: permissions, isLoading } = usePermissions( diff --git a/packages/manager/src/features/VPCs/utils.test.ts b/packages/manager/src/features/VPCs/utils.test.ts index 0d6682a0337..b81b674eccb 100644 --- a/packages/manager/src/features/VPCs/utils.test.ts +++ b/packages/manager/src/features/VPCs/utils.test.ts @@ -6,6 +6,7 @@ import { import { linodeConfigFactory } from 'src/factories/linodeConfigs'; import { + subnetAssignedDatabaseDataFactory, subnetAssignedLinodeDataFactory, subnetAssignedNodebalancerDataFactory, subnetFactory, @@ -36,14 +37,23 @@ const subnetNodeBalancerInfoId1 = subnetAssignedNodebalancerDataFactory.build({ const subnetNodeBalancerInfoId3 = subnetAssignedNodebalancerDataFactory.build({ id: 3, }); +const subnetdatabaseInfoId1 = subnetAssignedDatabaseDataFactory.build({ + id: 1, +}); +const subnetdatabaseInfoId2 = subnetAssignedDatabaseDataFactory.build({ + id: 2, +}); describe('getUniqueResourcesFromSubnets', () => { - it(`returns the number of unique linodes and nodeBalancers within a VPC's subnets`, () => { - const subnets0 = [subnetFactory.build({ linodes: [], nodebalancers: [] })]; + it(`returns the number of unique linodes, nodeBalancers, and databases within a VPC's subnets`, () => { + const subnets0 = [ + subnetFactory.build({ linodes: [], nodebalancers: [], databases: [] }), + ]; const subnets1 = [ subnetFactory.build({ linodes: subnetLinodeInfoList1, nodebalancers: subnetNodeBalancerInfoList1, + databases: [], }), ]; const subnets2 = [ @@ -60,17 +70,20 @@ describe('getUniqueResourcesFromSubnets', () => { subnetNodeBalancerInfoId3, subnetNodeBalancerInfoId3, ], + databases: [], }), ]; const subnets3 = [ subnetFactory.build({ linodes: subnetLinodeInfoList1, nodebalancers: subnetNodeBalancerInfoList1, + databases: [], }), - subnetFactory.build({ linodes: [], nodebalancers: [] }), + subnetFactory.build({ linodes: [], nodebalancers: [], databases: [] }), subnetFactory.build({ linodes: [subnetLinodeInfoId3], nodebalancers: [subnetNodeBalancerInfoId3], + databases: [], }), subnetFactory.build({ linodes: [ @@ -87,14 +100,31 @@ describe('getUniqueResourcesFromSubnets', () => { subnetAssignedNodebalancerDataFactory.build({ id: 9 }), subnetNodeBalancerInfoId1, ], + databases: [], + }), + ]; + + const subnets4 = [ + ...subnets3, + subnetFactory.build({ + databases: [subnetdatabaseInfoId1], + linodes: [], + nodebalancers: [], + }), + subnetFactory.build({ + databases: [subnetdatabaseInfoId2], + linodes: [], + nodebalancers: [], }), ]; - expect(getUniqueResourcesFromSubnets(subnets0)).toBe(0); - expect(getUniqueResourcesFromSubnets(subnets1)).toBe(8); - expect(getUniqueResourcesFromSubnets(subnets2)).toBe(4); + expect(getUniqueResourcesFromSubnets(subnets0, false)).toBe(0); + expect(getUniqueResourcesFromSubnets(subnets1, false)).toBe(8); + expect(getUniqueResourcesFromSubnets(subnets2, false)).toBe(4); // updated factory for generating linode ids, so unique linodes will be different - expect(getUniqueResourcesFromSubnets(subnets3)).toBe(16); + expect(getUniqueResourcesFromSubnets(subnets3, false)).toBe(16); + // Test databases count when getUniqueLinodesFromSubnets countDatabases param is true + expect(getUniqueResourcesFromSubnets(subnets4, true)).toBe(18); }); }); diff --git a/packages/manager/src/features/VPCs/utils.ts b/packages/manager/src/features/VPCs/utils.ts index 5e96fb4388e..3bdc3fa8807 100644 --- a/packages/manager/src/features/VPCs/utils.ts +++ b/packages/manager/src/features/VPCs/utils.ts @@ -23,9 +23,13 @@ export const getUniqueLinodesFromSubnets = (subnets: Subnet[]) => { return linodes.length; }; -export const getUniqueResourcesFromSubnets = (subnets: Subnet[]) => { +export const getUniqueResourcesFromSubnets = ( + subnets: Subnet[], + countDatabases: boolean +) => { const linodes: number[] = []; const nodeBalancer: number[] = []; + const databases: number[] = []; for (const subnet of subnets) { subnet.linodes.forEach((linodeInfo) => { if (!linodes.includes(linodeInfo.id)) { @@ -37,8 +41,15 @@ export const getUniqueResourcesFromSubnets = (subnets: Subnet[]) => { nodeBalancer.push(nodeBalancerInfo.id); } }); + if (countDatabases) { + subnet.databases.forEach((databaseInfo) => { + if (!databases.includes(databaseInfo.id)) { + databases.push(databaseInfo.id); + } + }); + } } - return linodes.length + nodeBalancer.length; + return linodes.length + nodeBalancer.length + databases.length; }; // Linode Interfaces: show unrecommended notice if (active) VPC interface has an IPv4 nat_1_1 address but isn't the default IPv4 route diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx index 4b454edc2a5..ff773621d24 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx @@ -116,10 +116,8 @@ export const PlanContainer = (props: PlanContainerProps) => { } = props; const location = useLocation(); const flags = useFlags(); - const { isGenerationalPlansEnabled } = useIsGenerationalPlansEnabled( - plans, - planType - ); + const { isGenerationalPlansEnabled, hasG7DedicatedPlans } = + useIsGenerationalPlansEnabled(plans, planType); // Show the Transfer column if, for any plan, the api returned data and we're not in the Database Create flow const showTransfer = @@ -135,8 +133,11 @@ export const PlanContainer = (props: PlanContainerProps) => { const isDatabaseResizeFlow = location.pathname.match(/\/databases\/.*\/(\d+\/resize)/)?.[0] === location.pathname; - const shouldDisplayNoRegionSelectedMessage = - !selectedRegionId && !isDatabaseCreateFlow && !isDatabaseResizeFlow; + + const shouldDisplayNoRegionSelectedMessage = Boolean( + (!selectedRegionId && !isDatabaseCreateFlow) || + (isDatabaseCreateFlow && hasG7DedicatedPlans && !selectedRegionId) + ); const isDatabaseGA = !flags.dbaasV2?.beta && diff --git a/packages/manager/src/features/components/PlansPanel/PlanSelection.test.tsx b/packages/manager/src/features/components/PlansPanel/PlanSelection.test.tsx index ea56ea19f89..f0fa78e4e8b 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanSelection.test.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanSelection.test.tsx @@ -265,7 +265,7 @@ describe('PlanSelection (card, mobile)', () => { expect(mockOnSelect).toHaveBeenCalled(); }); - it('shows the dynamic prices for a region with DC-specific pricing', () => { + it('shows the dynamic prices for a region with DC-specific pricing', async () => { const { container } = renderWithTheme( ); @@ -276,9 +276,16 @@ describe('PlanSelection (card, mobile)', () => { expect( container.querySelector('[data-qa-select-card-heading]') ).toHaveTextContent(mockPlan.heading); - expect( - container.querySelector('[data-qa-select-card-subheading="subheading-1"]') - ).toHaveTextContent('$14.40/mo ($0.021/hr)'); + + // Wait for price mutation to complete before asserting + await waitFor(() => { + expect( + container.querySelector( + '[data-qa-select-card-subheading="subheading-1"]' + ) + ).toHaveTextContent('$14.40/mo ($0.021/hr)'); + }); + expect( container.querySelector('[data-qa-select-card-subheading="subheading-2"]') ).toHaveTextContent('1 CPU, 50 GB Storage, 2 GB RAM'); diff --git a/packages/manager/src/features/components/PlansPanel/PlanSelection.tsx b/packages/manager/src/features/components/PlansPanel/PlanSelection.tsx index 77364fbe3f6..5bce3837425 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanSelection.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanSelection.tsx @@ -60,6 +60,9 @@ export const PlanSelection = (props: PlanSelectionProps) => { planHasLimitedAvailability, planIsDisabled512Gb, planResizeNotSupported, + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + planDBaaSResizeFromPremiumNotSupported, + planDBaaSResizeToPremiumNotSupported, planIsSmallerThanUsage, planIsTooSmall, } = plan; @@ -90,6 +93,9 @@ export const PlanSelection = (props: PlanSelectionProps) => { planIsDisabled512Gb || planHasLimitedAvailability || planResizeNotSupported || + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + (isDatabaseFlow && planDBaaSResizeFromPremiumNotSupported) || + (isDatabaseFlow && planDBaaSResizeToPremiumNotSupported) || wholePanelIsDisabled; const disabledPlanReasonCopy = getDisabledPlanReasonCopy({ @@ -97,6 +103,9 @@ export const PlanSelection = (props: PlanSelectionProps) => { planHasLimitedAvailability, planIsDisabled512Gb, planResizeNotSupported, + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + planDBaaSResizeFromPremiumNotSupported, + planDBaaSResizeToPremiumNotSupported, planIsSmallerThanUsage, planIsTooSmall, wholePanelIsDisabled, @@ -112,6 +121,9 @@ export const PlanSelection = (props: PlanSelectionProps) => { (planBelongsToDisabledClass || planIsDisabled512Gb || planHasLimitedAvailability || + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + planDBaaSResizeFromPremiumNotSupported || + planDBaaSResizeToPremiumNotSupported || planIsTooSmall || planIsSmallerThanUsage || planResizeNotSupported); diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index 83ad6b78d30..781878e24d4 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -42,6 +42,9 @@ export interface PlansPanelProps { currentPlanHeading?: string; disabled?: boolean; disabledClasses?: LinodeTypeClass[]; + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + disabledResizeFromPremiumPlans?: PlanSelectionType[]; + disabledResizeToPremiumPlans?: PlanSelectionType[]; disabledSmallerPlans?: PlanSelectionType[]; disabledTabs?: string[]; docsLink?: JSX.Element; @@ -80,6 +83,9 @@ export const PlansPanel = (props: PlansPanelProps) => { disabled, disabledClasses, disabledSmallerPlans, + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + disabledResizeToPremiumPlans, + disabledResizeFromPremiumPlans, docsLink, error, flow = 'linode', @@ -180,7 +186,11 @@ export const PlansPanel = (props: PlansPanelProps) => { disableLargestGbPlansFlag: flags.disableLargestGbPlans, disabledClasses, disabledSmallerPlans, + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + disabledResizeFromPremiumPlans, + disabledResizeToPremiumPlans, isLegacyDatabase, + isDatabaseResize, isResize: isDatabaseResize ? false : isResize, plans: plansMap, regionAvailabilities, diff --git a/packages/manager/src/features/components/PlansPanel/types.ts b/packages/manager/src/features/components/PlansPanel/types.ts index 4b41b9a9632..57b231438a1 100644 --- a/packages/manager/src/features/components/PlansPanel/types.ts +++ b/packages/manager/src/features/components/PlansPanel/types.ts @@ -30,6 +30,9 @@ export type PlanWithAvailability = export interface PlanSelectionAvailabilityTypes { planBelongsToDisabledClass: boolean; + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + planDBaaSResizeFromPremiumNotSupported?: boolean; + planDBaaSResizeToPremiumNotSupported?: boolean; planHasLimitedAvailability: boolean; planIsDisabled512Gb: boolean; planIsSmallerThanUsage?: boolean; diff --git a/packages/manager/src/features/components/PlansPanel/utils.ts b/packages/manager/src/features/components/PlansPanel/utils.ts index 56f6108786e..3da00809f73 100644 --- a/packages/manager/src/features/components/PlansPanel/utils.ts +++ b/packages/manager/src/features/components/PlansPanel/utils.ts @@ -1,6 +1,10 @@ import { useAccount } from '@linode/queries'; import { arrayToList, isFeatureEnabledV2 } from '@linode/utilities'; +import { + RESIZE_DISABLED_NON_G7_DEDICATED_SHARED_PLAN_TABS_TEXT, + RESIZE_DISABLED_PREMIUM_PLAN_TAB_TEXT, +} from 'src/features/Databases/constants'; import { useFlags } from 'src/hooks/useFlags'; import { useIsGenerationalPlansEnabled } from 'src/utilities/linodes'; @@ -24,6 +28,7 @@ import type { import type { BaseType, Capabilities, + DatabaseType, LinodeType, LinodeTypeClass, Region, @@ -321,9 +326,13 @@ export const replaceOrAppendPlaceholder512GbPlans = ( interface ExtractPlansInformationProps { disabledClasses?: LinodeTypeClass[]; + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + disabledResizeFromPremiumPlans?: PlanSelectionType[]; + disabledResizeToPremiumPlans?: PlanSelectionType[]; disabledSmallerPlans?: PlanSelectionType[]; disableLargestGbPlansFlag: Flags['disableLargestGbPlans'] | undefined; isAPLEnabled?: boolean; + isDatabaseResize?: boolean; isLegacyDatabase?: boolean; isResize?: boolean; plans: PlanSelectionType[]; @@ -333,7 +342,7 @@ interface ExtractPlansInformationProps { /** * Extracts plan information and determines if any plans are disabled. - * Used for Linode and Kubernetes selection Plan tables and notices. + * Used for Linode, Kubernetes, and Databases selection Plan tables and notices. * * @param disableLargestGbPlansFlag The flag to disable the largest GB plans. * @param disabledClasses The disabled classes (aka linode types). @@ -349,7 +358,11 @@ export const extractPlansInformation = ({ disabledSmallerPlans, isAPLEnabled, isLegacyDatabase, + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + disabledResizeFromPremiumPlans, + disabledResizeToPremiumPlans, isResize, + isDatabaseResize, plans, regionAvailabilities, selectedRegionId, @@ -373,6 +386,22 @@ export const extractPlansInformation = ({ // - Resizing existing linodes (from MTC regions) to this MTC plan is not supported. const planResizeNotSupported = isCustomMTCPlan && isResize; + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + const planDBaaSResizeFromPremiumNotSupported = + isDatabaseResize && + Boolean( + disabledResizeFromPremiumPlans?.find( + (disabledPlan) => disabledPlan.id === plan.id + ) + ); + const planDBaaSResizeToPremiumNotSupported = + isDatabaseResize && + Boolean( + disabledResizeToPremiumPlans?.find( + (disabledPlan) => disabledPlan.id === plan.id + ) + ); + const planHasLimitedAvailability = getIsLimitedAvailability({ plan, regionAvailabilities, @@ -403,6 +432,9 @@ export const extractPlansInformation = ({ planIsSmallerThanUsage, planIsTooSmall, planIsTooSmallForAPL, + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + planDBaaSResizeFromPremiumNotSupported, + planDBaaSResizeToPremiumNotSupported, }; } ); @@ -444,6 +476,9 @@ export const getIsPlanDisabled = (plan: PlanWithAvailability) => { planIsSmallerThanUsage, planIsTooSmall, planIsTooSmallForAPL, + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + planDBaaSResizeFromPremiumNotSupported, + planDBaaSResizeToPremiumNotSupported, } = plan; return ( @@ -453,7 +488,10 @@ export const getIsPlanDisabled = (plan: PlanWithAvailability) => { planResizeNotSupported || planIsSmallerThanUsage || planIsTooSmall || - planIsTooSmallForAPL + planIsTooSmallForAPL || + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + planDBaaSResizeFromPremiumNotSupported || + planDBaaSResizeToPremiumNotSupported ); }; @@ -466,12 +504,18 @@ export const getDisabledPlanReasonCopy = ({ planHasLimitedAvailability, planIsDisabled512Gb, planResizeNotSupported, + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + planDBaaSResizeFromPremiumNotSupported, + planDBaaSResizeToPremiumNotSupported, planIsSmallerThanUsage, planIsTooSmall, planIsTooSmallForAPL, wholePanelIsDisabled, }: { planBelongsToDisabledClass: DisabledTooltipReasons['planBelongsToDisabledClass']; + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + planDBaaSResizeFromPremiumNotSupported?: DisabledTooltipReasons['planDBaaSResizeFromPremiumNotSupported']; + planDBaaSResizeToPremiumNotSupported?: DisabledTooltipReasons['planDBaaSResizeToPremiumNotSupported']; planHasLimitedAvailability: DisabledTooltipReasons['planHasLimitedAvailability']; planIsDisabled512Gb: DisabledTooltipReasons['planIsDisabled512Gb']; planIsSmallerThanUsage?: DisabledTooltipReasons['planIsSmallerThanUsage']; @@ -498,6 +542,15 @@ export const getDisabledPlanReasonCopy = ({ return PLAN_IS_TOO_SMALL_FOR_APL_COPY; } + // @TODO remove dbaas resize class type restriction sometime post-release when we support resizing across different plans + if (planDBaaSResizeToPremiumNotSupported) { + return RESIZE_DISABLED_PREMIUM_PLAN_TAB_TEXT; + } + + if (planDBaaSResizeFromPremiumNotSupported) { + return RESIZE_DISABLED_NON_G7_DEDICATED_SHARED_PLAN_TABS_TEXT; + } + if ( planHasLimitedAvailability || planIsDisabled512Gb || @@ -512,7 +565,7 @@ export const getDisabledPlanReasonCopy = ({ export const useShouldDisablePremiumPlansTab = ({ types, }: { - types: LinodeType[] | PlanSelectionType[] | undefined; + types: DatabaseType[] | LinodeType[] | PlanSelectionType[] | undefined; }): boolean => { const { isGenerationalPlansEnabled, allowedPlans } = useIsGenerationalPlansEnabled(types, 'premium'); diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts index 66be91b6f58..3e77bd344be 100644 --- a/packages/manager/src/mocks/mockState.ts +++ b/packages/manager/src/mocks/mockState.ts @@ -55,6 +55,7 @@ export const emptyStore: MockState = { placementGroups: [], regionAvailability: [], regions: [], + reservedIPs: [], streams: [], subnets: [], supportReplies: [], diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts index 3347bb93695..9ec424b0040 100644 --- a/packages/manager/src/mocks/presets/baseline/crud.ts +++ b/packages/manager/src/mocks/presets/baseline/crud.ts @@ -14,6 +14,10 @@ import { firewallCrudPreset } from '../crud/firewalls'; import { imagesCrudPreset } from '../crud/images'; import { kubernetesCrudPreset } from '../crud/kubernetes'; import { locksCrudPreset } from '../crud/locks'; +import { + networkingCrudPreset, + reservedIPsCrudPreset, +} from '../crud/networking'; import { nodeBalancerCrudPreset } from '../crud/nodebalancers'; import { permissionsCrudPreset } from '../crud/permissions'; import { placementGroupsCrudPreset } from '../crud/placementGroups'; @@ -46,7 +50,10 @@ export const baselineCrudPreset: MockPresetBaseline = { ...volumeCrudPreset.handlers, ...usersCrudPreset.handlers, ...vpcCrudPreset.handlers, + ...networkingCrudPreset.handlers, ...nodeBalancerCrudPreset.handlers, + ...networkingCrudPreset.handlers, + ...reservedIPsCrudPreset.handlers, // Events. getEvents, diff --git a/packages/manager/src/mocks/presets/crud/handlers/networking.ts b/packages/manager/src/mocks/presets/crud/handlers/networking.ts index 7f395f6b49e..6de93054c30 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/networking.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/networking.ts @@ -1,6 +1,6 @@ import { http } from 'msw'; -import { ipAddressFactory } from 'src/factories'; +import { ipAddressFactory, reservedIPsTypeFactory } from 'src/factories'; import { mswDB } from 'src/mocks/indexedDB'; import { // makeErrorResponse, @@ -9,7 +9,7 @@ import { makeResponse, } from 'src/mocks/utilities/response'; -import type { IPAddress } from '@linode/api-v4'; +import type { IPAddress, PriceType } from '@linode/api-v4'; import type { StrictResponse } from 'msw'; import type { MockState } from 'src/mocks/types'; import type { @@ -80,4 +80,60 @@ export const allocateIP = (mockState: MockState) => [ ), ]; +// Reserved IP handlers + +export const getReservedIPs = () => [ + http.get( + '*/v4beta/networking/reserved/ips', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const reservedIPs = await mswDB.getAll('reservedIPs'); + return makePaginatedResponse({ + data: reservedIPs ?? [], + request, + }); + } + ), +]; + +export const reserveIP = (mockState: MockState) => [ + http.post( + '*/v4beta/networking/reserved/ips', + async ({ + request, + }): Promise> => { + const payload = await request.clone().json(); + + const ipAddress = ipAddressFactory.build({ + region: payload.region, + reserved: true, + tags: payload.tags ?? [], + type: 'ipv4', + }); + + await mswDB.add('reservedIPs', ipAddress, mockState); + + return makeResponse(ipAddress); + } + ), +]; + +export const getReservedIPsTypes = () => [ + http.get( + '*/v4beta/networking/reserved/ips/types', + ({ + request, + }): StrictResponse> => { + const templates = reservedIPsTypeFactory.buildList(1); + return makePaginatedResponse({ + data: templates, + request, + }); + } + ), +]; + // @TODO Linode Interfaces - add mocks for sharing/assigning IPs diff --git a/packages/manager/src/mocks/presets/crud/networking.ts b/packages/manager/src/mocks/presets/crud/networking.ts index ffe09c84032..9c5b59a319f 100644 --- a/packages/manager/src/mocks/presets/crud/networking.ts +++ b/packages/manager/src/mocks/presets/crud/networking.ts @@ -1,13 +1,23 @@ import { allocateIP, getIPAddresses, + getReservedIPs, + getReservedIPsTypes, + reserveIP, } from 'src/mocks/presets/crud/handlers/networking'; import type { MockPresetCrud } from 'src/mocks/types'; export const networkingCrudPreset: MockPresetCrud = { group: { id: 'IP Addresses' }, - handlers: [getIPAddresses, allocateIP], + handlers: [getIPAddresses], id: 'ip-addresses:crud', label: 'IP Addresses CRUD', }; + +export const reservedIPsCrudPreset: MockPresetCrud = { + group: { id: 'Reserved IPs' }, + handlers: [getReservedIPs, allocateIP, getReservedIPsTypes, reserveIP], + id: 'reserved-ips:crud', + label: 'Reserved IPs CRUD', +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/index.ts b/packages/manager/src/mocks/presets/crud/seeds/index.ts index 7fb0f995d18..c40eb2a011c 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/index.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/index.ts @@ -4,7 +4,7 @@ import { firewallSeeder } from './firewalls'; import { kubernetesSeeder } from './kubernetes'; import { linodesSeeder } from './linodes'; import { locksSeeder } from './locks'; -import { ipAddressSeeder } from './networking'; +import { ipAddressSeeder, reservedIPSeeder } from './networking'; import { nodeBalancerSeeder } from './nodebalancers'; import { placementGroupSeeder } from './placementGroups'; import { supportTicketsSeeder } from './supportTickets'; @@ -22,6 +22,7 @@ export const dbSeeders = [ locksSeeder, nodeBalancerSeeder, placementGroupSeeder, + reservedIPSeeder, supportTicketsSeeder, defaultUsersSeeder, parentUsersSeeder, diff --git a/packages/manager/src/mocks/presets/crud/seeds/networking.ts b/packages/manager/src/mocks/presets/crud/seeds/networking.ts index 97cb025092b..dccb3502449 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/networking.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/networking.ts @@ -1,6 +1,6 @@ import { getSeedsCountMap } from 'src/dev-tools/utils'; -import { ipAddressFactory } from 'src/factories'; -import { mswDB } from 'src/mocks/indexedDB'; +import { ipAddressFactory, reservedIPsFactory } from 'src/factories'; +import { addToEntities, mswDB } from 'src/mocks/indexedDB'; import { seedWithUniqueIds } from 'src/mocks/presets/crud/seeds/utils'; import type { MockSeeder, MockState } from 'src/mocks/types'; @@ -30,3 +30,33 @@ export const ipAddressSeeder: MockSeeder = { return updatedMockState; }, }; + +export const reservedIPSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'Reserved IP Address Seeds', + group: { id: 'Reserved IPs' }, + id: 'reserved-ips:crud', + label: 'Reserved IPs', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[reservedIPSeeder.id] ?? 0; + const reservedIPSeeds = reservedIPsFactory.buildList(count); + + const uniqueReservedIPSeeds = seedWithUniqueIds<'reservedIPs'>({ + dbEntities: await mswDB.getAll('reservedIPs'), + seedEntities: reservedIPSeeds, + }); + + addToEntities(mockState, 'reservedIPs', uniqueReservedIPSeeds); + + const updatedMockState = { + ...mockState, + reservedIPs: mockState.reservedIPs.concat(uniqueReservedIPSeeds), + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/utils.ts b/packages/manager/src/mocks/presets/crud/seeds/utils.ts index 591d3e6b555..0574b9456cb 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/utils.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/utils.ts @@ -42,6 +42,9 @@ export const removeSeeds = async (seederId: MockSeeder['id']) => { case 'placement-groups:crud': await mswDB.deleteAll('placementGroups', mockState, 'seedState'); break; + case 'reserved-ips:crud': + await mswDB.deleteAll('reservedIPs', mockState, 'seedState'); + break; case 'support-tickets:crud': await mswDB.deleteAll('supportTickets', mockState, 'seedState'); break; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 884cee1fe36..a08846dce5c 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -370,33 +370,14 @@ const entityTransfers = [ const databases = [ http.get('*/databases/instances', () => { - const database1 = databaseInstanceFactory.build({ - cluster_size: 1, - id: 1, - label: 'database-instance-1', - }); - const database2 = databaseInstanceFactory.build({ - cluster_size: 2, - id: 2, - label: 'database-instance-2', - }); - const database3 = databaseInstanceFactory.build({ - cluster_size: 3, - id: 3, - label: 'database-instance-3', - }); - const database4 = databaseInstanceFactory.build({ - cluster_size: 1, - id: 4, - label: 'database-instance-4', - }); - const database5 = databaseInstanceFactory.build({ - cluster_size: 1, - id: 5, - label: 'database-instance-5', - }); + const ids = Array.from({ length: 5 }, (_, i) => i + 1); // Update length to change the number of databases - const databases = [database1, database2, database3, database4, database5]; + const databases = ids.map((id) => { + return databaseInstanceFactory.build({ + id, + label: `databases-instance-${id}`, + }); + }); return HttpResponse.json(makeResourcePage(databases)); }), @@ -615,6 +596,15 @@ const vpc = [ ); }), http.get('*/v4beta/vpcs/:vpcId/subnets', () => { + /* Uncomment to the code below to mock a subnet with assignedDatabases that can be found in the GET database instances call */ + // const ids = Array.from({ length: 5 }, (_, i) => i + 1); // Update length to change the number of assigned databases + // const assignedDatabases = ids.map((id) => { + // return subnetAssignedDatabaseDataFactory.build({ + // id, + // }); + // }); + // const mockSubnet = subnetFactory.build({ databases: assignedDatabases }); + // return HttpResponse.json(makeResourcePage([mockSubnet])); return HttpResponse.json(makeResourcePage(subnetFactory.buildList(30))); }), http.delete('*/v4beta/vpcs/:vpcId/subnets/:subnetId', () => { diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 2be09d2d891..e2f268f3030 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -148,6 +148,7 @@ export type MockPresetCrudGroup = { | 'Permissions' | 'Placement Groups' | 'Quotas' + | 'Reserved IPs' | 'Support Tickets' | 'Users' | 'Volumes' @@ -171,6 +172,7 @@ export type MockPresetCrudId = | 'permissions:crud' | 'placement-groups:crud' | 'quotas:crud' + | 'reserved-ips:crud' | 'support-tickets:crud' | 'users(default):crud' | 'users(parent):crud' @@ -241,6 +243,7 @@ export interface MockState { placementGroups: PlacementGroup[]; regionAvailability: RegionAvailability[]; regions: Region[]; + reservedIPs: IPAddress[]; streams: Stream[]; subnets: [number, Subnet][]; // number is VPC ID supportReplies: SupportReply[]; diff --git a/packages/manager/src/routes/delivery/index.ts b/packages/manager/src/routes/delivery/index.ts index a0fed0209b6..b4a66c208a5 100644 --- a/packages/manager/src/routes/delivery/index.ts +++ b/packages/manager/src/routes/delivery/index.ts @@ -22,16 +22,18 @@ const deliveryLandingRoute = createRoute({ }, getParentRoute: () => deliveryRoute, path: '/', -}).lazy(() => - import('src/features/Delivery/deliveryLandingLazyRoute').then( - (m) => m.deliveryLandingLazyRoute - ) -); +}); const streamsRoute = createRoute({ getParentRoute: () => deliveryRoute, path: 'streams', validateSearch: (search: StreamSearchParams) => search, +}); + +const streamsLandingRoute = createRoute({ + getParentRoute: () => streamsRoute, + path: '/', + validateSearch: (search: StreamSearchParams) => search, }).lazy(() => import('src/features/Delivery/deliveryLandingLazyRoute').then( (m) => m.deliveryLandingLazyRoute @@ -39,16 +41,16 @@ const streamsRoute = createRoute({ ); const streamsCreateRoute = createRoute({ - getParentRoute: () => deliveryRoute, - path: 'streams/create', + getParentRoute: () => streamsRoute, + path: 'create', }).lazy(() => import('src/features/Delivery/Streams/StreamForm/streamCreateLazyRoute').then( (m) => m.streamCreateLazyRoute ) ); -const streamsEditRoute = createRoute({ - getParentRoute: () => deliveryRoute, +const streamRoute = createRoute({ + getParentRoute: () => streamsRoute, params: { parse: ({ streamId }: { streamId: string }) => ({ streamId: Number(streamId), @@ -57,10 +59,45 @@ const streamsEditRoute = createRoute({ streamId: String(streamId), }), }, - path: 'streams/$streamId/edit', + path: '$streamId', +}); + +const streamLandingRoute = createRoute({ + beforeLoad: ({ params }) => { + throw redirect({ + to: '/logs/delivery/streams/$streamId/summary', + params: { streamId: params.streamId }, + replace: true, + }); + }, + getParentRoute: () => streamRoute, + path: '/', +}); + +const streamSummaryRoute = createRoute({ + getParentRoute: () => streamRoute, + path: 'summary', +}).lazy(() => + import('src/features/Delivery/Streams/Stream/streamLandingLazyRoute').then( + (m) => m.streamLandingLazyRoute + ) +); + +const streamMetricsRoute = createRoute({ + beforeLoad: ({ params, context }) => { + if (!context?.flags?.aclpLogs?.metricsEnabled) { + throw redirect({ + to: '/logs/delivery/streams/$streamId/summary', + params: { streamId: params.streamId }, + replace: true, + }); + } + }, + getParentRoute: () => streamRoute, + path: 'metrics', }).lazy(() => - import('src/features/Delivery/Streams/StreamForm/streamEditLazyRoute').then( - (m) => m.streamEditLazyRoute + import('src/features/Delivery/Streams/Stream/streamLandingLazyRoute').then( + (m) => m.streamLandingLazyRoute ) ); @@ -72,6 +109,12 @@ const destinationsRoute = createRoute({ getParentRoute: () => deliveryRoute, path: 'destinations', validateSearch: (search: DestinationSearchParams) => search, +}); + +const destinationsLandingRoute = createRoute({ + getParentRoute: () => destinationsRoute, + path: '/', + validateSearch: (search: DestinationSearchParams) => search, }).lazy(() => import('src/features/Delivery/deliveryLandingLazyRoute').then( (m) => m.deliveryLandingLazyRoute @@ -79,16 +122,16 @@ const destinationsRoute = createRoute({ ); const destinationsCreateRoute = createRoute({ - getParentRoute: () => deliveryRoute, - path: 'destinations/create', + getParentRoute: () => destinationsRoute, + path: 'create', }).lazy(() => import( 'src/features/Delivery/Destinations/DestinationForm/destinationCreateLazyRoute' ).then((m) => m.destinationCreateLazyRoute) ); -const destinationsEditRoute = createRoute({ - getParentRoute: () => deliveryRoute, +const destinationRoute = createRoute({ + getParentRoute: () => destinationsRoute, params: { parse: ({ destinationId }: { destinationId: string }) => ({ destinationId: Number(destinationId), @@ -97,7 +140,24 @@ const destinationsEditRoute = createRoute({ destinationId: String(destinationId), }), }, - path: 'destinations/$destinationId/edit', + path: '$destinationId', +}); + +const destinationLandingRoute = createRoute({ + beforeLoad: ({ params }) => { + throw redirect({ + to: '/logs/delivery/destinations/$destinationId/summary', + params: { destinationId: params.destinationId }, + replace: true, + }); + }, + getParentRoute: () => destinationRoute, + path: '/', +}); + +const destinationSummaryRoute = createRoute({ + getParentRoute: () => destinationRoute, + path: 'summary', }).lazy(() => import( 'src/features/Delivery/Destinations/DestinationForm/destinationEditLazyRoute' @@ -106,9 +166,21 @@ const destinationsEditRoute = createRoute({ export const deliveryRouteTree = deliveryRoute.addChildren([ deliveryLandingRoute, - streamsRoute.addChildren([streamsCreateRoute, streamsEditRoute]), + streamsRoute.addChildren([ + streamsLandingRoute, + streamsCreateRoute, + streamRoute.addChildren([ + streamLandingRoute, + streamSummaryRoute, + streamMetricsRoute, + ]), + ]), destinationsRoute.addChildren([ + destinationsLandingRoute, destinationsCreateRoute, - destinationsEditRoute, + destinationRoute.addChildren([ + destinationLandingRoute, + destinationSummaryRoute, + ]), ]), ]); diff --git a/packages/manager/src/routes/images/index.ts b/packages/manager/src/routes/images/index.ts index 0e48c5b2c2a..6ff0d9b9eda 100644 --- a/packages/manager/src/routes/images/index.ts +++ b/packages/manager/src/routes/images/index.ts @@ -45,6 +45,7 @@ const imageActions = { edit: 'edit', 'manage-replicas': 'manage-replicas', rebuild: 'rebuild', + view: 'view', } as const; export type ImageAction = (typeof imageActions)[keyof typeof imageActions]; @@ -315,6 +316,15 @@ const shareGroupsTypeRoute = createRoute({ validateSearch: (search: ImagesSearchParams) => search, }); +const shareGroupsCreateRoute = createRoute({ + getParentRoute: () => imagesRoute, + path: 'share-groups/create', +}).lazy(() => + import( + 'src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateLazyRoute' + ).then((m) => m.shareGroupsCreateLazyRoute) +); + export const imagesRouteTree = imagesRoute.addChildren([ imagesIndexRoute.addChildren([imageActionRoute]), imageLibraryLandingRoute.addChildren([ @@ -324,6 +334,7 @@ export const imagesRouteTree = imagesRoute.addChildren([ ]), shareGroupsLandingRoute.addChildren([ shareGroupsIndexRoute.addChildren([shareGroupsTypeRoute]), + shareGroupsCreateRoute, ]), imagesCreateRoute.addChildren([ imagesCreateIndexRoute, diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index a6a30f838e8..47c7c20a85a 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -213,7 +213,7 @@ export const stackscriptToSearchableItem = ( export const streamToSearchableItem = (stream: Stream): SearchableItem => ({ data: { description: getStreamDescription(stream), - path: `/logs/delivery/streams/${stream.id}/edit`, + path: `/logs/delivery/streams/${stream.id}/summary`, status: stream.status, created: stream.created, }, @@ -227,7 +227,7 @@ export const destinationToSearchableItem = ( ): SearchableItem => ({ data: { description: getDestinationDescription(destination), - path: `/logs/delivery/destinations/${destination.id}/edit`, + path: `/logs/delivery/destinations/${destination.id}/summary`, created: destination.created, }, entityType: 'destination', diff --git a/packages/manager/src/testSetup.ts b/packages/manager/src/testSetup.ts index 7b221df7d12..433dbd34420 100644 --- a/packages/manager/src/testSetup.ts +++ b/packages/manager/src/testSetup.ts @@ -1,17 +1,22 @@ import * as matchers from '@testing-library/jest-dom/matchers'; -import { cleanup } from '@testing-library/react'; +import { cleanup, configure } from '@testing-library/react'; import { expect } from 'vitest'; import { server } from './mocks/testServer'; expect.extend(matchers); +// Configure testing-library timeouts for CI stability +configure({ + asyncUtilTimeout: process.env.CI ? 10000 : 5000, // Increase waitFor timeout in CI +}); + afterEach(() => { cleanup(); }); -beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })); -afterAll(() => server.close()); +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }), 30000); // 30s timeout for MSW setup +afterAll(() => server.close(), 30000); // 30s timeout for cleanup afterEach(() => server.resetHandlers()); // @ts-expect-error this prevents some console errors diff --git a/packages/manager/src/utilities/analytics/utils.test.ts b/packages/manager/src/utilities/analytics/utils.test.ts index 916d16855f4..863630113e2 100644 --- a/packages/manager/src/utilities/analytics/utils.test.ts +++ b/packages/manager/src/utilities/analytics/utils.test.ts @@ -120,7 +120,7 @@ describe('waitForAdobeAnalyticsToBeLoaded', () => { it( 'should reject if adobe is not defined after 5 seconds', - { timeout: 7000 }, + { timeout: 30000 }, () => { vi.stubGlobal('_satellite', undefined); expect(waitForAdobeAnalyticsToBeLoaded()).rejects.toThrow( diff --git a/packages/manager/src/utilities/linodes.test.ts b/packages/manager/src/utilities/linodes.test.ts index 35eef4aee04..3e061621722 100644 --- a/packages/manager/src/utilities/linodes.test.ts +++ b/packages/manager/src/utilities/linodes.test.ts @@ -9,6 +9,7 @@ import { useIsGenerationalPlansEnabled, useIsLinodeCloneFirewallEnabled, useIsLinodeInterfacesEnabled, + useIsPasswordLessLinodesEnabled, } from './linodes'; import { wrapWithTheme } from './testHelpers'; @@ -306,3 +307,25 @@ describe('useIsGenerationalPlansEnabled', () => { expect(result.current?.isGenerationalPlansEnabled).toBe(false); }); }); + +describe('useIsPasswordLessLinodesEnabled', () => { + it('returns isPasswordLessLinodesEnabled: true if the feature is enabled', () => { + const options = { flags: { passwordlessLinodes: true } }; + + const { result } = renderHook(() => useIsPasswordLessLinodesEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + expect(result.current?.isPasswordLessLinodesEnabled).toBe(true); + }); + + it('returns isPasswordLessLinodesEnabled: false if the feature is NOT enabled', () => { + const options = { flags: { passwordlessLinodes: false } }; + + const { result } = renderHook(() => useIsPasswordLessLinodesEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + expect(result.current?.isPasswordLessLinodesEnabled).toBe(false); + }); +}); diff --git a/packages/manager/src/utilities/linodes.ts b/packages/manager/src/utilities/linodes.ts index 1d0f5f354b0..dfef92ec4f0 100644 --- a/packages/manager/src/utilities/linodes.ts +++ b/packages/manager/src/utilities/linodes.ts @@ -5,6 +5,7 @@ import { useFlags } from 'src/hooks/useFlags'; import type { AccountMaintenance, + DatabaseTypeClass, Linode, LinodeTypeClass, MaintenancePolicySlug, @@ -90,6 +91,21 @@ export const useIsLinodeCloneFirewallEnabled = () => { }; }; +/** + * Returns whether or not the feature for deploying Linodes without a root + * password should be enabled. + * + * When enabled, root_pass is optional if authorized_users are provided. + * When disabled, root_pass is strictly required. + */ +export const useIsPasswordLessLinodesEnabled = () => { + const flags = useFlags(); + + return { + isPasswordLessLinodesEnabled: Boolean(flags.passwordlessLinodes), + }; +}; + /** * Returns whether or not features related to the Generational Compute Plans * should be enabled. @@ -106,7 +122,7 @@ export const useIsLinodeCloneFirewallEnabled = () => { */ export const useIsGenerationalPlansEnabled = ( plans: Array<{ id: string }> | undefined, - planType: LinodeTypeClass | undefined + planType: DatabaseTypeClass | LinodeTypeClass | undefined ) => { const flags = useFlags(); @@ -150,5 +166,6 @@ export const useIsGenerationalPlansEnabled = ( isGenerationalPlansEnabled: isFlagEnabled && !shouldDisableDueToUnavailability, allowedPlans: flags.generationalPlansv2?.allowedPlans || [], + hasG7DedicatedPlans, }; }; diff --git a/packages/manager/src/utilities/storage.ts b/packages/manager/src/utilities/storage.ts index 80ec7e62a7d..8d009132232 100644 --- a/packages/manager/src/utilities/storage.ts +++ b/packages/manager/src/utilities/storage.ts @@ -5,6 +5,7 @@ import type { StackScriptPayload } from '@linode/api-v4/lib/stackscripts/types'; import type { SupportTicketFormFields } from 'src/features/Support/SupportTickets/SupportTicketDialog'; const localStorageCache: Record = {}; +const sessionStorageCache: Record = {}; export const getStorage = (key: string, fallback?: any) => { if (localStorageCache[key]) { @@ -48,6 +49,25 @@ export const clearStorage = (key: string) => { window.localStorage.removeItem(key); }; +export const getSessionStorage = (key: string): string | undefined => { + if (key in sessionStorageCache) { + return sessionStorageCache[key]; + } + const item = window.sessionStorage.getItem(key); + sessionStorageCache[key] = item ?? undefined; + return item ?? undefined; +}; + +export const setSessionStorage = (key: string, value: string) => { + sessionStorageCache[key] = value; + window.sessionStorage.setItem(key, value); +}; + +export const clearSessionStorage = (key: string) => { + delete sessionStorageCache[key]; + window.sessionStorage.removeItem(key); +}; + const PAGE_SIZE = 'PAGE_SIZE'; const INFINITE_PAGE_SIZE = 'INFINITE_PAGE_SIZE'; const TOKEN = 'authentication/token'; @@ -150,9 +170,9 @@ export interface Storage { export const storage: Storage = { authentication: { codeVerifier: { - get: () => getStorage(CODE_VERIFIER), - set: (v) => setStorage(CODE_VERIFIER, v), - clear: () => clearStorage(CODE_VERIFIER), + get: () => getSessionStorage(CODE_VERIFIER), + set: (v) => setSessionStorage(CODE_VERIFIER, v), + clear: () => clearSessionStorage(CODE_VERIFIER), }, expire: { get: () => getStorage(EXPIRE), @@ -160,9 +180,9 @@ export const storage: Storage = { clear: () => clearStorage(EXPIRE), }, nonce: { - get: () => getStorage(NONCE), - set: (v) => setStorage(NONCE, v), - clear: () => clearStorage(NONCE), + get: () => getSessionStorage(NONCE), + set: (v) => setSessionStorage(NONCE, v), + clear: () => clearSessionStorage(NONCE), }, scopes: { get: () => getStorage(SCOPES), diff --git a/packages/manager/vite.config.ts b/packages/manager/vite.config.ts index e09e6b07809..5b6440d15bf 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -27,24 +27,4 @@ export default defineConfig({ allowedHosts: ['cloud.lindev.local'], port: 3000, }, - test: { - include: ['**/*.test.{js,jsx,ts,tsx}'], - coverage: { - exclude: [ - 'src/**/*.constants.{js,jsx,ts,tsx}', - 'src/**/*.stories.{js,jsx,ts,tsx}', - 'src/**/index.{js,jsx,ts,tsx}', - 'src/**/*.styles.{js,jsx,ts,tsx}', - ], - include: [ - 'src/components/**/*.{js,jsx,ts,tsx}', - 'src/hooks/*{js,jsx,ts,tsx}', - 'src/utilities/**/*.{js,jsx,ts,tsx}', - 'src/**/*.utils.{js,jsx,ts,tsx}', - ], - }, - environment: 'jsdom', - globals: true, - setupFiles: './src/testSetup.ts', - }, }); diff --git a/packages/manager/vitest.config.ts b/packages/manager/vitest.config.ts new file mode 100644 index 00000000000..b7d60c53720 --- /dev/null +++ b/packages/manager/vitest.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import viteConfig from './vite.config'; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + // Timeout configurations for CI stability + testTimeout: 30000, // 30 seconds per test + hookTimeout: 30000, // 30 seconds for hooks + teardownTimeout: 30000, // 30 seconds for teardown + // Limit test concurrency in CI to prevent resource exhaustion + maxConcurrency: process.env.CI ? 5 : undefined, + // Additional test-specific config + include: ['**/*.test.{js,jsx,ts,tsx}'], + environment: 'jsdom', + globals: true, + setupFiles: './src/testSetup.ts', + coverage: { + exclude: [ + 'src/**/*.constants.{js,jsx,ts,tsx}', + 'src/**/*.stories.{js,jsx,ts,tsx}', + 'src/**/index.{js,jsx,ts,tsx}', + 'src/**/*.styles.{js,jsx,ts,tsx}', + ], + include: [ + 'src/components/**/*.{js,jsx,ts,tsx}', + 'src/hooks/*{js,jsx,ts,tsx}', + 'src/utilities/**/*.{js,jsx,ts,tsx}', + 'src/**/*.utils.{js,jsx,ts,tsx}', + ], + }, + }, + }) +); diff --git a/packages/queries/src/databases/databases.ts b/packages/queries/src/databases/databases.ts index 7357985e112..2bf52b17452 100644 --- a/packages/queries/src/databases/databases.ts +++ b/packages/queries/src/databases/databases.ts @@ -56,13 +56,14 @@ export const useDatabasesQuery = ( params: Params, filter: Filter, isEnabled: boolean | undefined, + refetchInterval?: number, ) => useQuery, APIError[]>({ ...databaseQueries.databases._ctx.paginated(params, filter), enabled: isEnabled, placeholderData: keepPreviousData, // @TODO Consider removing polling - refetchInterval: 20000, + refetchInterval, }); export const useDatabasesInfiniteQuery = (filter: Filter, enabled: boolean) => { diff --git a/packages/queries/src/images/sharegroups.ts b/packages/queries/src/images/sharegroups.ts index e03fc06eb1c..3191acdf9df 100644 --- a/packages/queries/src/images/sharegroups.ts +++ b/packages/queries/src/images/sharegroups.ts @@ -1,14 +1,21 @@ -import { getSharegroup, getSharegroups } from '@linode/api-v4'; +import { + createSharegroup, + getSharegroup, + getSharegroups, +} from '@linode/api-v4'; import { getAll } from '@linode/utilities'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { keepPreviousData, useInfiniteQuery, + useMutation, useQuery, + useQueryClient, } from '@tanstack/react-query'; import type { APIError, + CreateSharegroupPayload, Filter, Params, ResourcePage, @@ -95,3 +102,27 @@ export const useShareGroupsInfiniteQuery = ( initialPageParam: 1, retry: false, }); + +export const useCreateShareGroupMutation = () => { + const queryclient = useQueryClient(); + + return useMutation({ + mutationFn: createSharegroup, + onSuccess(shareGroup) { + queryclient.invalidateQueries({ + queryKey: shareGroupsQueries.sharegroups._ctx.paginated._def, + }); + queryclient.invalidateQueries({ + queryKey: shareGroupsQueries.sharegroups._ctx.all._def, + }); + queryclient.invalidateQueries({ + queryKey: shareGroupsQueries.sharegroups._ctx.infinite._def, + }); + queryclient.setQueryData( + shareGroupsQueries.sharegroups._ctx.sharegroup(shareGroup.id.toString()) + .queryKey, + shareGroup, + ); + }, + }); +}; diff --git a/packages/queries/src/networking/networking.ts b/packages/queries/src/networking/networking.ts index 53d304786fe..2ccbe1b62a2 100644 --- a/packages/queries/src/networking/networking.ts +++ b/packages/queries/src/networking/networking.ts @@ -1,10 +1,12 @@ import { createIPv6Range, + getIP, getIPv6RangeInfo, getReservedIP, getReservedIPs, reserveIP, unReserveIP, + updateIP, updateReservedIP, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; @@ -18,7 +20,11 @@ import { import { useMemo } from 'react'; import { linodeQueries } from '../linodes/linodes'; -import { getAllIps, getAllIPv6Ranges } from './requests'; +import { + getAllIps, + getAllIPv6Ranges, + getAllReservedIPsTypes, +} from './requests'; import type { APIError, @@ -28,6 +34,7 @@ import type { IPRange, IPRangeInformation, Params, + PriceType, ReserveIPPayload, ResourcePage, } from '@linode/api-v4'; @@ -50,6 +57,10 @@ export const networkingQueries = createQueryKeys('networking', { }, queryKey: null, }, + ip: (address: string) => ({ + queryFn: () => getIP(address), + queryKey: [address], + }), reservedIPs: (params: Params = {}, filter: Filter = {}) => ({ queryFn: () => getReservedIPs(params, filter), queryKey: [params, filter], @@ -58,6 +69,10 @@ export const networkingQueries = createQueryKeys('networking', { queryFn: () => getReservedIP(address), queryKey: [address], }), + reservedIPTypes: { + queryFn: getAllReservedIPsTypes, + queryKey: null, + }, }); export const useAllIPsQuery = ( @@ -139,6 +154,26 @@ export const useCreateIPv6RangeMutation = () => { }); }; +export const useUpdateIPMutation = (address: string) => { + const queryClient = useQueryClient(); + return useMutation< + IPAddress, + APIError[], + { address: string; rdns: null | string; reserved: boolean } + >({ + mutationFn: (data) => updateIP(address, data.rdns, data.reserved), + onSuccess(ip) { + queryClient.invalidateQueries({ + queryKey: networkingQueries.ips._def, + }); + queryClient.setQueryData( + networkingQueries.ip(address).queryKey, + ip, + ); + }, + }); +}; + export const useReservedIPsQuery = ( params?: Params, filter?: Filter, @@ -207,3 +242,9 @@ export const useUnReserveIPMutation = (address: string) => { }, }); }; + +export const useReservedIPTypesQuery = () => { + return useQuery({ + ...networkingQueries.reservedIPTypes, + }); +}; diff --git a/packages/queries/src/profile/preferences.ts b/packages/queries/src/profile/preferences.ts index cfcc9529f02..1f1f58baac8 100644 --- a/packages/queries/src/profile/preferences.ts +++ b/packages/queries/src/profile/preferences.ts @@ -1,5 +1,10 @@ import { updateUserPreferences } from '@linode/api-v4'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { queryPresets } from '../base'; import { profileQueries } from './profile'; @@ -8,6 +13,14 @@ import type { APIError } from '@linode/api-v4'; import type { ManagerPreferences } from '@linode/utilities'; import type { QueryClient } from '@tanstack/react-query'; +const isPreferencesMergeBase = ( + value: ManagerPreferences | undefined, +): value is ManagerPreferences => + value !== null && + value !== undefined && + typeof value === 'object' && + !Array.isArray(value); + // Reference for this pattern: https://tkdodo.eu/blog/react-query-data-transformations#3-using-the-select-option export const usePreferences = ( select?: (data: ManagerPreferences | undefined) => TData, @@ -32,12 +45,17 @@ export const useMutatePreferences = (replace = false) => { if (replace) { return updateUserPreferences(data); } - const existingPreferences = - await queryClient.ensureQueryData( - profileQueries.preferences, - ); + const preferencesQueryOptions = queryOptions(profileQueries.preferences); + const existingPreferences = await queryClient.ensureQueryData( + preferencesQueryOptions, + ); + return updateUserPreferences({ ...existingPreferences, ...data }); }, + onError: () => + queryClient.invalidateQueries({ + queryKey: profileQueries.preferences.queryKey, + }), onMutate: (data) => updatePreferenceData(data, replace, queryClient), }); }; @@ -49,9 +67,14 @@ export const updatePreferenceData = ( ): void => { queryClient.setQueryData( profileQueries.preferences.queryKey, - (oldData) => ({ - ...(!replace ? oldData : {}), - ...newData, - }), + (oldData) => { + if (replace) { + return { ...newData }; + } + if (!isPreferencesMergeBase(oldData)) { + return oldData; + } + return { ...oldData, ...newData }; + }, ); }; diff --git a/packages/search/package.json b/packages/search/package.json index 9f67b9c1d92..884c051e708 100644 --- a/packages/search/package.json +++ b/packages/search/package.json @@ -20,6 +20,6 @@ "@linode/tsconfig": "workspace:*" }, "peerDependencies": { - "vite": "*" + "vite": "^7.3.2" } } diff --git a/packages/ui/src/components/Drawer/Drawer.tsx b/packages/ui/src/components/Drawer/Drawer.tsx index c1486bf620f..c727b856bd5 100644 --- a/packages/ui/src/components/Drawer/Drawer.tsx +++ b/packages/ui/src/components/Drawer/Drawer.tsx @@ -35,6 +35,10 @@ export interface DrawerProps extends _DrawerProps { * If true, the drawer will feature a loading spinner for its content. */ isFetching?: boolean; + /** + * Optional Pendo ID for tracking clicks on the X icon that closes the drawer. + */ + pendoId?: string; /** * Title that appears at the top of the drawer */ @@ -72,6 +76,7 @@ export const Drawer = React.forwardRef( isFetching, onClose, open, + pendoId, sx, title, titleSuffix, @@ -204,6 +209,7 @@ export const Drawer = React.forwardRef( onClose?.({}, 'escapeKeyDown')} size="large" diff --git a/packages/validation/.changeset/pr-13516-changed-1775718949304.md b/packages/validation/.changeset/pr-13516-changed-1775718949304.md new file mode 100644 index 00000000000..78646280603 --- /dev/null +++ b/packages/validation/.changeset/pr-13516-changed-1775718949304.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Make root_pass optional to support passworless linode creation, rebuild and disk creation ([#13516](https://github.com/linode/manager/pull/13516)) diff --git a/packages/validation/.changeset/pr-13559-tech-stories-1775480726426.md b/packages/validation/.changeset/pr-13559-tech-stories-1775480726426.md new file mode 100644 index 00000000000..9af395cb44a --- /dev/null +++ b/packages/validation/.changeset/pr-13559-tech-stories-1775480726426.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Tech Stories +--- + +Bump `tsup` from `8.4.0` to `8.5.1` ([#13559](https://github.com/linode/manager/pull/13559)) diff --git a/packages/validation/package.json b/packages/validation/package.json index cfeb90685de..7e815c17d2f 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -43,7 +43,7 @@ "devDependencies": { "@linode/tsconfig": "workspace:*", "concurrently": "^9.0.1", - "tsup": "^8.4.0" + "tsup": "^8.5.1" }, "keywords": [ "linode", diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index 530cdfafa85..06b36f37420 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -351,11 +351,9 @@ const streamSchemaBase = object({ .min(3, 'Stream name must have at least 3 characters.') .max(maxLength, maxLengthMessage) .required('Stream name is required.'), - status: mixed<'active' | 'inactive' | 'provisioning'>().oneOf([ - 'active', - 'inactive', - 'provisioning', - ]), + status: mixed< + 'active' | 'deactivating' | 'failed' | 'inactive' | 'provisioning' + >().oneOf(['active', 'deactivating', 'failed', 'inactive', 'provisioning']), type: string() .oneOf(['audit_logs', 'lke_audit_logs']) .required('Stream type is required.'), @@ -372,8 +370,10 @@ export const createStreamSchema = streamSchemaBase; export const updateStreamSchema = streamSchemaBase .omit(['type']) .shape({ - status: mixed<'active' | 'inactive' | 'provisioning'>() - .oneOf(['active', 'inactive', 'provisioning']) + status: mixed< + 'active' | 'deactivating' | 'failed' | 'inactive' | 'provisioning' + >() + .oneOf(['active', 'deactivating', 'failed', 'inactive', 'provisioning']) .required(), details: lazy((value) => { if ( diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 9d5be6d31aa..ba21e024e3d 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -451,7 +451,7 @@ export const UpdateLinodeSchema = object({ export const RebuildLinodeSchema = object({ image: string().required('An image is required.'), - root_pass: string().required('Password is required.'), + root_pass: string(), authorized_keys: array().of(string().required()), authorized_users: array().of(string().required()), stackscript_id: number().optional(), @@ -607,15 +607,7 @@ export const CreateLinodeDiskSchema = object({ image: string(), authorized_keys: array().of(string()), authorized_users: array().of(string()), - root_pass: string().when('image', { - is: (value: any) => Boolean(value), - then: (schema) => - schema.required( - 'You must provide a root password when deploying from an image.', - ), - // .concat(rootPasswordValidation), - otherwise: (schema) => schema.notRequired(), - }), + root_pass: string(), stackscript_id: number(), stackscript_data, }); @@ -635,6 +627,40 @@ export const CreateLinodeDiskFromImageSchema = image: string() .required('An image is required.') .typeError('An image is required.'), + root_pass: string().when('image', { + is: (value: unknown) => Boolean(value), + then: (schema) => + schema.required( + 'You must provide a root password when deploying from an image.', + ), + // .concat(rootPasswordValidation), + otherwise: (schema) => schema.notRequired(), + }), + }); + +export const CreateLinodeDiskFromImageWithoutPasswordSchema = + CreateLinodeDiskSchema.clone().shape({ + image: string() + .required('An image is required.') + .typeError('An image is required.'), + root_pass: string().when('image', { + is: (value: unknown) => Boolean(value), + then: (schema) => + schema.test({ + name: 'root-pass-or-ssh-key-required', + message: + 'An SSH Key or a Root Password is required to create a disk. We recommend using an SSH Key for better security.', + test(value, context) { + const { authorized_users, authorized_keys } = context.parent; + const hasAuthorizedUsers = + Array.isArray(authorized_users) && authorized_users.length > 0; + const hasAuthorizedKeys = + Array.isArray(authorized_keys) && authorized_keys.length > 0; + return Boolean(value) || hasAuthorizedUsers || hasAuthorizedKeys; + }, + }), + otherwise: (schema) => schema.notRequired(), + }), }); const LABEL_LENGTH_MESSAGE = 'Label must be between 1 and 64 characters.'; @@ -793,6 +819,8 @@ export const CreateLinodeSchema = object({ region: string().ensure().required('Region is required.'), stackscript_id: number().nullable().notRequired(), backup_id: number().nullable().notRequired(), + kernel: string().notRequired(), + boot_size: number().notRequired(), swap_size: number().notRequired(), image: string().when('stackscript_id', { is: (value?: number) => value !== undefined, @@ -811,14 +839,7 @@ export const CreateLinodeSchema = object({ tags: array().of(string().defined()).notRequired(), private_ip: boolean().notRequired(), authorized_users: array().of(string().defined()).notRequired(), - root_pass: string().when('image', { - is: (value: any) => Boolean(value), - then: (schema) => - schema.required( - 'You must provide a root password when deploying from an image.', - ), - otherwise: (schema) => schema.notRequired(), - }), + root_pass: string(), interfaces: array().when( 'interface_generation', ([interface_generation], schema) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22a0e5ac725..bc6fa5f80d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) '@vitest/ui': specifier: ^4.0.10 - version: 4.0.10(vitest@4.0.10) + version: 4.1.2(vitest@4.1.2) concurrently: specifier: 9.1.0 version: 9.1.0 @@ -92,7 +92,7 @@ importers: version: 8.29.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) vitest: specifier: ^4.0.10 - version: 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) + version: 4.1.2(@types/node@22.18.1)(@vitest/ui@4.1.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/api-v4: dependencies: @@ -100,8 +100,8 @@ importers: specifier: workspace:* version: link:../validation axios: - specifier: ~1.13.5 - version: 1.13.5 + specifier: ~1.15.0 + version: 1.15.0 ipaddr.js: specifier: ^2.0.0 version: 2.2.0 @@ -117,16 +117,25 @@ importers: version: 22.18.1 axios-mock-adapter: specifier: ^1.22.0 - version: 1.22.0(axios@1.13.5) + version: 1.22.0(axios@1.15.0) concurrently: specifier: ^9.0.1 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.8)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) + specifier: ^8.5.1 + version: 8.5.1(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) packages/manager: dependencies: + '@akamai/cds-components': + specifier: 0.0.0-20260407204557 + version: 0.0.0-20260407204557(@akamai/cds-icons@0.0.0-20260407204557(lit@3.3.2)(react@19.1.0))(@akamai/cds-tokens@0.0.0-20260407204557)(@lit/react@1.0.8(@types/react@19.1.6))(lit@3.3.2)(luxon@3.4.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@akamai/cds-icons': + specifier: 0.0.0-20260407204557 + version: 0.0.0-20260407204557(lit@3.3.2)(react@19.1.0) + '@akamai/cds-tokens': + specifier: 0.0.0-20260407204557 + version: 0.0.0-20260407204557 '@braintree/sanitize-url': specifier: ^7.1.0 version: 7.1.0 @@ -220,15 +229,12 @@ importers: '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 - akamai-cds-react-components: - specifier: 0.1.0 - version: 0.1.0(@linode/design-language-system@5.3.2)(@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 axios: - specifier: ~1.13.5 - version: 1.13.5 + specifier: ~1.15.0 + version: 1.15.0 braintree-web: specifier: ^3.92.2 version: 3.112.1 @@ -367,10 +373,10 @@ importers: version: 10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@storybook/addon-docs': specifier: ^10.3.3 - version: 10.3.3(@types/react@19.1.6)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 10.3.3(@types/react@19.1.6)(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.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: ^10.3.3 - version: 10.3.3(esbuild@0.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.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)) + version: 10.3.3(esbuild@0.27.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@7.3.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 @@ -454,10 +460,10 @@ importers: version: 4.4.5 '@vitejs/plugin-react-swc': specifier: ^4.0.1 - 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)) + version: 4.0.1(vite@7.3.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@3.2.4(@types/node@22.18.1)(@vitest/ui@4.1.2(vitest@4.1.2))(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) @@ -496,7 +502,7 @@ importers: version: 1.15.0(cypress@15.13.0) cypress-vite: specifier: ^1.8.0 - version: 1.8.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)) + version: 1.8.0(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) dotenv: specifier: ^16.0.3 version: 16.4.5 @@ -531,11 +537,11 @@ importers: specifier: ^10.3.3 version: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 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) + specifier: ^7.3.2 + version: 7.3.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.60.0)(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.5.0(rollup@4.60.1)(typescript@5.9.3)(vite@7.3.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: @@ -586,8 +592,8 @@ importers: specifier: ^4.0.3 version: 4.2.0 vite: - specifier: '*' - 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) + specifier: ^7.3.2 + version: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) devDependencies: '@linode/tsconfig': specifier: workspace:* @@ -619,7 +625,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^10.3.3 - version: 10.3.3(esbuild@0.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 10.3.3(esbuild@0.27.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@7.3.2(@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 @@ -643,7 +649,7 @@ importers: version: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.5.0(rollup@4.60.1)(typescript@5.9.3)(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/tsconfig: {} @@ -688,7 +694,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^10.3.3 - version: 10.3.3(esbuild@0.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 10.3.3(esbuild@0.27.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@7.3.2(@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 @@ -715,7 +721,7 @@ importers: version: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.5.0(rollup@4.60.1)(typescript@5.9.3)(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/utilities: dependencies: @@ -776,8 +782,8 @@ importers: specifier: ^9.0.1 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.8)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) + specifier: ^8.5.1 + version: 8.5.1(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) scripts: devDependencies: @@ -822,6 +828,46 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@akamai/cds-components@0.0.0-20260407204557': + resolution: {integrity: sha512-/p5GJy7/OLTm7ylh/mtcnFS7XtHGEYzyGcDGTZzr7OWoqhDr7G66EkrTaRhw7+jVShMvF4xEAnNA7h2MEkdeKw==, tarball: https://repos.akamai.com/artifactory/api/npm/npmjs-repos/@akamai/cds-components/-/@akamai/cds-components-0.0.0-20260407204557.tgz} + peerDependencies: + '@akamai/cds-icons': 0.0.0-20260407204557 + '@akamai/cds-tokens': 0.0.0-20260407204557 + '@angular/core': ^21.2.5 + '@angular/forms': ^21.2.5 + '@lit/react': ^1.0.0 + lit: ^3.2.1 + luxon: ^3.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@angular/core': + optional: true + '@angular/forms': + optional: true + '@lit/react': + optional: true + luxon: + optional: true + react: + optional: true + react-dom: + optional: true + + '@akamai/cds-icons@0.0.0-20260407204557': + resolution: {integrity: sha512-NKI1tGqT6UzRy+RuRTVEVbkPJLWLxZ+CkSC5ZRajdLm6MDA6/b5m+TgpD3ccjeNZwJLg4y3r30K3iUi0dmOkFw==, tarball: https://repos.akamai.com/artifactory/api/npm/npmjs-repos/@akamai/cds-icons/-/@akamai/cds-icons-0.0.0-20260407204557.tgz} + peerDependencies: + lit: ^3.2.1 + react: ^18.0.0 + peerDependenciesMeta: + lit: + optional: true + react: + optional: true + + '@akamai/cds-tokens@0.0.0-20260407204557': + resolution: {integrity: sha512-jVEMS3bw/E1kJrJv/Is2Zb+Bp0JW9kUU9laoh3a4GaJQTEs82U+Au2d85M393h5gsS+iM81YbCRP48cnKWXOAg==, tarball: https://repos.akamai.com/artifactory/api/npm/npmjs-repos/@akamai/cds-tokens/-/@akamai/cds-tokens-0.0.0-20260407204557.tgz} + '@algolia/cache-browser-local-storage@4.24.0': resolution: {integrity: sha512-t63W9BnoXVrGy9iYHBgObNXqYXM3tYXCjDSHeNwnsc324r4o5UiVKUiAB4THQ5z9U5hTj6qUvwg/Ez43ZD85ww==} @@ -1151,14 +1197,14 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.3': - resolution: {integrity: sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==} + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -1169,14 +1215,14 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.3': - resolution: {integrity: sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -1187,14 +1233,14 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.3': - resolution: {integrity: sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -1205,14 +1251,14 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.3': - resolution: {integrity: sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -1223,14 +1269,14 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.3': - resolution: {integrity: sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -1241,14 +1287,14 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.3': - resolution: {integrity: sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -1259,14 +1305,14 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.3': - resolution: {integrity: sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -1277,14 +1323,14 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.3': - resolution: {integrity: sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -1295,14 +1341,14 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.3': - resolution: {integrity: sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -1313,14 +1359,14 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.3': - resolution: {integrity: sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -1331,14 +1377,14 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.3': - resolution: {integrity: sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -1349,14 +1395,14 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.3': - resolution: {integrity: sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -1367,14 +1413,14 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.3': - resolution: {integrity: sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -1385,14 +1431,14 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.3': - resolution: {integrity: sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -1403,14 +1449,14 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.3': - resolution: {integrity: sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -1421,14 +1467,14 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.3': - resolution: {integrity: sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -1439,14 +1485,14 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.3': - resolution: {integrity: sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -1457,14 +1503,14 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.25.3': - resolution: {integrity: sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -1475,14 +1521,14 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.3': - resolution: {integrity: sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -1493,14 +1539,14 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.25.3': - resolution: {integrity: sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -1511,14 +1557,14 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.3': - resolution: {integrity: sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -1535,20 +1581,26 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.3': - resolution: {integrity: sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -1559,14 +1611,14 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.3': - resolution: {integrity: sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -1577,14 +1629,14 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.3': - resolution: {integrity: sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -1595,14 +1647,14 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.3': - resolution: {integrity: sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1856,10 +1908,6 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@jridgewell/gen-mapping@0.3.8': - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} - engines: {node: '>=6.0.0'} - '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -1867,10 +1915,6 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.11': resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} @@ -1880,9 +1924,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -2178,29 +2219,14 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.40.1': - resolution: {integrity: sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm-eabi@4.53.3': - resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} - cpu: [arm] - os: [android] - '@rollup/rollup-android-arm-eabi@4.60.0': resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.40.1': - resolution: {integrity: sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-android-arm64@4.53.3': - resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} - cpu: [arm64] + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] os: [android] '@rollup/rollup-android-arm64@4.60.0': @@ -2208,29 +2234,19 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.40.1': - resolution: {integrity: sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-arm64@4.53.3': - resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} cpu: [arm64] - os: [darwin] + os: [android] '@rollup/rollup-darwin-arm64@4.60.0': resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.40.1': - resolution: {integrity: sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.53.3': - resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} - cpu: [x64] + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] os: [darwin] '@rollup/rollup-darwin-x64@4.60.0': @@ -2238,29 +2254,19 @@ packages: cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.40.1': - resolution: {integrity: sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-arm64@4.53.3': - resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} - cpu: [arm64] - os: [freebsd] + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] '@rollup/rollup-freebsd-arm64@4.60.0': resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.40.1': - resolution: {integrity: sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.53.3': - resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} - cpu: [x64] + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] os: [freebsd] '@rollup/rollup-freebsd-x64@4.60.0': @@ -2268,219 +2274,195 @@ packages: cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.40.1': - resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} - cpu: [arm] - os: [linux] + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] '@rollup/rollup-linux-arm-gnueabihf@4.60.0': resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.40.1': - resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] + libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.40.1': - resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.53.3': - resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} - cpu: [arm64] + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.40.1': - resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.53.3': - resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.53.3': - resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} - cpu: [loong64] + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.0': - resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-loongarch64-gnu@4.40.1': - resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==} + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-powerpc64le-gnu@4.40.1': - resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==} - cpu: [ppc64] + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.60.0': - resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.40.1': - resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} - cpu: [riscv64] + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.40.1': - resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.53.3': - resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.40.1': - resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.53.3': - resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} - cpu: [s390x] + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.40.1': - resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.53.3': - resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} - cpu: [x64] + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.40.1': - resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==} + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.53.3': - resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-x64-musl@4.60.0': - resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.53.3': - resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} - cpu: [arm64] - os: [openharmony] + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] '@rollup/rollup-openharmony-arm64@4.60.0': resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.40.1': - resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-arm64-msvc@4.53.3': - resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} cpu: [arm64] - os: [win32] + os: [openharmony] '@rollup/rollup-win32-arm64-msvc@4.60.0': resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.40.1': - resolution: {integrity: sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.53.3': - resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} - cpu: [ia32] + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] os: [win32] '@rollup/rollup-win32-ia32-msvc@4.60.0': @@ -2488,9 +2470,9 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.53.3': - resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} - cpu: [x64] + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] os: [win32] '@rollup/rollup-win32-x64-gnu@4.60.0': @@ -2498,18 +2480,18 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.40.1': - resolution: {integrity: sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==} + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.53.3': - resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.0': - resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} cpu: [x64] os: [win32] @@ -2564,8 +2546,8 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@storybook/addon-a11y@10.3.3': resolution: {integrity: sha512-1yELCE8NXUJKcfS2k97pujtVw4z95PCwyoy2I6VAPiG/nRnJI8M6ned08YmCMEJhLBgGA1+GBh9HO4uk+xPcYA==} @@ -2752,48 +2734,56 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-gnu@1.13.5': resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.10.11': resolution: {integrity: sha512-2mMscXe/ivq8c4tO3eQSbQDFBvagMJGlalXCspn0DgDImLYTEnt/8KHMUMGVfh0gMJTZ9q4FlGLo7mlnbx99MQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-arm64-musl@1.13.5': resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.10.11': resolution: {integrity: sha512-eu2apgDbC4xwsigpl6LS+iyw6a3mL6kB4I+6PZMbFF2nIb1Dh7RGnu70Ai6mMn1o80fTmRSKsCT3CKMfVdeNFg==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-gnu@1.13.5': resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.10.11': resolution: {integrity: sha512-0n+wPWpDigwqRay4IL2JIvAqSKCXv6nKxPig9M7+epAlEQlqX+8Oq/Ap3yHtuhjNPb7HmnqNJLCXT1Wx+BZo0w==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-linux-x64-musl@1.13.5': resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.10.11': resolution: {integrity: sha512-7+bMSIoqcbXKosIVd314YjckDRPneA4OpG1cb3/GrkQTEDXmWT3pFBBlJf82hzJfw7b6lfv6rDVEFBX7/PJoLA==} @@ -2965,9 +2955,6 @@ packages: '@types/chai@5.0.1': resolution: {integrity: sha512-5T8ajsg3M/FOncpLYW7sdOcD6yf4+722sze/tc4KQV0P8Z2rAr3SAuHCIkYmYpt8VbcQlnz8SxlOlPQYefe4cA==} - '@types/chai@5.2.2': - resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3007,9 +2994,6 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -3070,9 +3054,6 @@ packages: '@types/mocha@10.0.9': resolution: {integrity: sha512-sicdRoWtYevwxjOHNMPTl3vSfJM6oyW8o1wXeI7uww6b6xHg8eBznQDNSGBCDJmsE8UMxP05JgZRtsKbTqt//Q==} - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@22.18.1': resolution: {integrity: sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==} @@ -3288,14 +3269,25 @@ packages: '@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.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} - '@vitest/mocker@4.0.10': - resolution: {integrity: sha512-e2OfdexYkjkg8Hh3L9NVEfbwGXq5IZbDovkf30qW2tOh7Rh9sVtmSr2ztEXOFbymNxS4qjzLXUQIvATvN4B+lg==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true @@ -3305,31 +3297,37 @@ packages: '@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.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/runner@4.0.10': - resolution: {integrity: sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==} + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} - '@vitest/snapshot@4.0.10': - resolution: {integrity: sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} '@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.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} - '@vitest/ui@4.0.10': - resolution: {integrity: sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==} + '@vitest/ui@4.1.2': + resolution: {integrity: sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==} peerDependencies: - vitest: 4.0.10 + vitest: 4.1.2 '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.0.10': - resolution: {integrity: sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==} + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} '@vueless/storybook-dark-mode@9.0.5': resolution: {integrity: sha512-JU0bQe+KHvmg04k2yprzVkM0d8xdKwqFaFuQmO7afIUm//ttroDpfHfPzwLZuTDW9coB5bt2+qMSHZOBbt0w4g==} @@ -3368,17 +3366,6 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - akamai-cds-react-components@0.1.0: - resolution: {integrity: sha512-3j4RUS2gsEvTvGVihG48oxfZ+sFRGM+6zfB04UNIVrxsIa5QLYhA4C6RKqmuEqKlsQhgSeg/XkcKJh4vKmW3Qw==} - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - - akamai-cds-web-components@0.1.0: - resolution: {integrity: sha512-Ia8JIqr0dV1yuFAaEiP1KfTOZVNLIc0OhWIcnmPb7lnUUOtKfLSB3bbUyYhcm0xag+hLCBxo+Ds+axE9ZL55uw==} - peerDependencies: - '@linode/design-language-system': ^5.3.2 - algoliasearch@4.24.0: resolution: {integrity: sha512-bf0QV/9jVejssFBmz2HQLxUadxk574t4iwjCKp5E7NBzwKkrDEhKPISIIjAU/p6K5qDx3qoeh4+26zWN1jmw3g==} @@ -3517,8 +3504,8 @@ packages: peerDependencies: axios: '>= 0.17.0' - axios@1.13.5: - resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -3667,10 +3654,6 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} - chai@6.2.1: - resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} - engines: {node: '>=18'} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -3837,6 +3820,9 @@ packages: engines: {node: '>=18'} hasBin: true + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -4219,6 +4205,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -4239,13 +4228,13 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.25.3: - resolution: {integrity: sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true @@ -4411,8 +4400,8 @@ packages: resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} engines: {node: '>=4'} - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} extend@3.0.2: @@ -4463,14 +4452,6 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.4.4: - resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4506,6 +4487,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flag-icons@6.15.0: resolution: {integrity: sha512-ARo9Q+aATZEjyjveeec9e+orx+xLWUBdOX9baOKoGqDzMbvZ65ghPhaHbVt5T7ZB+Q4OFsB4Hr+eQnpV8Q+dLA==} @@ -4520,6 +4504,9 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -5267,9 +5254,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -5439,6 +5423,9 @@ packages: engines: {node: '>=10'} hasBin: true + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + mocha-junit-reporter@2.2.1: resolution: {integrity: sha512-iDn2tlKHn8Vh8o4nCzcUVW4q7iXp7cC4EB78N0cDHIobLymyHNwe0XG8HEHHjc3hJlXm0Vy6zcrxaIhnI2fWmw==} peerDependencies: @@ -5558,6 +5545,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==} @@ -5693,10 +5683,6 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} - engines: {node: '>=12'} - picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -5718,6 +5704,9 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} @@ -5740,10 +5729,6 @@ packages: yaml: optional: true - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -5791,8 +5776,9 @@ packages: proxy-from-env@1.0.0: resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} psl@1.10.0: resolution: {integrity: sha512-KSKHEbjAnpUuAUserOq0FxGXCUrzC3WniuSJhvdbs102rL55266ZcHBqLWOsG30spQMlPdpy7icATiAQehg/iA==} @@ -6065,18 +6051,13 @@ packages: resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} engines: {node: '>= 0.8.15'} - rollup@4.40.1: - resolution: {integrity: sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - rollup@4.53.3: - resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rollup@4.60.0: - resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6252,10 +6233,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.8.0-beta.0: - resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} - engines: {node: '>= 8'} - deprecated: The work that was done in this beta branch won't be included in future versions + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -6280,12 +6260,12 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + storybook@10.3.3: resolution: {integrity: sha512-tMoRAts9EVqf+mEMPLC6z1DPyHbcPe+CV1MhLN55IKsl0HxNjvVGK44rVPSePbltPE6vIsn4bdRj6CCUt8SJwQ==} hasBin: true @@ -6372,6 +6352,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -6462,20 +6445,28 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyglobby@0.2.13: - resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} - engines: {node: '>=12.0.0'} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tinyspy@4.0.4: @@ -6515,9 +6506,6 @@ packages: resolution: {integrity: sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==} engines: {node: '>=16'} - tr46@1.0.1: - resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} - tr46@5.0.0: resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} engines: {node: '>=18'} @@ -6565,8 +6553,8 @@ packages: '@mui/material': optional: true - tsup@8.4.0: - resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -6664,6 +6652,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -6751,13 +6742,18 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-svgr@4.5.0: resolution: {integrity: sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==} peerDependencies: vite: '>=2.6.0' - vite@7.2.2: - resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6796,64 +6792,53 @@ packages: yaml: optional: true - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} - engines: {node: ^20.19.0 || >=22.12.0} + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.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 + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: + '@edge-runtime/vm': optional: true - sass-embedded: + '@types/debug': optional: true - stylus: + '@types/node': optional: true - sugarss: + '@vitest/browser': optional: true - terser: + '@vitest/ui': optional: true - tsx: + happy-dom: optional: true - yaml: + jsdom: optional: true - vitest@4.0.10: - resolution: {integrity: sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==} + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 + '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.10 - '@vitest/browser-preview': 4.0.10 - '@vitest/browser-webdriverio': 4.0.10 - '@vitest/ui': 4.0.10 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true @@ -6874,9 +6859,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - webidl-conversions@4.0.2: - resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -6897,9 +6879,6 @@ packages: resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==} engines: {node: '>=18'} - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -7073,6 +7052,24 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@akamai/cds-components@0.0.0-20260407204557(@akamai/cds-icons@0.0.0-20260407204557(lit@3.3.2)(react@19.1.0))(@akamai/cds-tokens@0.0.0-20260407204557)(@lit/react@1.0.8(@types/react@19.1.6))(lit@3.3.2)(luxon@3.4.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@akamai/cds-icons': 0.0.0-20260407204557(lit@3.3.2)(react@19.1.0) + '@akamai/cds-tokens': 0.0.0-20260407204557 + lit: 3.3.2 + optionalDependencies: + '@lit/react': 1.0.8(@types/react@19.1.6) + luxon: 3.4.4 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@akamai/cds-icons@0.0.0-20260407204557(lit@3.3.2)(react@19.1.0)': + optionalDependencies: + lit: 3.3.2 + react: 19.1.0 + + '@akamai/cds-tokens@0.0.0-20260407204557': {} + '@algolia/cache-browser-local-storage@4.24.0': dependencies: '@algolia/cache-common': 4.24.0 @@ -7536,190 +7533,190 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.25.3': + '@esbuild/aix-ppc64@0.27.4': optional: true - '@esbuild/aix-ppc64@0.27.4': + '@esbuild/aix-ppc64@0.27.7': optional: true '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.25.3': + '@esbuild/android-arm64@0.27.4': optional: true - '@esbuild/android-arm64@0.27.4': + '@esbuild/android-arm64@0.27.7': optional: true '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.25.3': + '@esbuild/android-arm@0.27.4': optional: true - '@esbuild/android-arm@0.27.4': + '@esbuild/android-arm@0.27.7': optional: true '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.25.3': + '@esbuild/android-x64@0.27.4': optional: true - '@esbuild/android-x64@0.27.4': + '@esbuild/android-x64@0.27.7': optional: true '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.25.3': + '@esbuild/darwin-arm64@0.27.4': optional: true - '@esbuild/darwin-arm64@0.27.4': + '@esbuild/darwin-arm64@0.27.7': optional: true '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.25.3': + '@esbuild/darwin-x64@0.27.4': optional: true - '@esbuild/darwin-x64@0.27.4': + '@esbuild/darwin-x64@0.27.7': optional: true '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.25.3': + '@esbuild/freebsd-arm64@0.27.4': optional: true - '@esbuild/freebsd-arm64@0.27.4': + '@esbuild/freebsd-arm64@0.27.7': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.25.3': + '@esbuild/freebsd-x64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.27.4': + '@esbuild/freebsd-x64@0.27.7': optional: true '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.25.3': + '@esbuild/linux-arm64@0.27.4': optional: true - '@esbuild/linux-arm64@0.27.4': + '@esbuild/linux-arm64@0.27.7': optional: true '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.25.3': + '@esbuild/linux-arm@0.27.4': optional: true - '@esbuild/linux-arm@0.27.4': + '@esbuild/linux-arm@0.27.7': optional: true '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.25.3': + '@esbuild/linux-ia32@0.27.4': optional: true - '@esbuild/linux-ia32@0.27.4': + '@esbuild/linux-ia32@0.27.7': optional: true '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.25.3': + '@esbuild/linux-loong64@0.27.4': optional: true - '@esbuild/linux-loong64@0.27.4': + '@esbuild/linux-loong64@0.27.7': optional: true '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.25.3': + '@esbuild/linux-mips64el@0.27.4': optional: true - '@esbuild/linux-mips64el@0.27.4': + '@esbuild/linux-mips64el@0.27.7': optional: true '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.25.3': + '@esbuild/linux-ppc64@0.27.4': optional: true - '@esbuild/linux-ppc64@0.27.4': + '@esbuild/linux-ppc64@0.27.7': optional: true '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.25.3': + '@esbuild/linux-riscv64@0.27.4': optional: true - '@esbuild/linux-riscv64@0.27.4': + '@esbuild/linux-riscv64@0.27.7': optional: true '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.25.3': + '@esbuild/linux-s390x@0.27.4': optional: true - '@esbuild/linux-s390x@0.27.4': + '@esbuild/linux-s390x@0.27.7': optional: true '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.25.3': + '@esbuild/linux-x64@0.27.4': optional: true - '@esbuild/linux-x64@0.27.4': + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.25.3': + '@esbuild/netbsd-arm64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.27.4': + '@esbuild/netbsd-arm64@0.27.7': optional: true '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.25.3': + '@esbuild/netbsd-x64@0.27.4': optional: true - '@esbuild/netbsd-x64@0.27.4': + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.25.3': + '@esbuild/openbsd-arm64@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.27.4': + '@esbuild/openbsd-arm64@0.27.7': optional: true '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.25.3': + '@esbuild/openbsd-x64@0.27.4': optional: true - '@esbuild/openbsd-x64@0.27.4': + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.25.12': @@ -7728,42 +7725,45 @@ snapshots: '@esbuild/openharmony-arm64@0.27.4': optional: true - '@esbuild/sunos-x64@0.25.12': + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/sunos-x64@0.25.3': + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.4': optional: true - '@esbuild/win32-arm64@0.25.12': + '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/win32-arm64@0.25.3': + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.4': optional: true - '@esbuild/win32-ia32@0.25.12': + '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-ia32@0.25.3': + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.4': optional: true - '@esbuild/win32-x64@0.25.12': + '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-x64@0.25.3': + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.4': optional: true + '@esbuild/win32-x64@0.27.7': + 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) @@ -7994,21 +7994,13 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@istanbuljs/schema@0.1.3': {} - - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(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: 13.0.6 - 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.9.3 + '@istanbuljs/schema@0.1.3': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.2.2(typescript@5.9.3) - vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.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 @@ -8017,12 +8009,6 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/gen-mapping@0.3.8': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -8030,8 +8016,6 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.11': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -8042,11 +8026,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -8071,6 +8050,7 @@ snapshots: '@lit/react@1.0.8(@types/react@19.1.6)': dependencies: '@types/react': 19.1.6 + optional: true '@lit/reactive-element@2.1.2': dependencies: @@ -8322,213 +8302,162 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.32': {} - '@rollup/pluginutils@5.2.0(rollup@4.60.0)': + '@rollup/pluginutils@5.2.0(rollup@4.60.1)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.60.0 - - '@rollup/rollup-android-arm-eabi@4.40.1': - optional: true - - '@rollup/rollup-android-arm-eabi@4.53.3': - optional: true + rollup: 4.60.1 '@rollup/rollup-android-arm-eabi@4.60.0': optional: true - '@rollup/rollup-android-arm64@4.40.1': - optional: true - - '@rollup/rollup-android-arm64@4.53.3': + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true '@rollup/rollup-android-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-arm64@4.40.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.53.3': + '@rollup/rollup-android-arm64@4.60.1': optional: true '@rollup/rollup-darwin-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-x64@4.40.1': - optional: true - - '@rollup/rollup-darwin-x64@4.53.3': + '@rollup/rollup-darwin-arm64@4.60.1': optional: true '@rollup/rollup-darwin-x64@4.60.0': optional: true - '@rollup/rollup-freebsd-arm64@4.40.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.53.3': + '@rollup/rollup-darwin-x64@4.60.1': optional: true '@rollup/rollup-freebsd-arm64@4.60.0': optional: true - '@rollup/rollup-freebsd-x64@4.40.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.53.3': + '@rollup/rollup-freebsd-arm64@4.60.1': optional: true '@rollup/rollup-freebsd-x64@4.60.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.40.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + '@rollup/rollup-freebsd-x64@4.60.1': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.40.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': optional: true '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.40.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.53.3': + '@rollup/rollup-linux-arm-musleabihf@4.60.1': optional: true '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.40.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.53.3': + '@rollup/rollup-linux-arm64-gnu@4.60.1': optional: true '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.53.3': + '@rollup/rollup-linux-arm64-musl@4.60.1': optional: true '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.0': - optional: true - - '@rollup/rollup-linux-loongarch64-gnu@4.40.1': + '@rollup/rollup-linux-loong64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.40.1': + '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.53.3': + '@rollup/rollup-linux-loong64-musl@4.60.1': optional: true '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.0': + '@rollup/rollup-linux-ppc64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.40.1': + '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.53.3': + '@rollup/rollup-linux-ppc64-musl@4.60.1': optional: true '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.40.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.53.3': + '@rollup/rollup-linux-riscv64-gnu@4.60.1': optional: true '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.40.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.53.3': + '@rollup/rollup-linux-riscv64-musl@4.60.1': optional: true '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.40.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.53.3': + '@rollup/rollup-linux-s390x-gnu@4.60.1': optional: true '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-musl@4.40.1': + '@rollup/rollup-linux-x64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-x64-musl@4.53.3': + '@rollup/rollup-linux-x64-musl@4.60.0': optional: true - '@rollup/rollup-linux-x64-musl@4.60.0': + '@rollup/rollup-linux-x64-musl@4.60.1': optional: true '@rollup/rollup-openbsd-x64@4.60.0': optional: true - '@rollup/rollup-openharmony-arm64@4.53.3': + '@rollup/rollup-openbsd-x64@4.60.1': optional: true '@rollup/rollup-openharmony-arm64@4.60.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.40.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.53.3': + '@rollup/rollup-openharmony-arm64@4.60.1': optional: true '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.40.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.53.3': + '@rollup/rollup-win32-arm64-msvc@4.60.1': optional: true '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.53.3': + '@rollup/rollup-win32-ia32-msvc@4.60.1': optional: true '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.40.1': + '@rollup/rollup-win32-x64-gnu@4.60.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.53.3': + '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.0': + '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true '@sentry-internal/browser-utils@9.19.0': @@ -8599,7 +8528,7 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} - '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@storybook/addon-a11y@10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': dependencies: @@ -8607,10 +8536,10 @@ snapshots: axe-core: 4.10.2 storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/addon-docs@10.3.3(@types/react@19.1.6)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/addon-docs@10.3.3(@types/react@19.1.6)(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.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': 10.3.3(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@storybook/csf-plugin': 10.3.3(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@storybook/icons': 2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@storybook/react-dom-shim': 10.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) react: 19.1.0 @@ -8624,45 +8553,25 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.3.3(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': - dependencies: - '@storybook/csf-plugin': 10.3.3(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - 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) - transitivePeerDependencies: - - esbuild - - rollup - - webpack - - '@storybook/builder-vite@10.3.3(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/builder-vite@10.3.3(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.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': 10.3.3(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@storybook/csf-plugin': 10.3.3(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.3.3(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': - dependencies: - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - unplugin: 2.3.11 - optionalDependencies: - esbuild: 0.27.4 - rollup: 4.60.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@10.3.3(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/csf-plugin@10.3.3(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.27.4 - rollup: 4.60.0 - vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + esbuild: 0.27.7 + rollup: 4.60.1 + vite: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@storybook/global@5.0.0': {} @@ -8682,33 +8591,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-vite@10.3.3(esbuild@0.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.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))': - dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(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.60.0) - '@storybook/builder-vite': 10.3.3(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@storybook/react': 10.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3) - empathic: 2.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: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - 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: - - esbuild - - rollup - - supports-color - - typescript - - webpack - - '@storybook/react-vite@10.3.3(esbuild@0.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/react-vite@10.3.3(esbuild@0.27.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@7.3.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.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@rollup/pluginutils': 5.2.0(rollup@4.60.0) - '@storybook/builder-vite': 10.3.3(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.3)(vite@7.3.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.60.1) + '@storybook/builder-vite': 10.3.3(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@storybook/react': 10.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -8718,7 +8605,7 @@ snapshots: resolve: 1.22.8 storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tsconfig-paths: 4.2.0 - vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - esbuild - rollup @@ -9045,10 +8932,6 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 - '@types/chai@5.2.2': - dependencies: - '@types/deep-eql': 4.0.2 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -9086,11 +8969,6 @@ snapshots: '@types/d3-timer@3.0.2': {} - '@types/debug@4.1.12': - dependencies: - '@types/ms': 2.1.0 - optional: true - '@types/deep-eql@4.0.2': {} '@types/doctrine@0.0.9': {} @@ -9146,9 +9024,6 @@ snapshots: '@types/mocha@10.0.9': {} - '@types/ms@2.1.0': - optional: true - '@types/node@22.18.1': dependencies: undici-types: 6.21.0 @@ -9402,15 +9277,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@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))': + '@vitejs/plugin-react-swc@4.0.1(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.32 '@swc/core': 1.13.5 - 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.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@3.2.4(vitest@4.0.10)': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.18.1)(@vitest/ui@4.1.2(vitest@4.1.2))(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 @@ -9425,7 +9300,7 @@ 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.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 3.2.4(@types/node@22.18.1)(@vitest/ui@4.1.2(vitest@4.1.2))(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 @@ -9437,49 +9312,62 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.0.10': + '@vitest/expect@4.1.2': dependencies: - '@standard-schema/spec': 1.0.0 - '@types/chai': 5.2.2 - '@vitest/spy': 4.0.10 - '@vitest/utils': 4.0.10 - chai: 6.2.1 - tinyrainbow: 3.0.3 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@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))': + '@vitest/mocker@3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.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 + '@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.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) + msw: 2.6.5(@types/node@22.18.1)(typescript@5.9.3) + vite: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - '@vitest/mocker@4.0.10(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@4.1.2(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.3.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 + '@vitest/spy': 4.1.2 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) + msw: 2.6.5(@types/node@22.18.1)(typescript@5.7.3) + vite: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.0.10': + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@3.2.4': dependencies: - tinyrainbow: 3.0.3 + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 - '@vitest/runner@4.0.10': + '@vitest/snapshot@3.2.4': dependencies: - '@vitest/utils': 4.0.10 + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/snapshot@4.0.10': + '@vitest/snapshot@4.1.2': dependencies: - '@vitest/pretty-format': 4.0.10 + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 magic-string: 0.30.21 pathe: 2.0.3 @@ -9487,18 +9375,18 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.0.10': {} + '@vitest/spy@4.1.2': {} - '@vitest/ui@4.0.10(vitest@4.0.10)': + '@vitest/ui@4.1.2(vitest@4.1.2)': dependencies: - '@vitest/utils': 4.0.10 + '@vitest/utils': 4.1.2 fflate: 0.8.2 - flatted: 3.3.3 + flatted: 3.4.2 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.15 - 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) + tinyrainbow: 3.1.0 + vitest: 4.1.2(@types/node@22.18.1)(@vitest/ui@4.1.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/utils@3.2.4': dependencies: @@ -9506,10 +9394,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.0.10': + '@vitest/utils@4.1.2': dependencies: - '@vitest/pretty-format': 4.0.10 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@vueless/storybook-dark-mode@9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: @@ -9551,21 +9440,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - akamai-cds-react-components@0.1.0(@linode/design-language-system@5.3.2)(@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.1.0(@linode/design-language-system@5.3.2) - 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.1.0(@linode/design-language-system@5.3.2): - dependencies: - '@linode/design-language-system': 5.3.2 - lit: 3.3.2 - algoliasearch@4.24.0: dependencies: '@algolia/cache-browser-local-storage': 4.24.0 @@ -9716,17 +9590,17 @@ snapshots: axe-core@4.10.2: {} - axios-mock-adapter@1.22.0(axios@1.13.5): + axios-mock-adapter@1.22.0(axios@1.15.0): dependencies: - axios: 1.13.5 + axios: 1.15.0 fast-deep-equal: 3.1.3 is-buffer: 2.0.5 - axios@1.13.5: + axios@1.15.0: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -9820,9 +9694,9 @@ snapshots: dependencies: run-applescript: 7.1.0 - bundle-require@5.1.0(esbuild@0.25.3): + bundle-require@5.1.0(esbuild@0.27.4): dependencies: - esbuild: 0.25.3 + esbuild: 0.27.4 load-tsconfig: 0.2.5 bytes@3.1.2: {} @@ -9891,8 +9765,6 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 - chai@6.2.1: {} - chai@6.2.2: {} chalk@3.0.0: @@ -10051,6 +9923,8 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 + confbox@0.1.8: {} + consola@3.4.2: {} consolidated-events@2.0.2: {} @@ -10157,11 +10031,11 @@ snapshots: dependencies: cypress: 15.13.0 - cypress-vite@1.8.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)): + cypress-vite@1.8.0(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: chokidar: 3.6.0 debug: 4.4.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) + vite: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -10499,6 +10373,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -10549,34 +10425,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.25.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.3 - '@esbuild/android-arm': 0.25.3 - '@esbuild/android-arm64': 0.25.3 - '@esbuild/android-x64': 0.25.3 - '@esbuild/darwin-arm64': 0.25.3 - '@esbuild/darwin-x64': 0.25.3 - '@esbuild/freebsd-arm64': 0.25.3 - '@esbuild/freebsd-x64': 0.25.3 - '@esbuild/linux-arm': 0.25.3 - '@esbuild/linux-arm64': 0.25.3 - '@esbuild/linux-ia32': 0.25.3 - '@esbuild/linux-loong64': 0.25.3 - '@esbuild/linux-mips64el': 0.25.3 - '@esbuild/linux-ppc64': 0.25.3 - '@esbuild/linux-riscv64': 0.25.3 - '@esbuild/linux-s390x': 0.25.3 - '@esbuild/linux-x64': 0.25.3 - '@esbuild/netbsd-arm64': 0.25.3 - '@esbuild/netbsd-x64': 0.25.3 - '@esbuild/openbsd-arm64': 0.25.3 - '@esbuild/openbsd-x64': 0.25.3 - '@esbuild/sunos-x64': 0.25.3 - '@esbuild/win32-arm64': 0.25.3 - '@esbuild/win32-ia32': 0.25.3 - '@esbuild/win32-x64': 0.25.3 - esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -10606,6 +10454,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -10831,7 +10708,7 @@ snapshots: dependencies: pify: 2.3.0 - expect-type@1.2.2: {} + expect-type@1.3.0: {} extend@3.0.2: {} @@ -10886,10 +10763,6 @@ snapshots: dependencies: pend: 1.2.0 - fdir@6.4.4(picomatch@4.0.2): - optionalDependencies: - picomatch: 4.0.2 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -10923,6 +10796,12 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.0 + flag-icons@6.15.0: {} flat-cache@4.0.1: @@ -10934,6 +10813,8 @@ snapshots: flatted@3.3.3: {} + flatted@3.4.2: {} + follow-redirects@1.15.11: {} font-logos@0.18.0: {} @@ -11716,8 +11597,6 @@ snapshots: lodash.once@4.1.1: {} - lodash.sortby@4.7.0: {} - lodash@4.17.21: {} lodash@4.18.1: {} @@ -11887,6 +11766,13 @@ snapshots: mkdirp@3.0.1: {} + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + mocha-junit-reporter@2.2.1(mocha@10.8.2): dependencies: debug: 4.4.0 @@ -12090,6 +11976,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 @@ -12217,8 +12105,6 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.2: {} - picomatch@4.0.3: {} picomatch@4.0.4: {} @@ -12229,6 +12115,12 @@ snapshots: pirates@4.0.6: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + possible-typed-array-names@1.0.0: {} postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(yaml@2.6.1): @@ -12240,12 +12132,6 @@ snapshots: tsx: 4.19.3 yaml: 2.6.1 - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -12286,7 +12172,7 @@ snapshots: proxy-from-env@1.0.0: {} - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} psl@1.10.0: dependencies: @@ -12591,60 +12477,6 @@ snapshots: rgbcolor@1.0.1: optional: true - rollup@4.40.1: - dependencies: - '@types/estree': 1.0.7 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.40.1 - '@rollup/rollup-android-arm64': 4.40.1 - '@rollup/rollup-darwin-arm64': 4.40.1 - '@rollup/rollup-darwin-x64': 4.40.1 - '@rollup/rollup-freebsd-arm64': 4.40.1 - '@rollup/rollup-freebsd-x64': 4.40.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.40.1 - '@rollup/rollup-linux-arm-musleabihf': 4.40.1 - '@rollup/rollup-linux-arm64-gnu': 4.40.1 - '@rollup/rollup-linux-arm64-musl': 4.40.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.40.1 - '@rollup/rollup-linux-powerpc64le-gnu': 4.40.1 - '@rollup/rollup-linux-riscv64-gnu': 4.40.1 - '@rollup/rollup-linux-riscv64-musl': 4.40.1 - '@rollup/rollup-linux-s390x-gnu': 4.40.1 - '@rollup/rollup-linux-x64-gnu': 4.40.1 - '@rollup/rollup-linux-x64-musl': 4.40.1 - '@rollup/rollup-win32-arm64-msvc': 4.40.1 - '@rollup/rollup-win32-ia32-msvc': 4.40.1 - '@rollup/rollup-win32-x64-msvc': 4.40.1 - fsevents: 2.3.3 - - rollup@4.53.3: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.3 - '@rollup/rollup-android-arm64': 4.53.3 - '@rollup/rollup-darwin-arm64': 4.53.3 - '@rollup/rollup-darwin-x64': 4.53.3 - '@rollup/rollup-freebsd-arm64': 4.53.3 - '@rollup/rollup-freebsd-x64': 4.53.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 - '@rollup/rollup-linux-arm-musleabihf': 4.53.3 - '@rollup/rollup-linux-arm64-gnu': 4.53.3 - '@rollup/rollup-linux-arm64-musl': 4.53.3 - '@rollup/rollup-linux-loong64-gnu': 4.53.3 - '@rollup/rollup-linux-ppc64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-musl': 4.53.3 - '@rollup/rollup-linux-s390x-gnu': 4.53.3 - '@rollup/rollup-linux-x64-gnu': 4.53.3 - '@rollup/rollup-linux-x64-musl': 4.53.3 - '@rollup/rollup-openharmony-arm64': 4.53.3 - '@rollup/rollup-win32-arm64-msvc': 4.53.3 - '@rollup/rollup-win32-ia32-msvc': 4.53.3 - '@rollup/rollup-win32-x64-gnu': 4.53.3 - '@rollup/rollup-win32-x64-msvc': 4.53.3 - fsevents: 2.3.3 - rollup@4.60.0: dependencies: '@types/estree': 1.0.8 @@ -12676,6 +12508,37 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} run-applescript@7.1.0: {} @@ -12879,9 +12742,7 @@ snapshots: source-map@0.6.1: {} - source-map@0.8.0-beta.0: - dependencies: - whatwg-url: 7.1.0 + source-map@0.7.6: {} space-separated-tokens@2.0.2: {} @@ -12908,10 +12769,10 @@ snapshots: statuses@2.0.1: {} - std-env@3.10.0: {} - std-env@3.9.0: {} + std-env@4.0.0: {} + storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@storybook/global': 5.0.0 @@ -13036,11 +12897,15 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + stylis@4.2.0: {} sucrase@3.35.0: dependencies: - '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 glob: 10.5.0 lines-and-columns: 1.2.4 @@ -13121,19 +12986,23 @@ snapshots: tinyexec@0.3.2: {} - tinyglobby@0.2.13: - dependencies: - fdir: 6.4.4(picomatch@4.0.2) - picomatch: 4.0.2 + tinyexec@1.0.4: {} tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + tinyrainbow@2.0.0: {} - tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} tinyspy@4.0.4: {} @@ -13166,10 +13035,6 @@ snapshots: dependencies: tldts: 6.1.61 - tr46@1.0.1: - dependencies: - punycode: 2.3.1 - tr46@5.0.0: dependencies: punycode: 2.3.1 @@ -13206,23 +13071,24 @@ 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.8)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1): + tsup@8.5.1(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1): dependencies: - bundle-require: 5.1.0(esbuild@0.25.3) + bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.0 - esbuild: 0.25.3 + debug: 4.4.3(supports-color@8.1.1) + esbuild: 0.27.4 + fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(yaml@2.6.1) resolve-from: 5.0.0 - rollup: 4.40.1 - source-map: 0.8.0-beta.0 + rollup: 4.60.0 + source-map: 0.7.6 sucrase: 3.35.0 tinyexec: 0.3.2 - tinyglobby: 0.2.13 + tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.13.5 @@ -13316,6 +13182,8 @@ snapshots: uc.micro@2.1.0: {} + ufo@1.6.3: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -13428,51 +13296,45 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-svgr@4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + vite-node@3.2.4(@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.60.0) - '@svgr/core': 8.1.0(typescript@5.9.3) - '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - - rollup + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss - supports-color - - typescript + - terser + - tsx + - yaml - vite-plugin-svgr@4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + vite-plugin-svgr@4.5.0(rollup@4.60.1)(typescript@5.9.3)(vite@7.3.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.60.0) + '@rollup/pluginutils': 5.2.0(rollup@4.60.1) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.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@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 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.3 - 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 - - vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite@7.3.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.27.4 + esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.8 - rollup: 4.60.0 + rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.18.1 @@ -13482,32 +13344,34 @@ snapshots: 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): + vitest@3.2.4(@types/node@22.18.1)(@vitest/ui@4.1.2(vitest@4.1.2))(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.10 - '@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)) - '@vitest/pretty-format': 4.0.10 - '@vitest/runner': 4.0.10 - '@vitest/snapshot': 4.0.10 - '@vitest/spy': 4.0.10 - '@vitest/utils': 4.0.10 + '@types/chai': 5.2.3 + '@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.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 debug: 4.4.3(supports-color@8.1.1) - es-module-lexer: 1.7.0 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 + picomatch: 4.0.4 + std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.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) + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite-node: 3.2.4(@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/debug': 4.1.12 '@types/node': 22.18.1 - '@vitest/ui': 4.0.10(vitest@4.0.10) + '@vitest/ui': 4.1.2(vitest@4.1.2) jsdom: 24.1.3 transitivePeerDependencies: - jiti @@ -13523,53 +13387,39 @@ snapshots: - tsx - yaml - 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.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): - dependencies: - '@vitest/expect': 4.0.10 - '@vitest/mocker': 4.0.10(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@vitest/pretty-format': 4.0.10 - '@vitest/runner': 4.0.10 - '@vitest/snapshot': 4.0.10 - '@vitest/spy': 4.0.10 - '@vitest/utils': 4.0.10 - debug: 4.4.3(supports-color@8.1.1) - es-module-lexer: 1.7.0 - expect-type: 1.2.2 + vitest@4.1.2(@types/node@22.18.1)(@vitest/ui@4.1.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 + picomatch: 4.0.4 + std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 - tinyrainbow: 3.0.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) + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/debug': 4.1.12 '@types/node': 22.18.1 - '@vitest/ui': 4.0.10(vitest@4.0.10) + '@vitest/ui': 4.1.2(vitest@4.1.2) jsdom: 24.1.3 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 - webidl-conversions@4.0.2: {} - webidl-conversions@7.0.0: {} webpack-virtual-modules@0.6.2: {} @@ -13585,12 +13435,6 @@ snapshots: tr46: 5.0.0 webidl-conversions: 7.0.0 - whatwg-url@7.1.0: - dependencies: - lodash.sortby: 4.7.0 - tr46: 1.0.1 - webidl-conversions: 4.0.2 - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000000..efb1ed5c92f --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,12 @@ +# must be unique in a given SonarQube instance +sonar.projectKey=fee:cloud-maanager +# this is the name and version displayed in the SonarQube UI. Was mandatory prior to SonarQube 6.1. +sonar.projectName=cloud-manager +sonar.projectVersion=1.161.0 + +# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. +# This property is optional if sonar.modules is set. +sonar.language=ts +sonar.sources=packages/manager/src +sonar.tests=packages/manager/src +sonar.test.inclusions=**/*.spec.tsx,**/*.test.tsx \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 838d14d7957..6ceac2bf78d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + // Global timeout configurations for all projects + testTimeout: 30000, // 30 seconds per test + hookTimeout: 30000, // 30 seconds for hooks + teardownTimeout: 30000, // 30 seconds for teardown projects: [ "packages/api-v4", "packages/manager",