From 774ae87148665f3e8325c762566d4eb97ac9dfbf Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Fri, 5 Sep 2025 08:45:15 -0500 Subject: [PATCH 01/12] =?UTF-8?q?Release=20v1.150.0=20-=20release=20?= =?UTF-8?q?=E2=86=92=20staging=20(#12815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [DI-26731] - Alerts contextual view enhancement (#12730) * [DI-26371] - Update handler, reset state, update query * [DI-26371] - Update useffect * [DI-26371] - update mutation hook * [DI-26371] - Fix mutation query hook * [DI-26371] - Fix mutation query hook * [DI-26731] - Add comments * [DI-26731] - Add changeset * [DI-26731] - Fix saved state issue * fix: [M3-10266] - Add automatic redirect for empty paginated Access Keys pages (#12598) * fix: [M3-10266] - OBJ Pagination Redirects * Added changeset: Add automatic redirect for empty paginated Access Keys pages * Add todo comment --------- Co-authored-by: Jaalah Ramos * upcoming: [DI-26793] - Legend row label shown as per group by order (#12742) * upcoming: [DI-26793] - Update logic to show legend rows based on the group by order * Added changeset * Fixed typecheck failures * upcoming: [DPS-34039] Add actions in Streams list (#12645) * upcoming: [M3-10486] - Update dual-stack labeling in VPC Create (#12746) ## Description ๐Ÿ“ Copy adjustments to the VPC Create flow as requested by Daniel ## How to test ๐Ÿงช ### Prerequisites (How to setup test environment) - Pull this PR and point to devcloud (ensure your account has vpc ipv6 customer tag) ### Verification steps (How to verify changes) - [ ] Ensure the VPC IPv6 feature flag is enabled and your account has the VPC Dual Stack account capability - [ ] Go to the VPC Create page - [ ] You should see the updated label changes `IP Stack` and `(dual-stack)` * upcoming: [M3-10485] - Update dual-stack labeling in LKE cluster create (#12754) * Update copy for cluster create dual stack section * Update Cypress test * Added changeset: Update dual-stack labeling for LKE-E clusters in create cluster flow * Update unit test * test: [M3-7763] - Account for parallelization in Cypress test result summary duration (#12765) * Report Cypress test duration of slowest runner * Added changeset: Fix Cypress test result summary duration accuracy * test: [M3-10490] - Add error handling test coverage for LKE-E Phase 2 (BYO VPC, IP Stack) (#12755) * Add test spec for API error handling * Add test case for surfacing client-side validation on VPC field * Restructure test hierarchy and add test for field errors * Fix failures not caught when cherry picking from wrong branch * Added changeset: Add Cypress error handling test coverage for LKE-E Phase 2 (BYO VPC, IP Stack) * Fix test failures from merging in develop * [DI-26971] - Add no region info msg for nodebalancer and firewall (#12759) * [DI-26971] - Add no region info msg for nodebalancer and firewall * [DI-26971] - Correct nodebalancer msg * [DI-26971] - Update msgs * [DI-26971] - Remove duplicate * [DI-26971] - Add changeset --------- Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> * chore: [UIE-9124] Improve `usePermissions` hook type safety (#12732) * improve usePermissions type safety and cleanup accout landing routing * oops not ready to do this yet * Added changeset: Improve usePermissions hook type safety * cleanup * feedback @aaleksee-akamai * feedback @corya-akamai * feedback @aaleksee-akamai * fix: [M3-10496] - Fix LKE jumping UI on HA Control Plane (#12768) * fix jumping * Added changeset: Jumping UI on LKE Create HA Control Plane when enabling Akamai App Platform * Update packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * add margin left --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * fix: [UI-8803] - IAM Cross browser AssignedRoles entities chips truncation (#12720) * fix some * improve ellipsis pattern * cleanup * cleanup * Added changeset: IAM - Cross browser AssignedRoles entities chips truncation * feat: [UIE-9055] - IAM RBAC: change docs links (#12743) * feat: [UIE-9055] - IAM RBAC: change docs links * Added changeset: IAM RBAC: change docs links to be relevant to content on page * test: [M3-10433] - Test for empty string in numeric input validation (#12769) * numeric input validation test * verify toggle buttons are not disabled by error * Added changeset: Test for empty string in numeric input validation * test for full error msg * test: [M3-9717] - Fix LKE Create Smoke Test Flake (#12738) * M3-9717 Fix LKE Create Smoke Test Flake * Added changeset: Fix LKE Create Smoke Test Flake * test: [M3-10489] - Add Cypress integration test to confirm no LKE-E Phase 2 options visible to standard LKE cluster create flow (#12770) * Add test coverage for standard flow, no phase 2 options * Add changeset * Fix errors * Improve comment * test: [M3-10366] - Add Cypress LKE-E 'phase2Mtc' feature flag smoke tests (#12773) * Move shared utils to lke util file * Add smoke test file for LKE-Enterprise * Add coverage for LKE details flow; clean up * Delete old spec file * Add a TODO comment for M3-10365 * Make naming scheme and jsdocs comments consistent * Added changeset: Add Cypress LKE-E 'phase2Mtc' feature flag smoke tests * Move constant to the correct file * Clean up an unneeded mock * Mock requests that require Kubernetes Enterprise capability * feat: [M3-10324] - Add type-to-confirm to Images (#12740) * support type to confirm on Images * fix up cypress test and loading states * remove question mark from dialog title * add unit test for image row fix * fix type-safety issue * Added changeset: Type-to-confirm to Image deletion dialog --------- Co-authored-by: Banks Nussman * fix:[M3-10481]- Fix Autocomplete undefined value issues - Part 1 (#12706) * Create debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Remove debug .yml file * Fix autocompletes * Update TwoStepRegion * Revert change to LinodeSelect.tsx * Use 'distributed-ALL' as default in TwoStepRegion.tsx * Add PowerActionsDialogOrDrawer.tsx * Added changeset: Replace undefined with null in Autocomplete values to fix MUI errors * Update MaintenancePolicySelect type --------- Co-authored-by: dmcintyr-akamai * upcoming: [DPS-34041] Add actions in Destinations list (#12749) * upcoming: [DPS-34041] Add actions in Destinations list * upcoming: [DPS-34041] CR changes 1 --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Change: [UIE-9059] - Volumes RBAC permissions (#12744) * adapters + volumes landing * Action menu * remaining instances * fix units * oops the mapping! * cleanup * remove wrong check * changesets * feedback @bnussman-akamai * first round of feedback * feedback 2 * missing grant hooks * tech-story: [M3-10364] - MSW CRUD: Add custom grants to profile preset to prevent type error when using restricted profile preset (#12756) * where are we going with this * something like this?? p1 * add back in service tool worker changes * combine profile and grants preset * Added changeset: MSW CRUD: Fix type error when using custom restricted profile preset and add grants preset support * rename things * more renaming * missed some renaming * fix: [M3-9469] - Reset selected region when switching between Core and Distributed tabs (#12767) ## Description ๐Ÿ“ The Disk Encryption section maintains state from previously selected regions when users switch between Core and Distributed region tabs, showing inconsistent copy, tooltips, and checkbox states until a new region is selected from the current tab. This is because Disk Encryption state is tied to selected region rather than active region tab. When the tab changes without region selection, the component doesn't reset. > [!NOTE] > I tried adding unit tests for this but could not figure out why the tests were failing but the UI is working as expected. Joe is also stumped. ## Changes ๐Ÿ”„ - Clear the region when the region tab changes * feat: [M3-10378] - Show Node Pool firewalls in Node Pool footers (#12779) * Add firewall to node pool footer * Add id and make it copyable * Add missing conditional for enterprise; update unit tests * Do not show 0 firewall; standardize copy tooltip behavior * Only show copyable id if label is also shown; otherwise link id * Added changeset: Linked Node Pool firewall in Node Pool footer for LKE-E clusters * Fix copy tooltip - don't include ID: * Fix test failure * test: [M3-9871] - Fix Linode resize test failures (#12727) * M3-9871 Fix Linode resize test failures * Added changeset: Fix Linode resize test failures * feat: [M3-10039M, M3-10382] - Configure Kubernetes Node Pool Drawer (#12710) * setup initial drawer and form components * add more fields to the new drawer * handle nodepool label properly throughout LKE * add feature flag and cluster tier logic * begin unit testing * add an e2e-like unit test * support tags * fix typo in the unit test comment * fix up ResizeNodePoolDrawer unit test to account for node pool label change in drawer title * fix unit test after adding tag support * change tags select label to just be `Tags` * ensure error is surfaced * keep the word `Plan` in drawer headers * update unit test to account for re-adding the `Plan` suffix to drawer titles * update version select logic to only include valid versions * improve unit test * use v4beta PUT endpoint so new fields actually work * Added changeset: Configure Node Pool Drawer to Kubernetes Cluster details page * Added changeset: Support for Node Pool `label` field * Added changeset: Use `v4beta` API endpoint for `updateNodePool` * fix spelling in comment * hide label & tag fields becuasse they lack PDT commitment * finish up comments * one more comment * Apply suggestions from code review Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * change changeset to be an upcoming feature * use shared `NodePoolConfigOptions` component @mjac0bs * work on error surfacing * restore inline validation, hopefully it works * `firewall_id` validation is working, but could use some clean up * undo unnessesary validation change * Revert "undo unnessesary validation change" This reverts commit baa45ee6c5cf21ee91465fd64a5c8f5d795032be. * fix double save issue * clean up * fix form label issues in theme * don't allow user to select default firewall once one has been set * improve validation message * fix label html issue * unit test `getNodePoolVersionOptions` * lint * address feedback regarding `useNodePoolDisplayLabel` * make node pool firewall_id bahavior more forwards compatible with upcoming api changes * add comments --------- Co-authored-by: Banks Nussman Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * upcoming: [M3-10523] - Fix and extend ACLP-supported region Linode mock examples (#12747) * Correct ACLP-supported region Linode mocks * Add example 3 in the mocks * Add comments * Added changeset: Fix and extend ACLP-supported region Linode mock examples * feat: [UIE-9141] - IAM RBAC: add perm check for nodebalancer landing (#12780) * feat: [UIE-9141] - IAM RBAC: add perm check for nodebalancer * Added changeset: IAM RBAC: This PR implements IAM RBAC permissions for NodeBalancer * fix test and add changeset * upcoming: [M3-10532] - Disable legacy interface selection in Linode Interface UI when creating Linode from backups (#12772) * disable legacy interfaces when creat ing from backup * tooltip for vpc legacy flow * minor updates * something like this? * changeset * whoops didn't save + add test * tooltip when disabled * update tooltip * no selection when disabled * add test case * this feels really weird ngl * address feedback * this seems more legit * remove stray console * [UIE-9148] - IAM / RBAC - Support permission segmentation for LA (#12764) * Do the deed * hook logic * fix and improve test * fix MaintenancePolicy permissions * improve hook logic based on feedback * feedback @hana-akamai * revert API changes * changesets * tests + improved & cleaned up logic * test: [M3-10568] - Fix failing `linode-storage.spec.ts` delete test following API release (#12794) * Fix failing test following API release, fix test docs and improve test name * Added changeset: Fix disk deletion test following API release changes * new: [STORIF-80] Volume summary page created. (#12757) * new: [STORIF-80] Volume summary page created. * Added changeset: Volume details page * Update packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> --------- Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> * [DI-26876] - Add missing props and enhance utils for firewalls contextual view (#12760) * [DI-26876] - Add missing props and enahnce utils for firewalls contextual view * [DI-26876] - revert * [DI-26876] - dont send undefined in metrics call in contextual view for optional filters * [DI-26876] - keep checks at the final processing fn * [DI-26876] - Pr comment * [DI-26876] - Add changeset * [DI-26876] - remove temporary changes --------- Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> * upcoming: [DI-26469] - TextField character limit validations in Alerts and Metrics (#12771) * fix: [DI-26469] - TextField 100 character limit validations in Alerts and Metrics * add changeset --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> * upcoming: [DI-26718] - change aggregation function label (#12787) * upcoming: [DI-26718] - change aggregation function label * add changeset * fix: [M3-10570] - Profile preferences across sessions (#12795) * update useMutatePreferences * Added changeset: Profile preferences across sessions * feedback * fix more section * refactor: [M3-10574] - Update jspdf (#12797) * Update jspdf * Added changeset: Updated jspdf to 3.0.2 * upcoming: [M3-10379] - Allow Firewall to be configured in the Add Node Pool Drawer (#12793) * initial work to add more config options to add node pool drawer * save changes * remove unnessesary changes * Added changeset: Add Firewall option to the Add Node Pool Drawer for LKE Enterprise Kubernetes Clusters * Added changeset: General Node Pool schema `nodePoolSchema` * Added changeset: Node Pool schemas `CreateNodePoolSchema` and `EditNodePoolSchema` * Added changeset: Update `CreateNodePoolData` to satisfy @linode/validation's `CreateNodePoolSchema`'s type * add some basic unit testing * organize unit tests --------- Co-authored-by: Banks Nussman * change: [M3-10395] - Pendo Tag selector support: Create Linode > Linode Plan tabs (#12806) * Add tab title to data-pendo-id * Added changeset: Add data-pendo-id attribute to TabbedPanel for Linode Plan tab tracking * upcoming: [M3-10529] - UX feedback: Change /settings to /account-settings and profile/settings to profile/preferences (#12785) * Change settings route to account-settings * Change rounte 'profile/settings' to 'profile/preferences' * Update PrimaryNav.test.tsx * Update Cypress tests to account for account settings route change * Update packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Remove redundant document titles * PR feedback - @coliu-akamai * Added changeset: UX feedback: Change /settings to /account-settings and profile/settings to profile/preferences --------- Co-authored-by: Joe D'Amore Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * tests [M3-10513] Fix for flakey test in alerts-listing-page.spec.ts (#12736) * limit cypress scope to fix test * Added changeset: Fix for flakey test in alerts-listing-page.spec.ts * test [M3 9641]: Tests for Host & VM Maintenance Linode details page changes (#12786) * initial commit * Added changeset: Tests for Host & VM Maintenance in Linode create page * remove deprecated tooltip * initial commit * failure test case * Added changeset: Tests for Host & VM Maintenance Linode details page changes * Delete packages/manager/.changeset/pr-12734-tests-1755697767741.md duplicate changeset file * file from wrong pr * refactoring after review * more edits after review * fix: [M3-10567] - The Make a Payment and Add Payment drawers does not close when the browser back button is clicked (#12796) * Fix: Close Make a payment and Add payment drawers when browser back button is clicked * Added changeset: The Make a Payment and Add Payment drawers does not close when the browser back button is clicked * Update packages/manager/.changeset/pr-12796-fixed-1756845120661.md Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> * PR feedback - Billing related test failures --------- Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> * feat: [M3-9853] - Update Linode Config and Rescue Dialog to support new volume count limit (#12791) * being updating types * confirm values sent to backend (sdi) * add some more slots for testing clean this all up fr later * move stuff to constants file * match api limit on our end oops * allow extra devices to be omitted from payload * changeset * switch to type map instead * fix failing test * rescue updates part 1 * should try to remove cast * optionality feedback @bnussman-akamai * Update packages/utilities/src/helpers/createDevicesFromStrings.ts Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> * address feedback simple warn @jaalah-akamai --------- Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> * fix: [M3-10573] - Improve node pool footer layout, fixing overflow due to added Firewall entity (#12798) * Fix overflow and remove extra margin on divider * Move TagCell underneath the footer text * Added changeset: Node Pool footer layout * Address feedback: apply @bnussman-akamai solution to footer overflow * Remove comment * feat: [UIE-9142] - IAM RBAC: perm check nodebalancer summary tab (#12790) * feat: [UIE-9142] - IAM RBAC: perm check nodebalancer summary tab * Added changeset: IAM RBAC: Implements IAM RBAC permissions for NodeBalancer summary tab * feat: [UIE-9150] - IAM RBAC: add a notification banner for Account Settings if user doesn't have permission (#12774) * feat: [UIE-9150] - IAM RBAC: add a message if lack of permissions * Added changeset: Add a notification banner for Account Settings if user doesn't have permission to see * fix e2e tests * fix: [M3-10400] - Improve disabled list option tooltip behavior in selects (#12777) ## Description ๐Ÿ“ Fix an edge-case where disabled list option tooltips do not disappear when the option that triggered the tooltip is no longer in view ## Changes ๐Ÿ”„ - Add an intersection observer so we only show the tooltip if the option is focused _and_ in view ## How to test ๐Ÿงช ### Prerequisites (How to setup test environment) - Go to a page with a region select such as the Linode Create page - Turn on the legacy MSW to mock a disabled region option ### Reproduction steps (How to reproduce the issue, if applicable) - [ ] On another branch or develop, hover over a disabled region option and scroll the container so the option is out of view - [ ] The tooltip still displays ### Verification steps (How to verify changes) - [ ] Checkout this PR or PR preview link - [ ] Hover over a disabled region option and scroll the container so the option is out of view - [ ] The tooltip should not display when out of view * upcoming: [DI-26883] - Added scope selection dropdown in create and edit alert definition form (#12745) * upcoming: [DI-26883] - Added scope selection in create & edit alert form * upcoming: [DI-26883] - Added regions in edit & create alert type interface * upcoming: [DI-26883] - Added constants messages * upcoming: [DI-26883] - Added scope & regions in alert details page * upcoming: [DI-26883] - Added disabled scope dropdown in edit alert definition page * upcoming: [DI-26883] - Updated region filter logic * upcoming: [DI-26883] - Updated AlertsResources component to have optional default value * upcoming: [DI-26883] - Added regions alert definition utils * upcoming: [DI-26883] - Updated alert definition schema to include scope and regions * upcoming: [DI-26883] - Added disabled property to entity scope select component * upcoming: [DI-26883] - Updated test cases * upcoming: [DI-26883] - Updated mock data * upcoming: [DI-26883] - Added scope content renderer for alert details * Added changeset * upcoming: [DI-26883] - Updated logic to scroll to top if page changed * fixed typecheck issue * [DI-26876] - Select default region in firewalls contextual view (#12805) * [DI-26876] - Select default region in firewalls contextual view * [DI-26876] - Save preferences in centralized view for linode region * [DI-26876] - Add changeset * [DI-26876] - Resolve comments * [DI-26876] - Remove temporary changes done for review/testing * tests [M3-9638]: Tests for Host & VM Maintenance in Linode create page (#12734) * initial commit * Added changeset: Tests for Host & VM Maintenance in Linode create page * remove deprecated tooltip * refactoring after review * refactoring after review * refactor: [M3-10584] - Remove unused LKE-E related code from LinodeConfigDialog (#12812) * cleanup * Added changeset: Unused LKE-E related code from `LinodeConfigDialog` * Cloud version v1.150.0, API v4 version v0.148.0, Validation version v0.74.0, UI version v0.20.0, Utilities version v0.8.0, Queries version v0.13.0, Shared version v0.8.0 * Update package version * Update changelog * Update packages/manager/CHANGELOG.md Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> * upcoming: [DI-26678] - Alert Firewall dimension filters customization (#12817) * upcoming: [DI-26678] - Firewall dimension filters customization for Alerts * upcoming: [DI-26678] - Mocks for testing * add changeset * upcoming: [DI-26678] - Fix typo * upcoming: [DI-26678] - Tidying up the RenderAlertsMetricAndDimensions component * upcoming: [DI-26678] - updated serverHandler for mock testing * upcoming: [DI-26678] - remove commented code in alerts factory, mock more linodes with ap-west region * upcoming: [DI-26678] - destructuring dimension object in isCheckRequired util * upcoming: [DI-26678] - removing unnecessary prop drilling by importing from constants * remove unused import * upcoming: [DI-26678] - fixing the default error message bug * upcoming: [DI-26678] - using a prop interface for the resolvedDimensionValue util * removing changelog --------- Co-authored-by: Ankita Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Co-authored-by: Jaalah Ramos Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Co-authored-by: mduda-akamai Co-authored-by: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Co-authored-by: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: bill-akamai Co-authored-by: aaleksee-akamai Co-authored-by: dmcintyr-akamai Co-authored-by: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman Co-authored-by: Purvesh Makode Co-authored-by: Dmytro Chyrva Co-authored-by: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Co-authored-by: John Callahan <114753608+jcallahan-akamai@users.noreply.github.com> Co-authored-by: Joe D'Amore --- packages/api-v4/CHANGELOG.md | 37 +- packages/api-v4/package.json | 4 +- packages/api-v4/src/account/types.ts | 4 + packages/api-v4/src/cloudpulse/types.ts | 3 +- .../api-v4/src/datastream/destinations.ts | 41 ++- packages/api-v4/src/datastream/streams.ts | 28 +- packages/api-v4/src/datastream/types.ts | 21 +- packages/api-v4/src/iam/types.ts | 106 +++--- packages/api-v4/src/kubernetes/nodePools.ts | 8 +- packages/api-v4/src/kubernetes/types.ts | 22 +- packages/api-v4/src/linodes/types.ts | 79 +++- packages/manager/CHANGELOG.md | 75 ++++ .../components/region-select.spec.tsx | 70 +++- .../core/account/account-cancellation.spec.ts | 44 +-- .../account/account-linode-managed.spec.ts | 29 +- .../restricted-user-details-pages.spec.ts | 2 +- .../cloudpulse/alerts-listing-page.spec.ts | 31 +- .../core/cloudpulse/create-user-alert.spec.ts | 6 +- .../dbaas-widgets-verification.spec.ts | 1 + .../core/cloudpulse/edit-user-alert.spec.ts | 8 +- .../linode-widget-verification.spec.ts | 1 + .../nodebalancer-widget-verification.spec.ts | 1 + .../core/images/machine-image-upload.spec.ts | 11 +- .../e2e/core/kubernetes/lke-create.spec.ts | 26 +- .../kubernetes/lke-enterprise-create.spec.ts | 343 +++++++++++++----- .../kubernetes/smoke-lke-enterprise.spec.ts | 279 ++++++++++++++ ...c.ts => smoke-lke-standard-create.spec.ts} | 60 +-- .../e2e/core/linodes/alerts-edit.spec.ts | 79 +++- .../create-linode-vm-host-maintenance.spec.ts | 144 ++++++++ ...inode-settings-vm-host-maintenance.spec.ts | 188 ++++++++++ .../e2e/core/linodes/linode-storage.spec.ts | 19 +- .../e2e/core/linodes/resize-linode.spec.ts | 9 +- .../smoke-linode-landing-table.spec.ts | 2 +- .../cypress/support/constants/alert.ts | 6 +- .../manager/cypress/support/constants/lke.ts | 4 + .../cypress/support/intercepts/linodes.ts | 45 ++- .../cypress/support/plugins/junit-report.ts | 5 + .../manager/cypress/support/ui/constants.ts | 2 +- .../manager/cypress/support/util/linodes.ts | 2 +- packages/manager/cypress/support/util/lke.ts | 46 +++ packages/manager/package.json | 6 +- .../MaintenancePolicySelect.tsx | 2 +- .../MaskableText/MaskableTextArea.tsx | 14 +- .../components/PrimaryNav/PrimaryNav.test.tsx | 7 +- .../src/components/PrimaryNav/PrimaryNav.tsx | 24 +- .../components/RegionSelect/RegionSelect.tsx | 2 +- .../RegionSelect/RegionSelect.types.ts | 2 +- .../components/TabbedPanel/TabbedPanel.tsx | 1 + .../src/components/TagCell.stories.tsx | 2 + .../src/components/TagCell/TagCell.test.tsx | 10 +- .../src/components/TagCell/TagCell.tsx | 17 +- .../TypeToConfirm/TypeToConfirm.tsx | 15 +- .../TypeToConfirmDialog.tsx | 7 +- .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + .../src/dev-tools/ServiceWorkerTool.tsx | 25 +- .../components/ExtraPresetOptions.tsx | 15 +- ...le.tsx => ExtraPresetProfileAndGrants.tsx} | 256 ++++++++++--- packages/manager/src/dev-tools/constants.ts | 2 + packages/manager/src/dev-tools/utils.ts | 22 ++ .../src/factories/cloudpulse/alerts.ts | 70 ++++ .../src/factories/kubernetesCluster.ts | 1 + packages/manager/src/featureFlags.ts | 1 + .../src/features/Account/GlobalSettings.tsx | 25 +- .../AccountSettingsLanding.tsx} | 6 +- .../accountSettingsLandingLazyRoute.ts | 9 + .../src/features/Billing/BillingDetail.tsx | 2 - .../BillingSummary/BillingSummary.tsx | 3 + .../PaymentInfoPanel/PaymentInformation.tsx | 2 + .../Alerts/AlertRegions/AlertRegions.tsx | 24 +- .../AlertRegions/DisplayAlertRegions.test.tsx | 3 + .../AlertRegions/DisplayAlertRegions.tsx | 29 +- .../Alerts/AlertsDetail/AlertDetail.test.tsx | 15 +- .../Alerts/AlertsDetail/AlertDetail.tsx | 28 +- .../AlertsDetail/AlertDetailCriteria.test.tsx | 2 +- .../AlertsDetail/AlertDetailOverview.tsx | 8 +- .../RenderAlertsMetricsAndDimensions.tsx | 69 +++- .../AlertsDetail/ScopeContentRenderer.tsx | 81 +++++ .../Alerts/AlertsDetail/constants.ts | 10 + .../Alerts/AlertsDetail/utils.test.ts | 104 +++++- .../CloudPulse/Alerts/AlertsDetail/utils.ts | 117 +++++- .../Alerts/AlertsListing/AlertListTable.tsx | 1 - .../AlertsResources/AlertsResources.tsx | 7 +- .../AlertsResources/DisplayAlertResources.tsx | 2 +- .../AlertInformationActionTable.tsx | 41 ++- .../ContextualView/AlertReusableComponent.tsx | 6 +- .../CreateAlertDefinition.test.tsx | 43 +-- .../CreateAlert/CreateAlertDefinition.tsx | 15 +- .../DimensionFilterAutocomplete.test.tsx | 31 ++ .../DimensionFilterAutocomplete.tsx | 20 +- .../ValueFieldRenderer.tsx | 28 +- .../DimensionFilterValue/ValueSchemas.ts | 116 ++++-- .../DimensionFilterValue/constants.ts | 115 +++++- .../DimensionFilterValue/useFetchOptions.ts | 110 ++++-- .../DimensionFilterValue/utils.test.ts | 13 +- .../Criteria/DimensionFilterValue/utils.ts | 23 +- .../CreateAlert/Criteria/Metric.test.tsx | 10 +- .../CreateAlert/EntityScopeRenderer.tsx | 70 ++++ .../AlertEntityScopeSelect.tsx | 14 +- .../ResourceMultiSelect.test.tsx | 2 +- .../ResourceMultiSelect.tsx | 6 +- .../Regions/CloudPulseModifyAlertRegions.tsx | 7 +- .../CloudPulse/Alerts/CreateAlert/schemas.ts | 9 +- .../CloudPulse/Alerts/CreateAlert/types.ts | 2 +- .../Alerts/CreateAlert/utilities.ts | 9 +- .../EditAlert/EditAlertDefinition.test.tsx | 1 + .../Alerts/EditAlert/EditAlertDefinition.tsx | 22 +- .../CloudPulse/Alerts/Utils/utils.test.ts | 4 +- .../features/CloudPulse/Alerts/Utils/utils.ts | 6 +- .../features/CloudPulse/Alerts/constants.ts | 30 +- .../CloudPulseDashboardWithFilters.tsx | 6 + .../Utils/CloudPulseWidgetUtils.test.ts | 3 + .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 88 +++-- .../CloudPulse/Utils/FilterBuilder.ts | 1 + .../Utils/ReusableDashboardFilterUtils.ts | 2 + .../features/CloudPulse/Utils/constants.ts | 10 +- .../features/CloudPulse/Utils/utils.test.ts | 30 +- .../src/features/CloudPulse/Utils/utils.ts | 29 +- .../CloudPulse/Widget/CloudPulseWidget.tsx | 1 + .../CloudPulseDashboardFilterBuilder.tsx | 6 +- .../shared/CloudPulseRegionSelect.test.tsx | 180 ++++++++- .../shared/CloudPulseRegionSelect.tsx | 98 +++-- .../CloudPulse/shared/DimensionTransform.ts | 2 + .../src/features/CloudPulse/shared/types.ts | 6 +- .../features/DataStream/DataStreamLanding.tsx | 7 +- .../DestinationActionMenu.test.tsx | 29 ++ .../Destinations/DestinationActionMenu.tsx | 40 ++ .../DestinationCreate/DestinationCreate.tsx | 131 ------- .../DestinationCreate.test.tsx | 0 .../DestinationForm/DestinationCreate.tsx | 62 ++++ .../DestinationForm/DestinationEdit.test.tsx | 63 ++++ .../DestinationForm/DestinationEdit.tsx | 124 +++++++ .../DestinationForm/DestinationForm.tsx | 101 ++++++ .../destinationCreateLazyRoute.ts | 2 +- .../destinationEditLazyRoute.ts | 9 + .../Destinations/DestinationTableRow.tsx | 13 +- .../Destinations/DestinationsLanding.test.tsx | 143 ++++++-- .../Destinations/DestinationsLanding.tsx | 45 ++- .../LabelValue.tsx} | 33 +- .../src/features/DataStream/Shared/types.ts | 31 +- .../Streams/StreamActionMenu.test.tsx | 52 +++ .../DataStream/Streams/StreamActionMenu.tsx | 46 +++ .../StreamCreateSubmitBar.test.tsx | 38 -- ...ationLinodeObjectStorageDetailsSummary.tsx | 26 -- .../Streams/StreamCreate/StreamCreate.tsx | 120 ------ .../StreamCreateGeneralInfo.test.tsx | 54 --- .../DataStream/Streams/StreamCreate/types.ts | 12 - .../StreamFormCheckoutBar.styles.ts} | 0 .../StreamFormCheckoutBar.test.tsx} | 12 +- .../CheckoutBar/StreamFormCheckoutBar.tsx} | 8 +- .../CheckoutBar/StreamFormSubmitBar.test.tsx | 60 +++ .../CheckoutBar/StreamFormSubmitBar.tsx} | 25 +- ...LinodeObjectStorageDetailsSummary.test.tsx | 29 +- ...ationLinodeObjectStorageDetailsSummary.tsx | 34 ++ .../Delivery/StreamFormDelivery.test.tsx} | 10 +- .../Delivery/StreamFormDelivery.tsx} | 25 +- .../Streams/StreamForm/StreamCreate.tsx | 86 +++++ .../Streams/StreamForm/StreamEdit.test.tsx | 84 +++++ .../Streams/StreamForm/StreamEdit.tsx | 131 +++++++ .../Streams/StreamForm/StreamForm.tsx | 52 +++ .../StreamFormClusters.test.tsx} | 14 +- .../StreamFormClusters.tsx} | 33 +- .../StreamFormClustersData.ts} | 2 +- .../StreamForm/StreamFormGeneralInfo.test.tsx | 101 ++++++ .../StreamFormGeneralInfo.tsx} | 35 +- .../streamCreateLazyRoute.ts | 2 +- .../Streams/StreamForm/streamEditLazyRoute.ts | 9 + .../DataStream/Streams/StreamForm/types.ts | 12 + .../Streams/StreamTableRow.test.tsx | 54 +++ .../DataStream/Streams/StreamTableRow.tsx | 25 +- .../Streams/StreamsLanding.test.tsx | 202 +++++++++-- .../DataStream/Streams/StreamsLanding.tsx | 97 ++++- .../features/DataStream/dataStreamUtils.ts | 40 +- .../DatabaseBackups/DatabaseBackups.tsx | 4 +- .../Domains/DomainDetail/DomainDetail.tsx | 1 + .../EntityTransfersLanding.tsx | 2 - .../EntityTransfers/RenderTransferRow.tsx | 5 +- .../TransfersPendingActionMenu.test.tsx | 8 +- .../TransfersPendingActionMenu.tsx | 4 +- .../EntityTransfers/TransfersTable.tsx | 9 +- .../features/Events/factories/datastream.tsx | 32 ++ .../manager/src/features/IAM/IAMLanding.tsx | 4 +- .../src/features/IAM/Shared/constants.ts | 9 + .../src/features/IAM/Shared/utilities.ts | 2 +- .../features/IAM/Users/UserDetailsLanding.tsx | 12 +- .../IAM/Users/UserRoles/AssignedEntities.tsx | 47 ++- .../adapters/accountGrantsToPermissions.ts | 4 + .../nodeBalancerGrantsToPermissions.ts | 31 ++ .../IAM/hooks/adapters/permissionAdapters.ts | 66 +++- .../adapters/volumeGrantsToPermissions.ts | 18 + .../features/IAM/hooks/usePermissions.test.ts | 90 ++++- .../src/features/IAM/hooks/usePermissions.ts | 73 +++- .../ImagesLanding/DeleteImageDialog.tsx | 59 +++ .../Images/ImagesLanding/ImageRow.test.tsx | 21 ++ .../Images/ImagesLanding/ImageRow.tsx | 1 + .../Images/ImagesLanding/ImagesLanding.tsx | 132 +------ .../ClusterNetworkingPanel.test.tsx | 8 +- .../CreateCluster/ClusterNetworkingPanel.tsx | 5 +- .../CreateCluster/CreateCluster.tsx | 2 +- .../CreateCluster/HAControlPlane.tsx | 1 + .../KubeEntityDetailFooter.tsx | 10 +- .../KubernetesClusterDetail.tsx | 1 + .../AddNodePoolDrawer.test.tsx | 152 +++++++- .../NodePoolsDisplay/AddNodePoolDrawer.tsx | 217 +++++------ .../AutoscaleNodePoolDrawer.tsx | 12 +- .../ConfigureNodePoolDrawer.tsx | 40 ++ .../ConfigureNodePoolDrawer.utils.ts | 24 ++ .../ConfigureNodePoolForm.test.tsx | 224 ++++++++++++ .../ConfigureNodePoolForm.tsx | 118 ++++++ .../LabelsAndTaints/LabelAndTaintDrawer.tsx | 13 +- .../NodePoolsDisplay/NodePool.tsx | 31 +- .../NodePoolsDisplay/NodePoolFooter.test.tsx | 66 +++- .../NodePoolsDisplay/NodePoolFooter.tsx | 34 +- .../NodePoolsDisplay.test.tsx | 1 + .../NodePoolsDisplay/NodePoolsDisplay.tsx | 40 +- .../NodePoolsDisplay/NodeRow.tsx | 14 +- .../NodePoolsDisplay/NodeTable.styles.ts | 1 + .../NodePoolsDisplay/NodeTable.test.tsx | 2 +- .../NodePoolsDisplay/NodeTable.tsx | 6 +- .../ResizeNodePoolDrawer.test.tsx | 45 ++- .../NodePoolsDisplay/ResizeNodePoolDrawer.tsx | 18 +- .../NodePoolsDisplay/utils.test.ts | 75 +++- .../NodePoolsDisplay/utils.ts | 68 +++- .../KubernetesPlanSelectionTable.tsx | 5 +- .../NodePoolConfigDrawer.tsx | 3 +- .../NodePoolConfigOptions.tsx | 103 +++++- .../Kubernetes/NodePoolFirewallSelect.tsx | 171 ++++++--- .../NodePoolUpdateStrategySelect.tsx | 8 +- .../AdditionalOptions/MaintenancePolicy.tsx | 2 +- .../LinodeCreate/Networking/InterfaceType.tsx | 39 +- .../features/Linodes/LinodeCreate/Region.tsx | 19 +- .../Tabs/utils/useGetLinodeCreateType.ts | 4 + .../Linodes/LinodeCreate/TwoStepRegion.tsx | 30 +- .../features/Linodes/LinodeCreate/VPC/VPC.tsx | 15 +- .../features/Linodes/LinodeCreate/index.tsx | 9 +- .../Linodes/LinodeCreate/utilities.test.tsx | 32 ++ .../Linodes/LinodeCreate/utilities.ts | 8 +- .../Linodes/LinodeEntityDetailFooter.tsx | 1 + .../LinodeConfigs/LinodeConfigDialog.test.tsx | 4 - .../LinodeConfigs/LinodeConfigDialog.tsx | 60 +-- .../LinodeConfigs/LinodeConfigs.test.tsx | 2 +- .../LinodesDetail/LinodeConfigs/constants.ts | 135 +++++++ .../LinodeRescue/DeviceSelection.tsx | 4 +- .../LinodeRescue/StandardRescueDialog.tsx | 50 ++- .../LinodeStorage/LinodeVolumes.test.tsx | 10 + .../LinodeStorage/LinodeVolumes.tsx | 20 +- .../VolumesUpgradeBanner.test.tsx | 18 + .../Linodes/LinodesLanding/LinodesLanding.tsx | 7 +- .../Linodes/MigrateLinode/ConfigureForm.tsx | 2 +- .../Linodes/PowerActionsDialogOrDrawer.tsx | 7 +- .../LoginHistory/LoginHistoryLanding.tsx | 2 - .../Maintenance/MaintenanceLanding.tsx | 2 - .../NodeBalancerDetail/NodeBalancerDetail.tsx | 15 +- .../NodeBalancerSummary/SummaryPanel.test.tsx | 36 ++ .../NodeBalancerSummary/SummaryPanel.tsx | 15 +- .../NodeBalancerActionMenu.test.tsx | 43 +++ .../NodeBalancerActionMenu.tsx | 16 +- .../NodeBalancerTableRow.test.tsx | 14 + .../NodeBalancersLanding.test.tsx | 46 ++- .../NodeBalancersLanding.tsx | 11 +- .../NodeBalancersLandingEmptyState.test.tsx | 50 ++- .../NodeBalancersLandingEmptyState.tsx | 11 +- .../AccessKeyLanding/AccessKeyLanding.tsx | 21 ++ .../BucketLanding/BucketRegions.tsx | 2 +- .../BucketLanding/ClusterSelect.tsx | 2 +- .../PlacementGroupsCreateDrawer.test.tsx | 35 -- .../OAuthClients/OAuthClientActionMenu.tsx | 7 +- .../manager/src/features/Profile/Profile.tsx | 12 +- .../features/Profile/Settings/Settings.tsx | 16 +- .../Profile/Settings/settingsLazyRoute.ts | 8 + .../src/features/Quotas/QuotasLanding.tsx | 2 - .../Settings/settingsLandingLazyRoute.ts | 7 - .../TopMenu/UserMenu/UserMenuPopover.tsx | 78 ++-- .../UsersAndGrants/UsersAndGrants.tsx | 2 - .../VPCTopSectionContent.test.tsx | 10 +- .../FormComponents/VPCTopSectionContent.tsx | 4 +- .../Volumes/Drawers/AttachVolumeDrawer.tsx | 24 +- .../Volumes/Drawers/CloneVolumeDrawer.tsx | 32 +- .../Volumes/Drawers/EditVolumeDrawer.tsx | 21 +- .../Volumes/Drawers/ManageTagsDrawer.tsx | 21 +- .../Volumes/Drawers/ResizeVolumeDrawer.tsx | 26 +- .../VolumeDrawer/LinodeVolumeAttachForm.tsx | 31 +- .../Drawers/VolumeDrawer/ModeSelection.tsx | 5 + .../src/features/Volumes/VolumeCreate.tsx | 26 +- .../Volumes/VolumeDetails/VolumeDetails.tsx | 64 ++++ .../VolumeEntityDetail.tsx | 23 ++ .../VolumeEntityDetailBody.tsx | 147 ++++++++ .../VolumeEntityDetailFooter.tsx | 28 ++ .../VolumeEntityDetailHeader.tsx | 29 ++ .../VolumeDetails/volumeLandingLazyRoute.ts | 7 + .../features/Volumes/VolumeTableRow.test.tsx | 23 ++ .../src/features/Volumes/VolumeTableRow.tsx | 49 ++- .../Volumes/VolumesActionMenu.test.tsx | 23 ++ .../features/Volumes/VolumesActionMenu.tsx | 50 ++- .../src/features/Volumes/VolumesLanding.tsx | 11 +- .../Volumes/VolumesLandingEmptyState.tsx | 9 +- .../src/mocks/presets/crud/datastream.ts | 15 +- .../mocks/presets/crud/handlers/datastream.ts | 168 +++++++++ .../presets/extra/account/customProfile.ts | 33 -- .../extra/account/customProfileAndGrants.ts | 50 +++ packages/manager/src/mocks/presets/index.ts | 4 +- packages/manager/src/mocks/serverHandlers.ts | 185 +++++++++- packages/manager/src/mocks/types.ts | 6 +- .../manager/src/queries/cloudpulse/alerts.ts | 36 +- packages/manager/src/routes/account/index.ts | 2 +- .../AccountSettingsRoute.tsx} | 2 +- .../src/routes/accountSettings/index.ts | 42 +++ .../manager/src/routes/datastream/index.ts | 45 ++- packages/manager/src/routes/index.tsx | 4 +- packages/manager/src/routes/profile/index.ts | 34 +- packages/manager/src/routes/settings/index.ts | 42 --- packages/manager/src/routes/volumes/index.ts | 19 + packages/manager/src/testSetup.ts | 7 + .../src/utilities/pricing/kubernetes.ts | 10 +- packages/queries/CHANGELOG.md | 7 + packages/queries/package.json | 4 +- .../queries/src/datastreams/datastream.ts | 108 +++++- packages/queries/src/images/images.ts | 13 +- packages/shared/CHANGELOG.md | 9 +- packages/shared/package.json | 4 +- packages/shared/testSetup.ts | 7 + packages/ui/CHANGELOG.md | 15 +- packages/ui/package.json | 4 +- .../ListItemOption/ListItemOption.tsx | 29 +- packages/ui/src/foundations/themes/dark.ts | 2 +- packages/ui/src/foundations/themes/light.ts | 2 +- packages/utilities/CHANGELOG.md | 10 +- packages/utilities/package.json | 4 +- .../utilities/src/__data__/regionsData.ts | 2 +- .../helpers/createDevicesFromStrings.test.ts | 30 +- .../src/helpers/createDevicesFromStrings.ts | 89 ++++- packages/validation/CHANGELOG.md | 11 + packages/validation/package.json | 4 +- packages/validation/src/datastream.schema.ts | 39 +- packages/validation/src/kubernetes.schema.ts | 98 +++-- packages/validation/src/linodes.schema.ts | 60 ++- pnpm-lock.yaml | 61 ++-- scripts/junit-summary/util/index.ts | 30 +- 337 files changed, 9117 insertions(+), 2493 deletions(-) create mode 100644 packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts rename packages/manager/cypress/e2e/core/kubernetes/{smoke-lke-create.spec.ts => smoke-lke-standard-create.spec.ts} (76%) create mode 100644 packages/manager/cypress/e2e/core/linodes/create-linode-vm-host-maintenance.spec.ts create mode 100644 packages/manager/cypress/e2e/core/linodes/linode-settings-vm-host-maintenance.spec.ts rename packages/manager/src/dev-tools/components/{ExtraPresetProfile.tsx => ExtraPresetProfileAndGrants.tsx} (50%) rename packages/manager/src/features/{Settings/SettingsLanding.tsx => AccountSettings/AccountSettingsLanding.tsx} (89%) create mode 100644 packages/manager/src/features/AccountSettings/accountSettingsLandingLazyRoute.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/ScopeContentRenderer.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/constants.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/EntityScopeRenderer.tsx create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.test.tsx create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.tsx delete mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx rename packages/manager/src/features/DataStream/Destinations/{DestinationCreate => DestinationForm}/DestinationCreate.test.tsx (100%) create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx rename packages/manager/src/features/DataStream/Destinations/{DestinationCreate => DestinationForm}/destinationCreateLazyRoute.ts (84%) create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts rename packages/manager/src/features/DataStream/{Streams/StreamCreate/Delivery/DestinationDetail.tsx => Shared/LabelValue.tsx} (52%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts => StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts} (100%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx => StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx} (85%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx => StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx} (86%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx => StreamForm/CheckoutBar/StreamFormSubmitBar.tsx} (62%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate => StreamForm}/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx (78%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/Delivery/StreamCreateDelivery.test.tsx => StreamForm/Delivery/StreamFormDelivery.test.tsx} (96%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/Delivery/StreamCreateDelivery.tsx => StreamForm/Delivery/StreamFormDelivery.tsx} (85%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateClusters.test.tsx => StreamForm/StreamFormClusters.test.tsx} (96%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateClusters.tsx => StreamForm/StreamFormClusters.tsx} (90%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateClustersData.ts => StreamForm/StreamFormClustersData.ts} (92%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateGeneralInfo.tsx => StreamForm/StreamFormGeneralInfo.tsx} (65%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate => StreamForm}/streamCreateLazyRoute.ts (90%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/types.ts create mode 100644 packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx create mode 100644 packages/manager/src/features/IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts create mode 100644 packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts create mode 100644 packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx create mode 100644 packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.tsx create mode 100644 packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.utils.ts create mode 100644 packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.test.tsx create mode 100644 packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts delete mode 100644 packages/manager/src/features/Settings/settingsLandingLazyRoute.ts create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts delete mode 100644 packages/manager/src/mocks/presets/extra/account/customProfile.ts create mode 100644 packages/manager/src/mocks/presets/extra/account/customProfileAndGrants.ts rename packages/manager/src/routes/{settings/SettingsRoute.tsx => accountSettings/AccountSettingsRoute.tsx} (90%) create mode 100644 packages/manager/src/routes/accountSettings/index.ts delete mode 100644 packages/manager/src/routes/settings/index.ts diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 87635e8b4fa..bc1b61f6703 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,9 +1,32 @@ -## [2025-08-26] - v0.147.0 +## [2025-09-09] - v0.148.0 + +### Added: + +- Support for Node Pool `label` field ([#12710](https://github.com/linode/manager/pull/12710)) +- Volumes IAM RBAC permissions ([#12744](https://github.com/linode/manager/pull/12744)) +- NodeBalancers IAM RBAC permissions ([#12780](https://github.com/linode/manager/pull/12780)) +- Additional device slots to `Devices` type to match new API limits ([#12791](https://github.com/linode/manager/pull/12791)) + +### Changed: + +- Use `v4beta` API endpoint for `updateNodePool` ([#12710](https://github.com/linode/manager/pull/12710)) +- Update `CreateNodePoolData` to satisfy @linode/validation's `CreateNodePoolSchema`'s type ([#12793](https://github.com/linode/manager/pull/12793)) + +### Fixed: +- Wrong import path for EntityType ([#12764](https://github.com/linode/manager/pull/12764)) + +### Upcoming Features: + +- Add DELETE, PUT API endpoints for Streams ([#12645](https://github.com/linode/manager/pull/12645)) +- ACLP Alert: Add `regions` property in `CreateAlertDefinitionPayload` and `EditAlertDefinitionPayload` ([#12745](https://github.com/linode/manager/pull/12745)) +- Add DELETE, PUT API endpoints for Destinations ([#12749](https://github.com/linode/manager/pull/12749)) + +## [2025-08-26] - v0.147.0 ### Added: -- ACLP: `CloudPulseServiceType` type for type safety across cloudpulse ([#12646](https://github.com/linode/manager/pull/12646)) +- ACLP: `CloudPulseServiceType` type for type safety across cloudpulse ([#12646](https://github.com/linode/manager/pull/12646)) ### Changed: @@ -18,12 +41,11 @@ - API endpoint for Datastream - Create Destination ([#12627](https://github.com/linode/manager/pull/12627)) - Updated AccontMaintenance interface to make time fields nullable to match API ([#12665](https://github.com/linode/manager/pull/12665)) -- Update `KubernetesCluster` `vpc_id` and `subnet_id` types to include `null` ([#12700](https://github.com/linode/manager/pull/12700)) +- Update `KubernetesCluster` `vpc_id` and `subnet_id` types to include `null` ([#12700](https://github.com/linode/manager/pull/12700)) - CloudPulse: Update cloud pulse metrics request payload type at `types.ts` ([#12704](https://github.com/linode/manager/pull/12704)) ## [2025-08-12] - v0.146.0 - ### Added: - ACLP: `string` type for `capabilityServiceTypeMapping` constant ([#12573](https://github.com/linode/manager/pull/12573)) @@ -51,7 +73,6 @@ ## [2025-07-29] - v0.145.0 - ### Added: - `VPC Dual Stack` and `VPC IPv6 Large Prefixes` to account capabilities ([#12309](https://github.com/linode/manager/pull/12309)) @@ -69,14 +90,13 @@ ### Upcoming Features: - CloudPulse: Update service type in `types.ts` ([#12508](https://github.com/linode/manager/pull/12508)) -- ACLP-Alerting: Add nodebalancer to AlertServiceType for Alerts onboarding ([#12510](https://github.com/linode/manager/pull/12510)) +- ACLP-Alerting: Add nodebalancer to AlertServiceType for Alerts onboarding ([#12510](https://github.com/linode/manager/pull/12510)) - Add vpc_id and subnet_id to KubernetesCluster payload type ([#12513](https://github.com/linode/manager/pull/12513)) - Add API endpoints (GET, POST) for Streams ([#12524](https://github.com/linode/manager/pull/12524)) - ACLP-Alerting: Add firewall to AlertServiceType for Alerts onboarding ([#12550](https://github.com/linode/manager/pull/12550)) ## [2025-07-15] - v0.144.0 - ### Changed: - ACLP:Alerting - fixed the typo from evaluation_periods_seconds to evaluation_period_seconds ([#12466](https://github.com/linode/manager/pull/12466)) @@ -96,7 +116,7 @@ ### Changed: -- Allow `authorized_keys` to be null in `Profile` type ([#12390](https://github.com/linode/manager/pull/12390)) +- Allow `authorized_keys` to be null in `Profile` type ([#12390](https://github.com/linode/manager/pull/12390)) ### Removed: @@ -114,7 +134,6 @@ ## [2025-06-17] - v0.142.0 - ### Added: - `has_user_data` to `Linode` type ([#12352](https://github.com/linode/manager/pull/12352)) diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 26480538eb5..fe6accebbe4 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.147.0", + "version": "0.148.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -70,4 +70,4 @@ "tsc -p tsconfig.json --noEmit true --emitDeclarationOnly false" ] } -} +} \ No newline at end of file diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 36def13c55b..31b1c186491 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -344,6 +344,8 @@ export const EventActionKeys = [ 'database_migrate', 'database_upgrade', 'destination_create', + 'destination_delete', + 'destination_update', 'disk_create', 'disk_delete', 'disk_duplicate', @@ -470,6 +472,8 @@ export const EventActionKeys = [ 'stackscript_revise', 'stackscript_update', 'stream_create', + 'stream_delete', + 'stream_update', 'subnet_create', 'subnet_delete', 'subnet_update', diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 837c0503bbe..72b3d72d9db 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -196,6 +196,7 @@ export interface CreateAlertDefinitionPayload { description?: string; entity_ids?: string[]; label: string; + regions?: string[]; rule_criteria: { rules: MetricCriteria[]; }; @@ -337,10 +338,10 @@ export interface EditAlertDefinitionPayload { description?: string; entity_ids?: string[]; label?: string; + regions?: string[]; rule_criteria?: { rules: MetricCriteria[]; }; - scope?: AlertDefinitionScope; severity?: AlertSeverityType; status?: AlertStatusType; tags?: string[]; diff --git a/packages/api-v4/src/datastream/destinations.ts b/packages/api-v4/src/datastream/destinations.ts index 66784f50ff3..c3c8b00a231 100644 --- a/packages/api-v4/src/datastream/destinations.ts +++ b/packages/api-v4/src/datastream/destinations.ts @@ -1,4 +1,4 @@ -import { createDestinationSchema } from '@linode/validation'; +import { destinationSchema } from '@linode/validation'; import { BETA_API_ROOT } from '../constants'; import Request, { @@ -10,7 +10,11 @@ import Request, { } from '../request'; import type { Filter, ResourcePage as Page, Params } from '../types'; -import type { CreateDestinationPayload, Destination } from './types'; +import type { + CreateDestinationPayload, + Destination, + UpdateDestinationPayload, +} from './types'; /** * Returns all the information about a specified Destination. @@ -45,7 +49,38 @@ export const getDestinations = (params?: Params, filter?: Filter) => */ export const createDestination = (data: CreateDestinationPayload) => Request( - setData(data, createDestinationSchema), + setData(data, destinationSchema), setURL(`${BETA_API_ROOT}/monitor/streams/destinations`), setMethod('POST'), ); + +/** + * Updates a Destination. + * + * @param destinationId { number } The ID of the Destination. + * @param data { object } Options for type, label, etc. + */ +export const updateDestination = ( + destinationId: number, + data: UpdateDestinationPayload, +) => + Request( + setData(data, destinationSchema), + setURL( + `${BETA_API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, + ), + setMethod('PUT'), + ); + +/** + * Deletes a Destination. + * + * @param destinationId { number } The ID of the Destination. + */ +export const deleteDestination = (destinationId: number) => + Request<{}>( + setURL( + `${BETA_API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, + ), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/datastream/streams.ts b/packages/api-v4/src/datastream/streams.ts index 2e9ef2229cd..621bafa7247 100644 --- a/packages/api-v4/src/datastream/streams.ts +++ b/packages/api-v4/src/datastream/streams.ts @@ -1,4 +1,4 @@ -import { createStreamSchema } from '@linode/validation'; +import { createStreamSchema, updateStreamSchema } from '@linode/validation'; import { BETA_API_ROOT } from '../constants'; import Request, { @@ -10,7 +10,7 @@ import Request, { } from '../request'; import type { Filter, ResourcePage as Page, Params } from '../types'; -import type { CreateStreamPayload, Stream } from './types'; +import type { CreateStreamPayload, Stream, UpdateStreamPayload } from './types'; /** * Returns all the information about a specified Stream. @@ -47,3 +47,27 @@ export const createStream = (data: CreateStreamPayload) => setURL(`${BETA_API_ROOT}/monitor/streams`), setMethod('POST'), ); + +/** + * Updates a Stream. + * + * @param streamId { number } The ID of the Stream. + * @param data { object } Options for type, status, etc. + */ +export const updateStream = (streamId: number, data: UpdateStreamPayload) => + Request( + setData(data, updateStreamSchema), + setURL(`${BETA_API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), + setMethod('PUT'), + ); + +/** + * Deletes a Stream. + * + * @param streamId { number } The ID of the Stream. + */ +export const deleteStream = (streamId: number) => + Request<{}>( + setURL(`${BETA_API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/datastream/types.ts b/packages/api-v4/src/datastream/types.ts index 76f50d5e919..15b3129fdd3 100644 --- a/packages/api-v4/src/datastream/types.ts +++ b/packages/api-v4/src/datastream/types.ts @@ -103,14 +103,33 @@ interface CustomHeader { export interface CreateStreamPayload { destinations: number[]; - details?: StreamDetails; + details: StreamDetails; label: string; status?: StreamStatus; type: StreamType; } +export interface UpdateStreamPayload { + destinations: number[]; + details: StreamDetails; + label: string; + status: StreamStatus; + type: StreamType; +} + +export interface UpdateStreamPayloadWithId extends UpdateStreamPayload { + id: number; +} + export interface CreateDestinationPayload { details: CustomHTTPsDetails | LinodeObjectStorageDetails; label: string; type: DestinationType; } + +export type UpdateDestinationPayload = CreateDestinationPayload; + +export interface UpdateDestinationPayloadWithId + extends UpdateDestinationPayload { + id: number; +} diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 677cb9cafb5..fdf1a6bba56 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -1,4 +1,4 @@ -import type { EntityType } from 'src/entities/types'; +import type { EntityType } from '../entities/types'; export type AccountType = 'account'; @@ -102,7 +102,9 @@ export type AccountAdmin = | AccountBillingAdmin | AccountFirewallAdmin | AccountLinodeAdmin - | AccountOauthClientAdmin; + | AccountNodeBalancerAdmin + | AccountOauthClientAdmin + | AccountVolumeAdmin; /** Permissions associated with the "account_billing_admin" role. */ export type AccountBillingAdmin = @@ -141,6 +143,20 @@ export type AccountLinodeAdmin = AccountLinodeCreator | LinodeAdmin; /** Permissions associated with the "account_linode_creator" role. */ export type AccountLinodeCreator = 'create_linode'; +/** Permissions associated with the "account_nodebalancer_admin" role. */ +export type AccountNodeBalancerAdmin = + | AccountNodeBalancerCreator + | NodeBalancerAdmin; + +/** Permissions associated with the "account_nodebalancer_creator" role. */ +export type AccountNodeBalancerCreator = 'create_nodebalancer'; + +/** Permissions associated with the "account_volume_admin" role. */ +export type AccountVolumeAdmin = AccountVolumeCreator | VolumeAdmin; + +/** Permissions associated with the "account_volume_creator" role. */ +export type AccountVolumeCreator = 'create_volume'; + /** Permissions associated with the "account_maintenance_viewer" role. */ export type AccountMaintenanceViewer = 'list_maintenances'; @@ -207,7 +223,8 @@ export type AccountViewer = | AccountOauthClientViewer | AccountProfileViewer | FirewallViewer - | LinodeViewer; + | LinodeViewer + | VolumeViewer; /** Permissions associated with the "firewall_admin role. */ export type FirewallAdmin = @@ -287,45 +304,50 @@ export type LinodeViewer = | 'view_linode_network_transfer' | 'view_linode_stats'; -/** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */ -export type AccountRoleFacade = - | 'account_database_creator' - | 'account_domain_creator' - | 'account_image_creator' - | 'account_ip_admin' - | 'account_ip_viewer' - | 'account_lkecluster_creator' - | 'account_longview_creator' - | 'account_longview_subscription_admin' - | 'account_nodebalancer_creator' - | 'account_placement_group_creator' - | 'account_stackscript_creator' - | 'account_vlan_admin' - | 'account_vlan_viewer' - | 'account_volume_creator' - | 'account_vpc_creator'; - -/** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */ -export type EntityRoleFacade = - | 'database_admin' - | 'database_viewer' - | 'domain_admin' - | 'domain_viewer' - | 'image_admin' - | 'image_viewer' - | 'lkecluster_admin' - | 'lkecluster_viewer' - | 'longview_admin' - | 'longview_viewer' - | 'nodebalancer_admin' - | 'nodebalancer_viewer' - | 'placement_group_admin' - | 'placement_group_viewer' - | 'stackscript_admin' - | 'stackscript_viewer' - | 'volume_admin' - | 'volume_viewer' - | 'vpc_admin'; +/** Permissions associated with the "nodebalancer_admin" role. */ +// TODO: UIE-9154 - verify mapping for Nodebalancer as this is not migrated yet +export type NodeBalancerAdmin = + | 'delete_nodebalancer' + | 'delete_nodebalancer_config' + | 'delete_nodebalancer_config_node' + | NodeBalancerContributor; + +/** Permissions associated with the "nodebalancer_contributor" role. */ +export type NodeBalancerContributor = + | 'create_nodebalancer_config' + | 'create_nodebalancer_config_node' + | 'rebuild_nodebalancer_config' + | 'update_nodebalancer' + | 'update_nodebalancer_config' + | 'update_nodebalancer_config_node' + | 'update_nodebalancer_firewalls' + | NodeBalancerViewer; + +/** Permissions associated with the "nodebalancer_viewer" role. */ +export type NodeBalancerViewer = + | 'list_nodebalancer_config_nodes' + | 'list_nodebalancer_configs' + | 'list_nodebalancer_firewalls' + | 'view_nodebalancer' + | 'view_nodebalancer_config' + | 'view_nodebalancer_config_node' + | 'view_nodebalancer_statistics'; + +/** Permissions associated with the "volume_admin" role. */ +export type VolumeAdmin = 'delete_volume' | VolumeContributor; + +/** Permissions associated with the "volume_contributor" role. */ +export type VolumeContributor = + | 'attach_volume' + | 'clone_volume' + | 'delete_volume' + | 'detach_volume' + | 'resize_volume' + | 'update_volume' + | VolumeViewer; + +/** Permissions associated with the "volume_viewer" role. */ +export type VolumeViewer = 'view_volume'; /** Union of all permissions */ export type PermissionType = AccountAdmin; diff --git a/packages/api-v4/src/kubernetes/nodePools.ts b/packages/api-v4/src/kubernetes/nodePools.ts index 89b8d6b7daa..faf8895e28c 100644 --- a/packages/api-v4/src/kubernetes/nodePools.ts +++ b/packages/api-v4/src/kubernetes/nodePools.ts @@ -1,4 +1,4 @@ -import { nodePoolSchema } from '@linode/validation/lib/kubernetes.schema'; +import { CreateNodePoolSchema, EditNodePoolSchema } from '@linode/validation'; import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { @@ -61,7 +61,7 @@ export const createNodePool = (clusterID: number, data: CreateNodePoolData) => setURL( `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/pools`, ), - setData(data, nodePoolSchema), + setData(data, CreateNodePoolSchema), ); /** @@ -77,11 +77,11 @@ export const updateNodePool = ( Request( setMethod('PUT'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent( + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent( clusterID, )}/pools/${encodeURIComponent(nodePoolID)}`, ), - setData(data, nodePoolSchema), + setData(data, EditNodePoolSchema), ); /** diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index a518d0260d7..f0181314853 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -70,6 +70,12 @@ export interface KubeNodePoolResponse { * @note Only returned for LKE Enterprise clusters */ k8s_version?: string; + /** + * A label/name for the Node Pool + * + * @default "" + */ + label: string; labels: Label; nodes: PoolNodeResponse[]; tags: string[]; @@ -104,7 +110,7 @@ export interface CreateNodePoolData { * * @note Only supported on LKE Enterprise clusters */ - firewall_id?: number; + firewall_id?: null | number; /** * The LKE version that the node pool should use. * @@ -112,15 +118,21 @@ export interface CreateNodePoolData { * @note This field may be required when creating a Node Pool on a LKE Enterprise cluster */ k8s_version?: string; + /** + * An optional label/name for the Node Pool. + * + * @default "" + */ + label?: string; /** * Key-value pairs added as labels to nodes in the node pool. */ - labels?: Label; - tags?: string[]; + labels?: Label | null; + tags?: null | string[]; /** * Kubernetes taints to add to node pool nodes. */ - taints?: Taint[]; + taints?: null | Taint[]; /** * The Linode Type for all of the nodes in the Node Pool. */ @@ -132,7 +144,7 @@ export interface CreateNodePoolData { * @note Only supported on LKE Enterprise clusters * @default on_recycle */ - update_strategy?: NodePoolUpdateStrategy; + update_strategy?: NodePoolUpdateStrategy | null; } export type UpdateNodePoolData = Partial; diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 48005cd73d2..114a7dce89d 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -388,15 +388,73 @@ export interface VolumeDevice { volume_id: null | number; } +export type ConfigDevice = DiskDevice | null | VolumeDevice; + export interface Devices { - sda: DiskDevice | null | VolumeDevice; - sdb: DiskDevice | null | VolumeDevice; - sdc: DiskDevice | null | VolumeDevice; - sdd: DiskDevice | null | VolumeDevice; - sde: DiskDevice | null | VolumeDevice; - sdf: DiskDevice | null | VolumeDevice; - sdg: DiskDevice | null | VolumeDevice; - sdh: DiskDevice | null | VolumeDevice; + sda?: ConfigDevice; + sdaa?: ConfigDevice; + sdab?: ConfigDevice; + sdac?: ConfigDevice; + sdad?: ConfigDevice; + sdae?: ConfigDevice; + sdaf?: ConfigDevice; + sdag?: ConfigDevice; + sdah?: ConfigDevice; + sdai?: ConfigDevice; + sdaj?: ConfigDevice; + sdak?: ConfigDevice; + sdal?: ConfigDevice; + sdam?: ConfigDevice; + sdan?: ConfigDevice; + sdao?: ConfigDevice; + sdap?: ConfigDevice; + sdaq?: ConfigDevice; + sdar?: ConfigDevice; + sdas?: ConfigDevice; + sdat?: ConfigDevice; + sdau?: ConfigDevice; + sdav?: ConfigDevice; + sdaw?: ConfigDevice; + sdax?: ConfigDevice; + sday?: ConfigDevice; + sdaz?: ConfigDevice; + sdb?: ConfigDevice; + sdba?: ConfigDevice; + sdbb?: ConfigDevice; + sdbc?: ConfigDevice; + sdbd?: ConfigDevice; + sdbe?: ConfigDevice; + sdbf?: ConfigDevice; + sdbg?: ConfigDevice; + sdbh?: ConfigDevice; + sdbi?: ConfigDevice; + sdbj?: ConfigDevice; + sdbk?: ConfigDevice; + sdbl?: ConfigDevice; + sdc?: ConfigDevice; + sdd?: ConfigDevice; + sde?: ConfigDevice; + sdf?: ConfigDevice; + sdg?: ConfigDevice; + sdh?: ConfigDevice; + sdi?: ConfigDevice; + sdj?: ConfigDevice; + sdk?: ConfigDevice; + sdl?: ConfigDevice; + sdm?: ConfigDevice; + sdn?: ConfigDevice; + sdo?: ConfigDevice; + sdp?: ConfigDevice; + sdq?: ConfigDevice; + sdr?: ConfigDevice; + sds?: ConfigDevice; + sdt?: ConfigDevice; + sdu?: ConfigDevice; + sdv?: ConfigDevice; + sdw?: ConfigDevice; + sdx?: ConfigDevice; + sdy?: ConfigDevice; + sdz?: ConfigDevice; } export type KernelArchitecture = 'i386' | 'x86_64'; @@ -678,10 +736,7 @@ export interface MigrateLinodeRequest { region: string; } -export type RescueRequestObject = Pick< - Devices, - 'sda' | 'sdb' | 'sdc' | 'sdd' | 'sde' | 'sdf' | 'sdg' ->; +export type RescueRequestObject = Omit; export interface LinodeCloneData { backups_enabled?: boolean | null; diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index e647ca67c2e..1b0205fdb8f 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,81 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-09-09] - v1.150.0 + +### Added: + +- Type-to-confirm to Image deletion dialog ([#12740](https://github.com/linode/manager/pull/12740)) +- Volumes IAM RBAC permissions ([#12744](https://github.com/linode/manager/pull/12744)) +- Notification banner for Account Settings if user doesn't have permission to see ([#12774](https://github.com/linode/manager/pull/12774)) +- Linked Node Pool firewall in Node Pool footer for LKE-E clusters ([#12779](https://github.com/linode/manager/pull/12779)) +- IAM RBAC: Implement IAM RBAC permissions for NodeBalancer ([#12780](https://github.com/linode/manager/pull/12780)) +- IAM RBAC: Implement IAM RBAC permissions for NodeBalancer summary tab ([#12790](https://github.com/linode/manager/pull/12790)) +- Additional device slots to Linode Config and Rescue Dialog to match new API limits ([#12791](https://github.com/linode/manager/pull/12791)) + +### Changed: + +- IAM RBAC: change docs links to be relevant to content on page ([#12743](https://github.com/linode/manager/pull/12743)) +- Support IAM/RBAC permission segmentation for BETA/LA features ([#12764](https://github.com/linode/manager/pull/12764)) +- Aggregation Function labels from Average,Minimum,Maximum to Avg,Min,Max in ACLP-Alerting service ([#12787](https://github.com/linode/manager/pull/12787)) +- Add data-pendo-id attribute to TabbedPanel for Linode Plan tab tracking ([#12806](https://github.com/linode/manager/pull/12806)) + +### Fixed: + +- Empty paginated Access Keys pages ([#12598](https://github.com/linode/manager/pull/12598)) +- MUI Autocomplete console errors ([#12706](https://github.com/linode/manager/pull/12706)) +- IAM - Cross browser AssignedRoles entities chips truncation ([#12720](https://github.com/linode/manager/pull/12720)) +- Selected region not being reset when switching between Core and Distributed tabs ([#12767](https://github.com/linode/manager/pull/12767)) +- Jumping UI on LKE Create HA Control Plane when enabling Akamai App Platform ([#12768](https://github.com/linode/manager/pull/12768)) +- Profile preferences not retained across sessions ([#12795](https://github.com/linode/manager/pull/12795)) +- Make a Payment and Add Payment drawers not closing when browser back button is clicked ([#12796](https://github.com/linode/manager/pull/12796)) +- Node Pool footer layout ([#12798](https://github.com/linode/manager/pull/12798)) + +### Removed: + +- Unused LKE-E related code from `LinodeConfigDialog` ([#12812](https://github.com/linode/manager/pull/12812)) + +### Tech Stories: + +- Improve usePermissions hook type safety ([#12732](https://github.com/linode/manager/pull/12732)) +- MSW CRUD: Fix type error when using custom restricted profile preset and add grants preset support ([#12756](https://github.com/linode/manager/pull/12756)) +- Update `jspdf` to 3.0.2 ([#12797](https://github.com/linode/manager/pull/12797)) + +### Tests: + +- Fix Linode resize test failures ([#12727](https://github.com/linode/manager/pull/12727)) +- Add tests for Host & VM Maintenance in Linode create page ([#12734](https://github.com/linode/manager/pull/12734)) +- Fix for flaky test in alerts-listing-page.spec.ts ([#12736](https://github.com/linode/manager/pull/12736)) +- Fix LKE Create Smoke Test Flake ([#12738](https://github.com/linode/manager/pull/12738)) +- Add Cypress error handling test coverage for LKE-E Phase 2 (BYO VPC, IP Stack) ([#12755](https://github.com/linode/manager/pull/12755)) +- Fix Cypress test result summary duration accuracy ([#12765](https://github.com/linode/manager/pull/12765)) +- Add test for empty string in numeric input validation ([#12769](https://github.com/linode/manager/pull/12769)) +- Add Cypress test coverage for standard cluster creation with LKE-E phase2Mtc flag enabled ([#12770](https://github.com/linode/manager/pull/12770)) +- Add Cypress LKE-E 'phase2Mtc' feature flag smoke tests ([#12773](https://github.com/linode/manager/pull/12773)) +- Add mock IntersectionObserver in testSetup.ts and check disabled tooltip in region select component tests ([#12777](https://github.com/linode/manager/pull/12777)) +- Add tests for Host & VM Maintenance Linode details page changes ([#12786](https://github.com/linode/manager/pull/12786)) +- Fix disk deletion test following API release changes ([#12794](https://github.com/linode/manager/pull/12794)) + +### Upcoming Features: + +- Volume details page ([#12757](https://github.com/linode/manager/pull/12757)) +- DataStreams: add actions with handlers in Streams list, add Edit Stream component ([#12645](https://github.com/linode/manager/pull/12645)) +- Add Configure Node Pool Drawer to Kubernetes Cluster details page ([#12710](https://github.com/linode/manager/pull/12710)) +- CloudPulse - Alerts: Update handler in `AlertReusableComponenr.tsx`, reset state in `AlertInformationActionTable.tsx`, update query in `alerts.ts` ([#12730](https://github.com/linode/manager/pull/12730)) +- ACLP: Order of each legend row label value is based on group by sequence ([#12742](https://github.com/linode/manager/pull/12742)) +- ACLP Alert: Add `EntityScopeSelection` drop down in Alert creation and edit form([#12745](https://github.com/linode/manager/pull/12745)) +- Update dual-stack labeling in VPC Create ([#12746](https://github.com/linode/manager/pull/12746)) +- Fix and extend ACLP-supported region Linode mock examples ([#12747](https://github.com/linode/manager/pull/12747)) +- DataStreams: add actions with handlers in Destinations list, add Edit Destination component ([#12749](https://github.com/linode/manager/pull/12749)) +- Update dual-stack labeling for LKE-E clusters in create cluster flow ([#12754](https://github.com/linode/manager/pull/12754)) +- CloudPulse - Metrics: Add/Update 'no region' info message for all services in `constants.ts` ([#12759](https://github.com/linode/manager/pull/12759)) +- CloudPulse - Metrics: Add missing props and enhance utils for firewalls contextual view ([#12760](https://github.com/linode/manager/pull/12760)) +- ACLP-Metrics,Alerts: enforce validation for 100 characters for TextField components ([#12771](https://github.com/linode/manager/pull/12771)) +- Disable legacy interface selection for Linode Interfaces when creating a Linode from backups ([#12772](https://github.com/linode/manager/pull/12772)) +- UX feedback: Change /settings to /account-settings and profile/settings to profile/preferences ([#12785](https://github.com/linode/manager/pull/12785)) +- Add Firewall option to the Add Node Pool Drawer for LKE Enterprise Kubernetes Clusters ([#12793](https://github.com/linode/manager/pull/12793)) +- CloudPulse-Metrics: Update CloudPulseRegionSelect.tsx to handle default linode region selection in firewalls contextual view ([#12805](https://github.com/linode/manager/pull/12805)) + ## [2025-08-28] - v1.149.1 ### Fixed: diff --git a/packages/manager/cypress/component/components/region-select.spec.tsx b/packages/manager/cypress/component/components/region-select.spec.tsx index 91d5200a39c..5ff3a7ef941 100644 --- a/packages/manager/cypress/component/components/region-select.spec.tsx +++ b/packages/manager/cypress/component/components/region-select.spec.tsx @@ -1,3 +1,4 @@ +import { Typography } from '@linode/ui'; import { accountAvailabilityFactory, regionFactory } from '@linode/utilities'; import * as React from 'react'; import { mockGetAccountAvailability } from 'support/intercepts/account'; @@ -29,7 +30,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={[region]} - value={undefined} + value={null} /> ); @@ -58,7 +59,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={[region]} - value={undefined} + value={null} /> ); @@ -88,7 +89,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={[region]} - value={undefined} + value={null} /> ); @@ -118,7 +119,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={[region]} - value={undefined} + value={null} /> ); @@ -152,7 +153,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); @@ -271,7 +272,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); @@ -289,7 +290,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={spyFn} regions={regions} - value={undefined} + value={null} /> ); @@ -359,7 +360,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} />, { dcGetWell: true, @@ -394,7 +395,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); @@ -424,7 +425,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); @@ -441,6 +442,53 @@ componentTests('RegionSelect', (mount) => { .should('be.visible'); }); }); + + it('should display a tooltip for disabled regions', () => { + const disabledRegion = 'US, Fremont, CA (us-west)'; + const disabledReason = `You've reached the limit of placement groups you can create in this region.`; + + mount( + {disabledReason}, + tooltipWidth: 300, + }, + }} + isGeckoLAEnabled={false} + onChange={() => {}} + regions={[ + ...regions, + regionFactory.build({ + id: 'us-west', + label: 'US, Fremont, CA', + capabilities: ['Object Storage'], + }), + ]} + value={null} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText(disabledRegion).as('regionItem').scrollIntoView(); + + cy.get('@regionItem').should('be.visible'); + + cy.findByText(disabledRegion) + .closest('li') + .should('have.attr', 'data-qa-disabled-item', 'true'); + + cy.findByText(disabledRegion).closest('li').click(); + cy.findByRole('tooltip') + .should('be.visible') + .and('contain.text', disabledReason); + }); }); visualTests((mount) => { @@ -455,7 +503,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); checkComponentA11y(); diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index a4ced12e972..3acf27ab02b 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -178,50 +178,14 @@ describe('Account cancellation', () => { mockGetAccount(mockAccount).as('getAccount'); mockGetProfile(mockProfile).as('getProfile'); mockGetProfileGrants(mockGrants).as('getGrants'); - mockCancelAccountError('Unauthorized', 403).as('cancelAccount'); - // Navigate to Account Settings page, click "Close Account" button. cy.visitWithLogin('/account/settings'); cy.wait(['@getAccount', '@getProfile', '@getGrants']); - cy.findByTestId('close-account') - .should('be.visible') - .within(() => { - cy.findByTestId('close-account-button') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Fill out cancellation dialog and attempt submission. - ui.dialog - .findByTitle(cancellationDialogTitle) - .should('be.visible') - .within(() => { - // Check both boxes but verify submit remains disabled without email - cy.get('[data-qa-checkbox="deleteAccountServices"]').click(); - cy.get('[data-qa-checkbox="deleteAccountUsers"]').click(); - - ui.button - .findByTitle('Close Account') - .should('be.visible') - .should('be.disabled'); - - cy.findByLabelText(`Enter your email address (${mockProfile.email})`) - .should('be.visible') - .should('be.enabled') - .type(mockProfile.email); - - ui.button - .findByTitle('Close Account') - .should('be.visible') - .should('be.enabled') - .click(); - - // Confirm that API unauthorized error message is displayed. - cy.wait('@cancelAccount'); - cy.findByText('Unauthorized').should('be.visible'); - }); + cy.findByText( + "You don't have permissions to edit this Account. Please contact your account administrator to request the necessary permissions.", + { exact: false } + ).should('be.visible'); }); }); diff --git a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts index c9466939e48..76822558365 100644 --- a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts @@ -120,31 +120,10 @@ describe('Account Linode Managed', () => { visitUrlWithManagedDisabled('/account/settings'); cy.wait(['@getAccount', '@getProfile', '@getGrants']); - ui.button - .findByTitle('Add Linode Managed') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Just to confirm...') - .should('be.visible') - .within(() => { - cy.get('h6') - .invoke('text') - .then((text) => { - expect(text.trim()).to.equal(linodeEnabledMessageText(0)); - }); - // Confirm that submit button is enabled. - ui.button - .findByTitle('Add Linode Managed') - .should('be.visible') - .should('be.enabled') - .click(); - cy.wait('@enableLinodeManaged'); - // Confirm that Cloud Manager displays a notice about Linode managed is unauthorized. - cy.findByText(errorMessage, { exact: false }).should('be.visible'); - }); + cy.findByText( + "You don't have permissions to edit this Account. Please contact your account administrator to request the necessary permissions.", + { exact: false } + ).should('be.visible'); }); /* 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 6e7a65ed47a..09712fbff0f 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 @@ -187,7 +187,7 @@ describe('restricted user details pages', () => { .and('be.disabled') .trigger('mouseover'); ui.tooltip.findByText( - 'You must be an unrestricted User in order to add or modify tags on Linodes.' + 'You must be an unrestricted User in order to add or modify tags on a Linode.' ); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index 485dd160cb7..7c1a0643c1f 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -260,24 +260,27 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { }); it('should validate UI elements and alert details', () => { - // Validate navigation links and buttons - cy.findByText('Alerts').should('be.visible'); + // filter to main content area to avoid confusion w/ 'Alerts' nav link in left sidebar + cy.get('main').within(() => { + // Validate breadcrumb and buttons + cy.findByText('Alerts', { exact: false }).should('be.visible'); - cy.findByText('Definitions') - .should('be.visible') - .and('have.attr', 'href', alertDefinitionsUrl); - ui.buttonGroup.findButtonByTitle('Create Alert').should('be.visible'); + cy.findByText('Definitions') + .should('be.visible') + .and('have.attr', 'href', alertDefinitionsUrl); + ui.buttonGroup.findButtonByTitle('Create Alert').should('be.visible'); - // Validate table headers - cy.get('[data-qa="alert-table"]').within(() => { - expectedHeaders.forEach((header) => { - cy.findByText(header).should('have.text', header); + // Validate table headers + cy.get('[data-qa="alert-table"]').within(() => { + expectedHeaders.forEach((header) => { + cy.findByText(header).should('have.text', header); + }); }); - }); - // Validate alert details - mockAlerts.forEach((alert) => { - validateAlertDetails(alert); + // Validate alert details + mockAlerts.forEach((alert) => { + validateAlertDetails(alert); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts index 82b1f5fd4a4..f5cfa16fe10 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -120,7 +120,7 @@ const mockAlerts = alertFactory.build({ * Fills metric details in the form. * @param ruleIndex - The index of the rule to fill. * @param dataField - The metric's data field (e.g., "CPU Utilization"). - * @param aggregationType - The aggregation type (e.g., "Average"). + * @param aggregationType - The aggregation type (e.g., "Avg"). * @param operator - The operator (e.g., ">=", "=="). * @param threshold - The threshold value for the metric. */ @@ -242,7 +242,7 @@ describe('Create Alert', () => { // Fill metric details for the first rule const cpuUsageMetricDetails = { - aggregationType: 'Average', + aggregationType: 'Avg', dataField: 'CPU Utilization', operator: '=', ruleIndex: 0, @@ -288,7 +288,7 @@ describe('Create Alert', () => { // Fill metric details for the second rule const memoryUsageMetricDetails = { - aggregationType: 'Average', + aggregationType: 'Avg', dataField: 'Memory Usage', operator: '=', ruleIndex: 1, diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 8de25cf4d55..3522398093c 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -145,6 +145,7 @@ const getWidgetLegendRowValuesFromResponse = ( status: 'success', unit, serviceType, + groupBy: ['entity_id'], }); // Destructure metrics data from the first legend row diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts index 3ab96ff6578..c50f8955077 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -205,7 +205,7 @@ describe('Integration Tests for Edit Alert', () => { // Assert rule values 1 assertRuleValues(0, { - aggregationType: 'Average', + aggregationType: 'Avg', dataField: 'CPU Utilization', operator: '=', threshold: '1000', @@ -213,7 +213,7 @@ describe('Integration Tests for Edit Alert', () => { // Assert rule values 2 assertRuleValues(1, { - aggregationType: 'Average', + aggregationType: 'Avg', dataField: 'Memory Usage', operator: '=', threshold: '1000', @@ -295,8 +295,8 @@ describe('Integration Tests for Edit Alert', () => { cy.get('[data-testid="rule_criteria.rules.0-id"]').within(() => { ui.autocomplete.findByLabel('Data Field').type('Disk I/O'); ui.autocompletePopper.findByTitle('Disk I/O').click(); - ui.autocomplete.findByLabel('Aggregation Type').type('Minimum'); - ui.autocompletePopper.findByTitle('Minimum').click(); + ui.autocomplete.findByLabel('Aggregation Type').type('Min'); + ui.autocompletePopper.findByTitle('Min').click(); ui.autocomplete.findByLabel('Operator').type('>'); ui.autocompletePopper.findByTitle('>').click(); cy.get('[data-qa-threshold]').should('be.visible').clear(); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 9c30ccef63e..becda1f1629 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -130,6 +130,7 @@ const getWidgetLegendRowValuesFromResponse = ( status: 'success', unit, serviceType, + groupBy: ['entity_id'], }); // Destructure metrics data from the first legend row diff --git a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts index 77d1c026048..e56b912b86f 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts @@ -123,6 +123,7 @@ const getWidgetLegendRowValuesFromResponse = ( status: 'success', unit, serviceType, + groupBy: ['entity_id'], }); // Destructure metrics data from the first legend row diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index f1dac1ca995..e36d10bb960 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -16,6 +16,8 @@ import { apiMatcher } from 'support/util/intercepts'; import { randomLabel, randomPhrase } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { DISALLOWED_IMAGE_REGIONS } from 'src/constants'; + import type { EventStatus } from '@linode/api-v4'; import type { RecPartial } from 'factory.ts'; @@ -121,7 +123,7 @@ const uploadImage = (label: string) => { // See also BAC-862. const region = chooseRegion({ capabilities: ['Object Storage'], - exclude: ['au-mel', 'gb-lon', 'sg-sin-2'], + exclude: DISALLOWED_IMAGE_REGIONS, }); const upload = 'machine-images/test-image.gz'; cy.visitWithLogin('/images/create/upload'); @@ -238,8 +240,13 @@ describe('machine image', () => { .findByTitle(`Delete Image ${updatedLabel}`) .should('be.visible') .within(() => { + cy.findByLabelText('Image Label') + .should('be.visible') + .should('be.enabled') + .type(updatedLabel); + ui.buttonGroup - .findButtonByTitle('Delete Image') + .findButtonByTitle('Delete') .should('be.visible') .should('be.enabled') .click(); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index dca95f300a8..3ecf54685fb 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -121,12 +121,18 @@ describe('LKE Cluster Creation', () => { beforeEach(() => { // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, postLa: false }, + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: true, + }, }).as('getFeatureFlags'); }); /* * - Confirms that users can create a cluster by completing the LKE create form. + * - Confirms that no IP Stack or VPC options are visible for standard tier clusters (LKE-E only). * - Confirms that LKE cluster is created. * - Confirms that user is redirected to new LKE cluster summary page. * - Confirms that correct information is shown on the LKE cluster summary page @@ -199,6 +205,15 @@ describe('LKE Cluster Creation', () => { cy.get('[data-testid="ha-radio-button-no"]').should('be.visible').click(); + // Confirms LKE-E Phase 2 IP Stack and VPC options do not display for a standard LKE cluster. + cy.findByText('IP Stack').should('not.exist'); + cy.findByText('IPv4', { exact: true }).should('not.exist'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); + cy.findByText('Automatically generate a VPC for this cluster').should( + 'not.exist' + ); + cy.findByText('Use an existing VPC').should('not.exist'); + let monthPrice = 0; // Confirm the expected available plans display. @@ -269,12 +284,19 @@ describe('LKE Cluster Creation', () => { .click(); }); + // Confirm request payload does not include LKE-E-specific values. + cy.wait('@createCluster').then((intercept) => { + const payload = intercept.request.body; + expect(payload.stack_type).to.be.undefined; + expect(payload.vpc_id).to.be.undefined; + expect(payload.subnet_id).to.be.undefined; + }); + // Wait for LKE cluster to be created and confirm that we are redirected // to the cluster summary page. cy.wait([ '@getCluster', '@getClusterPools', - '@createCluster', '@getLKEClusterTypes', '@getDashboardUrl', '@getControlPlaneACL', diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts index bc84016b588..78dc2c33408 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -15,6 +15,7 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { mockCreateCluster, + mockCreateClusterError, mockGetKubernetesVersions, mockGetLKEClusterTypes, mockGetTieredKubernetesVersions, @@ -31,35 +32,91 @@ import { vpcFactory, } from 'src/factories'; +const clusterLabel = randomLabel(); +const selectedVpcId = 1; +const selectedSubnetId = 1; + +const mockEnterpriseCluster = kubernetesClusterFactory.build({ + k8s_version: latestEnterpriseTierKubernetesVersion.id, + label: clusterLabel, + region: 'us-iad', + tier: 'enterprise', +}); + +const mockVpcs = [ + { + ...vpcFactory.build(), + id: selectedVpcId, + label: 'test-vpc', + region: 'us-iad', + subnets: [ + subnetFactory.build({ + id: selectedSubnetId, + label: 'subnet-a', + ipv4: '10.0.0.0/13', + }), + ], + }, +]; + describe('LKE Cluster Creation with LKE-E', () => { - describe('LKE-E Phase 2 Networking Configurations', () => { - const clusterLabel = randomLabel(); - const selectedVpcId = 1; - const selectedSubnetId = 1; - - const mockEnterpriseCluster = kubernetesClusterFactory.build({ - k8s_version: latestEnterpriseTierKubernetesVersion.id, - label: clusterLabel, - region: 'us-iad', - tier: 'enterprise', - }); + beforeEach(() => { + // TODO LKE-E: Remove feature flag mocks once we're in GA + mockAppendFeatureFlags({ + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: true, + }, + }).as('getFeatureFlags'); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); - const mockVpcs = [ - { - ...vpcFactory.build(), - id: selectedVpcId, - label: 'test-vpc', - region: 'us-iad', - subnets: [ - subnetFactory.build({ - id: selectedSubnetId, - label: 'subnet-a', - ipv4: '10.0.0.0/13', - }), + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetKubernetesVersions([latestKubernetesVersion]).as( + 'getKubernetesVersions' + ); + + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( + 'getLKEEnterpriseClusterTypes' + ); + mockCreateCluster(mockEnterpriseCluster).as('createCluster'); + + mockGetRegions([ + regionFactory.build({ + capabilities: [ + 'Linodes', + 'Kubernetes', + 'Kubernetes Enterprise', + 'VPCs', ], - }, - ]; + id: 'us-iad', + label: 'Washington, DC', + }), + ]).as('getRegions'); + + mockGetVPCs(mockVpcs).as('getVPCs'); + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getAccount']); + + ui.button.findByTitle('Create Cluster').click(); + cy.url().should('endWith', '/kubernetes/create'); + cy.wait([ + '@getKubernetesVersions', + '@getTieredKubernetesVersions', + '@getLinodeTypes', + ]); + }); + + describe('LKE-E Phase 2 Networking Configurations', () => { // Accounts for the different combination of IP Networking and VPC/Subnet radio selections const possibleNetworkingConfigurations = [ { @@ -88,62 +145,6 @@ describe('LKE Cluster Creation with LKE-E', () => { }, ]; - beforeEach(() => { - // TODO LKE-E: Remove feature flag mocks once we're in GA - mockAppendFeatureFlags({ - lkeEnterprise: { - enabled: true, - la: true, - postLa: false, - phase2Mtc: true, - }, - }).as('getFeatureFlags'); - mockGetAccount( - accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], - }) - ).as('getAccount'); - - mockGetTieredKubernetesVersions('enterprise', [ - latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetKubernetesVersions([latestKubernetesVersion]).as( - 'getKubernetesVersions' - ); - - mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); - mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( - 'getLKEEnterpriseClusterTypes' - ); - mockCreateCluster(mockEnterpriseCluster).as('createCluster'); - - mockGetRegions([ - regionFactory.build({ - capabilities: [ - 'Linodes', - 'Kubernetes', - 'Kubernetes Enterprise', - 'VPCs', - ], - id: 'us-iad', - label: 'Washington, DC', - }), - ]).as('getRegions'); - - mockGetVPCs(mockVpcs).as('getVPCs'); - - cy.visitWithLogin('/kubernetes/clusters'); - cy.wait(['@getAccount']); - - ui.button.findByTitle('Create Cluster').click(); - cy.url().should('endWith', '/kubernetes/create'); - cy.wait([ - '@getKubernetesVersions', - '@getTieredKubernetesVersions', - '@getLinodeTypes', - ]); - }); - possibleNetworkingConfigurations.forEach( ({ description, isUsingOwnVPC, stackType }) => { it(`${description}`, () => { @@ -158,9 +159,7 @@ describe('LKE Cluster Creation with LKE-E', () => { // Select either the autogenerated or existing (BYO) VPC radio button if (isUsingOwnVPC) { - cy.findByTestId('isUsingOwnVpc').within(() => { - cy.findByLabelText('Use an existing VPC').click(); - }); + cy.findByLabelText('Use an existing VPC').click(); // Select the existing VPC and Subnet to use ui.autocomplete.findByLabel('VPC').click(); @@ -171,7 +170,7 @@ describe('LKE Cluster Creation with LKE-E', () => { // Select either the IPv4 or IPv4 + IPv6 (dual-stack) IP Networking radio button cy.findByLabelText( - stackType === 'ipv4' ? 'IPv4' : 'IPv4 + IPv6' + stackType === 'ipv4' ? 'IPv4' : 'IPv4 + IPv6 (dual-stack)' ).click(); // Select a plan and add nodes @@ -223,4 +222,178 @@ describe('LKE Cluster Creation with LKE-E', () => { } ); }); + + describe('LKE-E Cluster Error Handling', () => { + /* + * Surfaces an API errors on the page. + */ + it('surfaces API error when creating cluster with an invalid configuration', () => { + const mockErrorMessage = + 'There was a general error when creating your cluster.'; + + mockGetVPCs(mockVpcs).as('getVPCs'); + mockCreateClusterError(mockErrorMessage).as('createClusterError'); + + cy.findByLabelText('Cluster Label').type(clusterLabel); + cy.findByText('LKE Enterprise').click(); + cy.wait(['@getLKEEnterpriseClusterTypes', '@getRegions']); + + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + cy.wait('@getVPCs'); + + cy.findByLabelText( + 'Automatically generate a VPC for this cluster' + ).click(); + cy.findByLabelText('IPv4 + IPv6 (dual-stack)').click(); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Select a plan and add nodes + cy.findByText(clusterPlans[0].tab).should('be.visible').click(); + cy.findByText(clusterPlans[0].planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${clusterPlans[0].nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + ui.button.findByTitle('Create Cluster').click(); + }); + + cy.wait('@createClusterError'); + cy.findByText(mockErrorMessage).should('be.visible'); + }); + + /** + * Surfaces field-level errors on the page. + */ + it('surfaces field-level errors on VPC fields', () => { + // Intercept the create cluster request and force an error response + cy.intercept('POST', '/v4beta/lke/clusters', { + statusCode: 400, + body: { + errors: [ + { + reason: 'There is an error configuring this VPC.', + field: 'vpc_id', + }, + { + reason: 'There is an error configuring this subnet.', + field: 'subnet_id', + }, + ], + }, + }).as('createClusterError'); + + cy.findByLabelText('Cluster Label').type(clusterLabel); + cy.findByText('LKE Enterprise').click(); + + // Select region, VPC, subnet, and IP stack + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + cy.findByLabelText('Use an existing VPC').click(); + ui.autocomplete.findByLabel('VPC').click(); + cy.findByText('test-vpc').click(); + ui.autocomplete.findByLabel('Subnet').click(); + cy.findByText(/subnet-a/).click(); + cy.findByLabelText('IPv4 + IPv6 (dual-stack)').click(); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Select a plan and add nodes + cy.findByText(clusterPlans[0].tab).should('be.visible').click(); + cy.findByText(clusterPlans[0].planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${clusterPlans[0].nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Try to submit the form + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + ui.button.findByTitle('Create Cluster').click(); + }); + + // Confirm error messages display + cy.wait('@createClusterError'); + cy.findByText('There is an error configuring this VPC.').should( + 'be.visible' + ); + cy.findByText('There is an error configuring this subnet.').should( + 'be.visible' + ); + }); + + /* + * Surfaces client-side validation error for VPC selection. + */ + it('surfaces a client-side validation error when BYO VPC is selected but no VPC is chosen', () => { + mockGetVPCs(mockVpcs).as('getVPCs'); + const errorText = + 'You must either select a VPC or select automatic VPC generation.'; + + cy.findByLabelText('Cluster Label').type(clusterLabel); + cy.findByText('LKE Enterprise').click(); + cy.wait(['@getLKEEnterpriseClusterTypes', '@getRegions']); + + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + cy.wait('@getVPCs'); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Select a plan and add nodes + cy.findByText(clusterPlans[0].tab).should('be.visible').click(); + cy.findByText(clusterPlans[0].planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${clusterPlans[0].nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Select the 'bring your own' VPC option + cy.findByLabelText('Use an existing VPC').click(); + + // Try to create the cluster without actually selecting a VPC + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + ui.button.findByTitle('Create Cluster').click(); + }); + + // Confirm error surfaces on the VPC field + cy.findByText(errorText).should('be.visible'); + + // Confirm switching to an autogenerated VPC clears the error + cy.findByLabelText( + 'Automatically generate a VPC for this cluster' + ).click(); + cy.findByText(errorText).should('not.exist'); + + // Confirm the error stays cleared when switching back to the existing VPC option + cy.findByLabelText('Use an existing VPC').click(); + cy.findByText(errorText).should('not.exist'); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts new file mode 100644 index 00000000000..01cc1e7ab25 --- /dev/null +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts @@ -0,0 +1,279 @@ +/** + * Tests basic functionality for LKE-E feature-flagged work. + * TODO: M3-10365 - Add `postLa` smoke tests to this file. + * TODO: M3-8838 - Delete this spec file once LKE-E is released to GA. + */ + +import { regionFactory } from '@linode/utilities'; +import { + accountFactory, + kubernetesClusterFactory, + nodePoolFactory, + subnetFactory, + vpcFactory, +} from '@src/factories'; +import { + latestEnterpriseTierKubernetesVersion, + minimumNodeNotice, +} from 'support/constants/lke'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockCreateCluster, + mockGetCluster, + mockGetClusterPools, + mockGetTieredKubernetesVersions, +} from 'support/intercepts/lke'; +import { mockGetClusters } from 'support/intercepts/lke'; +import {} from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetVPC } from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { addNodes } from 'support/util/lke'; +import { randomLabel } from 'support/util/random'; + +const mockCluster = kubernetesClusterFactory.build({ + id: 1, + vpc_id: 123, + label: randomLabel(), + tier: 'enterprise', +}); + +const mockVPC = vpcFactory.build({ + id: 123, + label: 'lke-e-vpc', + subnets: [subnetFactory.build()], +}); + +const mockNodePools = [nodePoolFactory.build()]; + +// Mock a valid region for LKE-E to avoid test flake. +const mockRegions = [ + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise', 'VPCs'], + id: 'us-iad', + label: 'Washington, DC', + }), +]; + +/** + * - Confirms VPC and IP Stack selections are shown with `phase2Mtc` feature flag is enabled. + * - Confirms VPC and IP Stack selections are not shown in create flow with `phase2Mtc` feature flag is disabled. + */ +describe('LKE-E Cluster Create', () => { + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + }); + + it('Simple Page Check - Phase 2 MTC Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: true, + }, + }).as('getFeatureFlags'); + + mockCreateCluster(mockCluster).as('createCluster'); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetRegions(mockRegions); + + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); + + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); + + cy.findByText('LKE Enterprise').click(); + + ui.regionSelect.find().click().type(`${mockRegions[0].label}`); + ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); + + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .click(); + + // Confirms LKE-E Phase 2 IP Stack and VPC options display with the flag ON. + cy.findByText('IP Stack').should('be.visible'); + cy.findByText('IPv4', { exact: true }).should('be.visible'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible'); + cy.findByText('Automatically generate a VPC for this cluster').should( + 'be.visible' + ); + cy.findByText('Use an existing VPC').should('be.visible'); + + cy.findByText('Shared CPU').should('be.visible').click(); + addNodes('Linode 2 GB'); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText('Linode 2 GB Plan').should('be.visible'); + cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); + + it('Simple Page Check - Phase 2 MTC Flag OFF', () => { + mockAppendFeatureFlags({ + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: false, + }, + }).as('getFeatureFlags'); + + mockCreateCluster(mockCluster).as('createCluster'); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetRegions(mockRegions); + + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); + + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); + + cy.findByText('LKE Enterprise').click(); + + ui.regionSelect.find().click().type(`${mockRegions[0].label}`); + ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); + + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .click(); + + // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with the flag OFF. + cy.findByText('IP Stack').should('not.exist'); + cy.findByText('IPv4', { exact: true }).should('not.exist'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); + cy.findByText('Automatically generate a VPC for this cluster').should( + 'not.exist' + ); + cy.findByText('Use an existing VPC').should('not.exist'); + + cy.findByText('Shared CPU').should('be.visible').click(); + addNodes('Linode 2 GB'); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText('Linode 2 GB Plan').should('be.visible'); + cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); +}); + +/** + * - Confirms cluster shows linked VPC and Node Pool VPC IP columns when `phase2Mtc` flag is enabled. + * - Confirms cluster's linked VPC and Node Pool VPC IP columns are hidden when `phase2Mtc` flag is disabled. + */ +describe('LKE-E Cluster Read', () => { + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + }); + + it('Simple Page Check - Phase 2 MTC Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }).as('getFeatureFlags'); + + mockGetClusters([mockCluster]).as('getClusters'); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockGetVPC(mockVPC).as('getVPC'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getVPC', '@getNodePools']); + + // Confirm linked VPC is present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('exist'); + cy.findByTestId('assigned-lke-cluster-label').should( + 'contain.text', + mockVPC.label + ); + }); + + // Confirm VPC IP columns are present in the node table header + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('be.visible'); + cy.contains('th', 'VPC IPv6').should('be.visible'); + }); + }); + + it('Simple Page Check - Phase 2 MTC Flag OFF', () => { + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: false }, + }).as('getFeatureFlags'); + + mockGetClusters([mockCluster]).as('getClusters'); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools']); + + // Confirm linked VPC is not present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('not.exist'); + cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + }); + + // Confirm VPC IP columns are not present in the node table header + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('not.exist'); + cy.contains('th', 'VPC IPv6').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts similarity index 76% rename from packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts rename to packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts index ad0d420eeaa..2dc7c4c1cd3 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts @@ -1,5 +1,10 @@ +/** + * Tests basic functionality for standard LKE creation. + */ + import { grantsFactory, profileFactory } from '@linode/utilities'; import { accountUserFactory, kubernetesClusterFactory } from '@src/factories'; +import { minimumNodeNotice } from 'support/constants/lke'; import { mockGetUser } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateCluster } from 'support/intercepts/lke'; @@ -8,56 +13,10 @@ import { mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; +import { addNodes } from 'support/util/lke'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -/** - * Performs a click operation on Cypress subject a given number of times. - * - * @param subject - Cypress subject to click. - * @param count - Number of times to perform click. - * - * @returns Cypress chainable. - */ -const multipleClick = ( - subject: Cypress.Chainable, - count: number -): Cypress.Chainable => { - if (count == 1) { - return subject.click(); - } - return multipleClick(subject.click(), count - 1); -}; - -/** - * Adds a random-sized node pool of the given plan. - * - * @param plan Name of plan for which to add nodes. - */ -const addNodes = (plan: string) => { - const defaultNodes = 3; - const extraNodes = randomNumber(1, 5); - - cy.get(`[data-qa-plan-row="${plan}"`).within(() => { - multipleClick(cy.get('[data-testid="increment-button"]'), extraNodes); - multipleClick(cy.get('[data-testid="decrement-button"]'), extraNodes + 1); - - cy.get('[data-testid="textfield-input"]') - .invoke('val') - .should('eq', `${defaultNodes - 1}`); - - ui.button - .findByTitle('Add') - .should('be.visible') - .should('be.enabled') - .click(); - }); -}; - -// Warning that's shown when recommended minimum number of nodes is not met. -const minimumNodeNotice = - 'We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.'; - describe('LKE Create Cluster', () => { beforeEach(() => { // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out @@ -78,7 +37,12 @@ describe('LKE Create Cluster', () => { cy.findByLabelText('Cluster Label').click(); cy.focused().type(mockCluster.label); - ui.regionSelect.find().click().type(`${chooseRegion().label}{enter}`); + const lkeRegion = chooseRegion({ + capabilities: ['Kubernetes'], + }); + + ui.regionSelect.find().click().type(`${lkeRegion.label}`); + ui.regionSelect.findItemByRegionId(lkeRegion.id).click(); cy.findByLabelText('Kubernetes Version').should('be.visible').click(); cy.findByText('1.32').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts index 0bbe2c41235..d2b7e3267a4 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts @@ -2,8 +2,8 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockGetAlertDefinition } from 'support/intercepts/cloudpulse'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { + interceptUpdateLinode, mockGetLinodeDetails, - mockUpdateLinode, } from 'support/intercepts/linodes'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; @@ -119,7 +119,7 @@ describe('region enables alerts', function () { ); }); - it('Legacy alerts = 0, Beta alerts = [] => legacy disabled', function () { + xit('Legacy alerts = 0, Beta alerts = [] => legacy disabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -180,7 +180,7 @@ describe('region enables alerts', function () { }); }); - it('Legacy alerts > 0, Beta alerts = [] => legacy enabled. can upgrade to beta enabled', function () { + xit('Legacy alerts > 0, Beta alerts = [] => legacy enabled. can upgrade to beta enabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -234,7 +234,7 @@ describe('region enables alerts', function () { // toggles in table are on but can be turned off assertLinodeAlertsEnabled(this.alertDefinitions); - mockUpdateLinode(mockLinode.id).as('updateLinode'); + interceptUpdateLinode(mockLinode.id).as('updateLinode'); ui.button .findByTitle('Save') .should('be.visible') @@ -243,7 +243,7 @@ describe('region enables alerts', function () { }); }); - it('Legacy alerts = 0, Beta alerts > 0, => beta enabled', function () { + xit('Legacy alerts = 0, Beta alerts > 0, => beta enabled', function () { const mockLinode = linodeFactory.build({ id: 2, label: randomLabel(), @@ -310,7 +310,7 @@ describe('region enables alerts', function () { }); }); - it('Legacy alerts > 0, Beta alerts > 0, => beta enabled. can downgrade to legacy enabled', function () { + xit('Legacy alerts > 0, Beta alerts > 0, => beta enabled. can downgrade to legacy enabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -366,7 +366,7 @@ describe('region enables alerts', function () { .click({ multiple: true }); ui.toggle.find().should('have.attr', 'data-qa-toggle', 'false'); - mockUpdateLinode(mockLinode.id).as('updateLinode'); + interceptUpdateLinode(mockLinode.id).as('updateLinode'); cy.scrollTo('bottom'); // save changes ui.button @@ -383,7 +383,7 @@ describe('region enables alerts', function () { }); }); - it('in default beta mode, edits to beta alerts do not trigger confirmation modal', function () { + xit('in default beta mode, edits to beta alerts do not trigger confirmation modal', function () { const mockLinode = linodeFactory.build({ id: 2, label: randomLabel(), @@ -411,7 +411,7 @@ describe('region enables alerts', function () { // toggles in table are on but can be turned off assertLinodeAlertsEnabled(this.alertDefinitions); - mockUpdateLinode(mockLinode.id).as('updateLinode'); + interceptUpdateLinode(mockLinode.id).as('updateLinode'); ui.button .findByTitle('Save') .should('be.visible') @@ -422,7 +422,7 @@ describe('region enables alerts', function () { ui.dialog.find().should('not.exist'); }); - it('in default legacy mode, edits to beta alerts trigger confirmation modal ', function () { + xit('in default legacy mode, edits to beta alerts trigger confirmation modal ', function () { const mockLinode = linodeFactory.build({ id: 2, label: randomLabel(), @@ -492,7 +492,7 @@ describe('region enables alerts', function () { }); }); - mockUpdateLinode(mockLinode.id).as('updateLinode'); + interceptUpdateLinode(mockLinode.id).as('updateLinode'); ui.button .findByTitle('Save') .should('be.visible') @@ -536,7 +536,7 @@ describe('region disables alerts. beta alerts not available regardless of linode mockGetRegions([mockDisabledRegion]).as('getRegions'); }); - it('Legacy alerts = 0, Beta alerts > 0, => legacy disabled', function () { + xit('Legacy alerts = 0, Beta alerts > 0, => legacy disabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -572,7 +572,7 @@ describe('region disables alerts. beta alerts not available regardless of linode }); }); - it('Legacy alerts > 0, Beta alerts = 0, => legacy enabled', function () { + xit('Legacy alerts > 0, Beta alerts = 0, => legacy enabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -604,4 +604,57 @@ describe('region disables alerts. beta alerts not available regardless of linode }); }); }); + + it('Deleting entire value in numeric input triggers validation error', function () { + const mockLinode = linodeFactory.build({ + id: MOCK_LINODE_ID, + label: randomLabel(), + region: this.mockDisabledRegion.id, + alerts: { ...mockEnabledLegacyAlerts }, + }); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/alerts`); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinode']); + ui.tabList.findTabByTitle('Alerts').within(() => { + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + const strNumericInputSelector = 'input[data-testid="textfield-input"]'; + // each data-qa-alerts-panel contains a toggle button and a numeric input + cy.get('[data-qa-alerts-panel="true"]').each((panel) => { + cy.wrap(panel).within(() => { + // toggle button is enabled + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible') + .should('be.enabled'); + cy.get('label[data-qa-alert]') + .invoke('attr', 'data-qa-alert') + .then((lbl) => { + cy.get(strNumericInputSelector).clear(); + cy.get(strNumericInputSelector).blur(); + // error appears in numeric input + cy.get('p[data-qa-textfield-error-text]') + .should('be.visible') + .then(($err) => { + // use the toggle button's label to get the full error msg + expect($err).to.contain(`${lbl} is required.`); + }); + // toggle button is not disabled by the error + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.enabled'); + cy.get(strNumericInputSelector).click(); + cy.get(strNumericInputSelector).type('1'); + // error is removed + cy.get('p[data-qa-textfield-error-text]').should('not.exist'); + }); + }); + }); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-vm-host-maintenance.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-vm-host-maintenance.spec.ts new file mode 100644 index 00000000000..7f0e8989401 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-vm-host-maintenance.spec.ts @@ -0,0 +1,144 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; +import { mockGetAccountSettings } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomString } from 'support/util/random'; + +import { accountSettingsFactory } from 'src/factories'; +const mockEnabledRegion = regionFactory.build({ + capabilities: ['Linodes', 'Maintenance Policy'], +}); +const mockDisabledRegion = regionFactory.build({ + capabilities: ['Linodes'], +}); + +describe('vmHostMaintenance feature flag', () => { + beforeEach(() => { + mockGetAccountSettings( + accountSettingsFactory.build({ + maintenance_policy: 'linode/power_off_on', + }) + ).as('getAccountSettings'); + mockGetRegions([mockEnabledRegion, mockDisabledRegion]).as('getRegions'); + }); + + it('Create flow when vmHostMaintenance feature flag is enabled', () => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: true, + }, + }).as('getFeatureFlags'); + const mockLinode = linodeFactory.build({ + label: randomLabel(), + region: mockEnabledRegion.id, + }); + mockCreateLinode(mockLinode).as('createLinode'); + + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getAccountSettings', '@getFeatureFlags', '@getRegions']); + + // "Host Maintenance Policy" section is present under the "Additional Options" + cy.contains('Additional Options').should('be.visible'); + cy.get('[data-qa-panel="Host Maintenance Policy"]') + .should('be.visible') + .within(() => { + cy.get('[data-qa-panel-summary="Host Maintenance Policy"]').click(); + }); + ui.autocomplete + .findByLabel('Maintenance Policy') + .should('be.visible') + .should('be.disabled'); + cy.findByText('Select a region to choose a maintenance policy.').should( + 'be.visible' + ); + // user selects region that does not have the "Maintenance Policy" capability + ui.regionSelect.find().click(); + ui.regionSelect.find().type(`${mockDisabledRegion.label}{enter}`); + ui.autocomplete + .findByLabel('Maintenance Policy') + .should('be.visible') + .should('be.disabled'); + cy.findByText( + 'Maintenance policy is not available in the selected region.' + ).should('be.visible'); + + // user selects region that does have the "Maintenance Policy" capability + ui.regionSelect.find().click(); + ui.regionSelect.find().clear(); + ui.regionSelect.find().type(`${mockEnabledRegion.label}{enter}`); + ui.autocomplete + .findByLabel('Maintenance Policy') + .should('be.visible') + .should('be.enabled'); + + // form prerequisites + cy.get('[type="password"]').should('be.visible').scrollIntoView(); + cy.get('[id="root-password"]').type(randomString(12)); + const mockPlan = { + planType: 'Shared CPU', + planLabel: 'Nanode 1 GB', + }; + linodeCreatePage.selectPlan(mockPlan.planType, mockPlan.planLabel); + cy.scrollTo('bottom'); + ui.button + .findByTitle('View Code Snippets') + .should('be.visible') + .should('be.enabled') + .click(); + + // maintenance policy is included in the code snippets + ui.dialog + .findByTitle('Create Linode') + .should('be.visible') + .within(() => { + cy.get('pre code') + .should('be.visible') + .within(() => { + cy.contains('--maintenance_policy linode/migrate'); + }); + // cURL tab + ui.tabList.findTabByTitle('cURL').should('be.visible').click(); + cy.get('pre code') + .should('be.visible') + .within(() => { + cy.contains('"maintenance_policy": "linode/migrate"'); + }); + ui.button + .findByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // submit + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + // POST payload should include maintenance_policy + cy.wait('@createLinode').then((intercept) => { + expect(intercept.request.body['maintenance_policy']).to.eq( + 'linode/migrate' + ); + }); + }); + + it('Create flow when vmHostMaintenance feature flag is disabled', () => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: false, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getAccountSettings', '@getFeatureFlags', '@getRegions']); + + ui.regionSelect.find().click(); + ui.regionSelect.find().type(`${mockEnabledRegion.label}{enter}`); + + // "Host Maintenance Policy" section is not present + cy.get('[data-qa-panel="Host Maintenance Policy"]').should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-settings-vm-host-maintenance.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-settings-vm-host-maintenance.spec.ts new file mode 100644 index 00000000000..416f1829b9f --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/linode-settings-vm-host-maintenance.spec.ts @@ -0,0 +1,188 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; +import { mockGetAccountSettings } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeDetails, + mockGetLinodeDisks, + mockUpdateLinode, + mockUpdateLinodeError, +} from 'support/intercepts/linodes'; +import { mockGetMaintenancePolicies } from 'support/intercepts/maintenance'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { randomLabel, randomNumber } from 'support/util/random'; + +import { accountSettingsFactory } from 'src/factories'; +import { maintenancePolicyFactory } from 'src/factories/maintenancePolicy'; + +import type { Disk } from '@linode/api-v4'; + +const mockEnabledRegion = regionFactory.build({ + capabilities: ['Linodes', 'Maintenance Policy'], +}); +const mockDisabledRegion = regionFactory.build({ + capabilities: ['Linodes'], +}); +const mockMaintenancePolicyMigrate = maintenancePolicyFactory.build({ + slug: 'linode/migrate', + label: 'Migrate', + type: 'linode_migrate', +}); +const mockMaintenancePolicyPowerOnOff = maintenancePolicyFactory.build({ + slug: 'linode/power_off_on', + label: 'Power Off / Power On', + type: 'linode_power_off_on', +}); +const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockEnabledRegion.id, +}); + +describe('vmHostMaintenance feature flag', () => { + beforeEach(() => { + mockGetAccountSettings( + accountSettingsFactory.build({ + maintenance_policy: 'linode/power_off_on', + }) + ).as('getAccountSettings'); + mockGetRegions([mockEnabledRegion, mockDisabledRegion]).as('getRegions'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + const mockDisk: Disk = { + created: '2020-08-21T17:26:14', + filesystem: 'ext4', + id: 44311273, + label: 'Debian 10 Disk', + size: 81408, + status: 'ready', + updated: '2020-08-21T17:26:30', + }; + mockGetLinodeDisks(mockLinode.id, [mockDisk]).as('getDisks'); + mockGetMaintenancePolicies([ + mockMaintenancePolicyMigrate, + mockMaintenancePolicyPowerOnOff, + ]).as('getMaintenancePolicies'); + // cy.wrap(mockMaintenancePolicyPowerOnOff).as('mockMaintenancePolicyPowerOnOff'); + }); + + it('VM host maintenance setting is editable when vmHostMaintenance feature flag is enabled. Mocked success.', () => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: true, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/settings`); + cy.wait([ + '@getAccountSettings', + '@getFeatureFlags', + '@getMaintenancePolicies', + '@getDisks', + ]); + mockUpdateLinode(mockLinode.id, { + ...mockLinode, + maintenance_policy: mockMaintenancePolicyPowerOnOff.slug, + }).as('updateLinode'); + + cy.contains('Host Maintenance Policy').should('be.visible'); + cy.contains('Maintenance Policy').should('be.visible'); + ui.autocomplete.findByLabel('Maintenance Policy').as('maintenanceInput'); + cy.get('@maintenanceInput') + .should('be.visible') + .should('have.value', mockMaintenancePolicyMigrate.label); + cy.get('@maintenanceInput') + .closest('form') + .within(() => { + // save button for the host maintenance setting is disabled before edits + ui.button.findByTitle('Save').should('be.disabled'); + // make edit + cy.get('@maintenanceInput').click(); + cy.focused().type(`${mockMaintenancePolicyPowerOnOff.label}`); + ui.autocompletePopper + .findByTitle(mockMaintenancePolicyPowerOnOff.label) + .should('be.visible') + .click(); + // save button is enabled after edit + ui.button.findByTitle('Save').should('be.enabled').click(); + }); + + // POST payload should include maintenance_policy + cy.wait('@updateLinode').then((intercept) => { + expect(intercept.request.body['maintenance_policy']).to.eq( + mockMaintenancePolicyPowerOnOff.slug + ); + }); + + // toast notification + ui.toast.assertMessage('Host Maintenance Policy settings updated.'); + }); + + it('VM host maintenance setting is editable when vmHostMaintenance feature flag is enabled. Mocked failure.', () => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: true, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/settings`); + cy.wait([ + '@getAccountSettings', + '@getFeatureFlags', + '@getMaintenancePolicies', + '@getDisks', + ]); + const linodeError = { + statusCode: 400, + errorMessage: 'Linode update failed', + }; + mockUpdateLinodeError( + mockLinode.id, + linodeError.errorMessage, + linodeError.statusCode + ); + + cy.contains('Host Maintenance Policy').should('be.visible'); + cy.contains('Maintenance Policy').should('be.visible'); + ui.autocomplete.findByLabel('Maintenance Policy').as('maintenanceInput'); + cy.get('@maintenanceInput') + .should('be.visible') + .should('have.value', mockMaintenancePolicyMigrate.label); + cy.get('@maintenanceInput') + .closest('form') + .within(() => { + // save button for the host maintenance setting is disabled before edits + ui.button.findByTitle('Save').should('be.disabled'); + // make edit + cy.get('@maintenanceInput').click(); + cy.focused().type(`${mockMaintenancePolicyPowerOnOff.label}`); + ui.autocompletePopper + .findByTitle(mockMaintenancePolicyPowerOnOff.label) + .should('be.visible') + .click(); + // save button is enabled after edit + ui.button.findByTitle('Save').should('be.enabled').click(); + }); + + cy.get('[data-qa-textfield-error-text="Maintenance Policy"]') + .should('be.visible') + .should('have.text', linodeError.errorMessage); + cy.get('[aria-errormessage]').should('be.visible'); + }); + + it('Maintenance policy setting is absent when feature flag is disabled', () => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: false, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/settings`); + cy.wait(['@getAccountSettings', '@getFeatureFlags', '@getDisks']); + + // "Host Maintenance Policy" section is not present + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + cy.findByText('Host Maintenance Policy').should('not.exist'); + cy.findByText('Maintenance Policy').should('not.exist'); + cy.get('[data-qa-panel="Host Maintenance Policy"]').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index 7cf8942b4af..8814b12c700 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -160,13 +160,12 @@ describe('linode storage tab', () => { }); /* - * - Confirms UI flow end-to-end when a user deletes a Linode disk. - * - Confirms that user can successfully delete a disk from a Linode. - * - Confirms that Cloud Manager UI automatically updates to reflect deleted disk. - * TODO: Disk cannot be deleted if disk_encryption is 'enabled' - * TODO: edit result of this test if/when behavior of backend is updated. uncertain what expected behavior is for this disk config + * - Confirms UI flow end-to-end when a user attempts to delete a Linode disk with encryption enabled. + * - Confirms that disk deletion fails and toast notification appears. */ - it('delete disk fails', () => { + // TODO: Disk cannot be deleted if disk_encryption is 'enabled' + // TODO: edit result of this test if/when behavior of backend is updated. uncertain what expected behavior is for this disk config + it('delete disk fails when Linode uses disk encryption', () => { const diskName = randomLabel(); cy.defer(() => createTestLinode({ @@ -214,9 +213,11 @@ describe('linode storage tab', () => { }); /* - * - Same test as above, but uses different linode config for disk_encryption + * - Confirms UI flow end-to-end when a user deletes a Linode disk. + * - Confirms that disk is deleted successfully + * - Confirms that UI updates to reflect the deleted disk. */ - it('delete disk succeeds', () => { + it('deletes a disk', () => { const diskName = randomLabel(); cy.defer(() => createTestLinode({ @@ -244,9 +245,7 @@ describe('linode storage tab', () => { deleteDisk(diskName); cy.wait('@deleteDisk').its('response.statusCode').should('eq', 200); - cy.findByText('Deleting', { exact: false }).should('be.visible'); ui.button.findByTitle('Add a Disk').should('be.enabled'); - ui.toast.assertMessage( `Disk ${diskName} on Linode ${linode.label} has been deleted.` ); diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index 1f6c3bdfa67..195357623a1 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -41,9 +41,10 @@ describe('resize linode', () => { // Click "Resize Linode". // The Resize Linode button remains disabled while the Linode is provisioning, // so we have to wait for that to complete before the button becomes enabled. + // Waiting longer (7.5 mins) for Linode to boot ui.button .findByTitle('Resize Linode') - .should('be.enabled', { timeout: LINODE_CREATE_TIMEOUT }) + .should('be.enabled', { timeout: 1.5 * LINODE_CREATE_TIMEOUT }) .click(); }); @@ -81,9 +82,10 @@ describe('resize linode', () => { // Click "Resize Linode". // The Resize Linode button remains disabled while the Linode is provisioning, // so we have to wait for that to complete before the button becomes enabled. + // Waiting longer (7.5 mins) for Linode to boot ui.button .findByTitle('Resize Linode') - .should('be.enabled', { timeout: LINODE_CREATE_TIMEOUT }) + .should('be.enabled', { timeout: 1.5 * LINODE_CREATE_TIMEOUT }) .click(); }); @@ -182,9 +184,10 @@ describe('resize linode', () => { // Click "Resize Linode". // The Resize Linode button remains disabled while the Linode is provisioning, // so we have to wait for that to complete before the button becomes enabled. + // Waiting longer (7.5 mins) for Linode to boot ui.button .findByTitle('Resize Linode') - .should('be.enabled', { timeout: LINODE_CREATE_TIMEOUT }) + .should('be.enabled', { timeout: 1.5 * LINODE_CREATE_TIMEOUT }) .click(); }); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index 6be3c0eb1b8..d02b019ee9a 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -105,7 +105,7 @@ describe('linode landing checks', () => { cy.findByTestId('menu-item-Marketplace').should('be.visible'); cy.findByTestId('menu-item-Billing').scrollIntoView(); cy.findByTestId('menu-item-Billing').should('be.visible'); - cy.findByTestId('menu-item-Settings').should('be.visible'); + cy.findByTestId('menu-item-Account Settings').should('be.visible'); cy.findByTestId('menu-item-Help & Support').should('be.visible'); }); diff --git a/packages/manager/cypress/support/constants/alert.ts b/packages/manager/cypress/support/constants/alert.ts index 2628cb1a6ff..398ff892ccf 100644 --- a/packages/manager/cypress/support/constants/alert.ts +++ b/packages/manager/cypress/support/constants/alert.ts @@ -32,10 +32,10 @@ export const severityMap: Record = { }; export const aggregationTypeMap: Record = { - avg: 'Average', + avg: 'Avg', count: 'Count', - max: 'Maximum', - min: 'Minimum', + max: 'Max', + min: 'Min', sum: 'Sum', }; diff --git a/packages/manager/cypress/support/constants/lke.ts b/packages/manager/cypress/support/constants/lke.ts index e8ad5e735f6..0f36b0e1462 100644 --- a/packages/manager/cypress/support/constants/lke.ts +++ b/packages/manager/cypress/support/constants/lke.ts @@ -121,3 +121,7 @@ export const clusterPlans: LkePlanDescription[] = [ type: 'standard', }, ]; + +// Warning that's shown when recommended minimum number of nodes is not met. +export const minimumNodeNotice = + 'We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.'; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 60c387bcccc..28516154615 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -806,6 +806,49 @@ export const mockGetLinodeStatsError = ( * * @returns Cypress chainable. */ -export const mockUpdateLinode = (linodeId: number): Cypress.Chainable => { +export const interceptUpdateLinode = ( + linodeId: number +): Cypress.Chainable => { return cy.intercept('PUT', apiMatcher(`linode/instances/${linodeId}`)); }; + +/** + * Intercepts PUT request to edit details of a linode + * + * @param linodeId - ID of Linode for intercepted request. + * @param updatedLinode - a mock linode object + * + * @returns Cypress chainable. + */ +export const mockUpdateLinode = ( + linodeId: number, + updatedLinode: Linode +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/instances/${linodeId}`), + updatedLinode + ); +}; + +/** + * Intercepts PUT request to edit details of a linode and mocks an error response. + * + * @param linodeId - ID of Linode for intercepted request. + * @param updatedLinode - a mock linode object + * @param errorMessage - Error message to be included in the mocked HTTP response. + * @param statusCode - HTTP status code for mocked error response. Default is `400`. + * + * @returns Cypress chainable. + */ +export const mockUpdateLinodeError = ( + linodeId: number, + errorMessage: string, + statusCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/instances/${linodeId}`), + makeErrorResponse(errorMessage, statusCode) + ); +}; diff --git a/packages/manager/cypress/support/plugins/junit-report.ts b/packages/manager/cypress/support/plugins/junit-report.ts index 2249babea92..9eff60f4a4d 100644 --- a/packages/manager/cypress/support/plugins/junit-report.ts +++ b/packages/manager/cypress/support/plugins/junit-report.ts @@ -27,6 +27,8 @@ const getCommonJunitConfig = ( testSuite: string, config: Cypress.PluginConfigOptions ) => { + const runnerIndex = Number(config.env['CY_TEST_SPLIT_RUN_INDEX']) || 1; + if (config.env[envVarName]) { if (!config.reporterOptions) { config.reporterOptions = {}; @@ -38,6 +40,9 @@ const getCommonJunitConfig = ( testsuitesTitle: testSuiteName, jenkinsMode: true, suiteTitleSeparatedBy: 'โ†’', + properties: { + runner_index: runnerIndex, + }, }; } return config; diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index a39dd3183e1..3ca596c5182 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -258,6 +258,6 @@ export const pages: Page[] = [ }, ], name: 'Settings', - url: `/settings`, + url: '/account-settings', }, ]; diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 174ed08ea9e..98ee8d6af74 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -108,7 +108,7 @@ export const createTestLinode = async ( let regionId = createRequestPayload?.region; if (!regionId) { - regionId = chooseRegion().id; + regionId = chooseRegion({ capabilities: ['Linodes', 'Vlans'] }).id; } const securityMethodPayload: Partial = diff --git a/packages/manager/cypress/support/util/lke.ts b/packages/manager/cypress/support/util/lke.ts index 63005b76c96..cbb17398451 100644 --- a/packages/manager/cypress/support/util/lke.ts +++ b/packages/manager/cypress/support/util/lke.ts @@ -1,4 +1,7 @@ import { sortByVersion } from '@linode/utilities'; +import { ui } from 'support/ui'; + +import { randomNumber } from './random'; /** * Returns the string of the highest semantic version. @@ -16,3 +19,46 @@ export const getLatestKubernetesVersion = (versions: string[]) => { } return latestVersion; }; + +/** + * Performs a click operation on Cypress subject a given number of times. + * + * @param subject - Cypress subject to click. + * @param count - Number of times to perform click. + * + * @returns Cypress chainable. + */ +const multipleClick = ( + subject: Cypress.Chainable, + count: number +): Cypress.Chainable => { + if (count == 1) { + return subject.click(); + } + return multipleClick(subject.click(), count - 1); +}; + +/** + * Adds a random-sized node pool of the given plan. + * + * @param plan Name of plan for which to add nodes. + */ +export const addNodes = (plan: string) => { + const defaultNodes = 3; + const extraNodes = randomNumber(1, 5); + + cy.get(`[data-qa-plan-row="${plan}"`).within(() => { + multipleClick(cy.get('[data-testid="increment-button"]'), extraNodes); + multipleClick(cy.get('[data-testid="decrement-button"]'), extraNodes + 1); + + cy.get('[data-testid="textfield-input"]') + .invoke('val') + .should('eq', `${defaultNodes - 1}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); +}; diff --git a/packages/manager/package.json b/packages/manager/package.json index ae508f7b6ca..a53d571a399 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.149.1", + "version": "1.150.0", "private": true, "type": "module", "bugs": { @@ -59,7 +59,7 @@ "he": "^1.2.0", "immer": "^9.0.6", "ipaddr.js": "^1.9.1", - "jspdf": "^3.0.1", + "jspdf": "^3.0.2", "jspdf-autotable": "^5.0.2", "launchdarkly-react-client-sdk": "3.0.10", "libphonenumber-js": "^1.10.6", @@ -189,4 +189,4 @@ "Firefox ESR", "not dead" ] -} +} \ No newline at end of file diff --git a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx index fabb0ecc050..6089a3b2677 100644 --- a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx +++ b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx @@ -28,7 +28,7 @@ interface MaintenancePolicySelectProps { hideDefaultChip?: boolean; onChange: (policy: MaintenancePolicy) => void; textFieldProps?: Partial; - value?: string; + value?: null | string; } export const MaintenancePolicySelect = ( diff --git a/packages/manager/src/components/MaskableText/MaskableTextArea.tsx b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx index abbeb85697a..5075bc68dc9 100644 --- a/packages/manager/src/components/MaskableText/MaskableTextArea.tsx +++ b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx @@ -1,6 +1,8 @@ import { Typography } from '@linode/ui'; import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; + import { Link } from '../Link'; /** @@ -8,11 +10,21 @@ import { Link } from '../Link'; * Example: Billing Contact info, rather than masking many individual fields */ export const MaskableTextAreaCopy = () => { + const { iamRbacPrimaryNavChanges } = useFlags(); return ( This data is sensitive and hidden for privacy. To unmask all sensitive data by default, go to{' '} - profile settings. + + profile settings + + . ); }; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 64e6aafb3cf..c560e1d0815 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -375,7 +375,7 @@ describe('PrimaryNav', () => { screen.getByRole('link', { name: 'Maintenance' }) ).toBeInTheDocument(); expect( - screen.getByRole('link', { name: 'Settings' }) + screen.getByRole('link', { name: 'Account Settings' }) ).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Account' })).toBeNull(); }); @@ -438,14 +438,15 @@ describe('PrimaryNav', () => { screen.queryByRole('link', { name: 'Service Transfers' }) ).toBeNull(); expect(screen.queryByRole('link', { name: 'Maintenance' })).toBeNull(); - expect(screen.queryByRole('link', { name: 'Settings' })).toBeNull(); expect( screen.queryByRole('link', { name: 'Account' }) ).toBeInTheDocument(); expect( screen.queryByRole('link', { name: 'Identity & Access' }) ).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Settings' })).toBeNull(); + expect( + screen.queryByRole('link', { name: 'Account Settings' }) + ).toBeNull(); }); }); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 9fb20619667..2a714cf4761 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -33,6 +33,7 @@ import type { PrimaryLink as PrimaryLinkType } from './PrimaryLink'; export type NavEntity = | 'Account' + | 'Account Settings' | 'Alerts' | 'Betas' | 'Billing' @@ -59,7 +60,6 @@ export type NavEntity = | 'Placement Groups' | 'Quotas' | 'Service Transfers' - | 'Settings' | 'StackScripts' | 'Users & Grants' | 'Volumes' @@ -113,11 +113,17 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); - const { data: collapsedSideNavPreference } = usePreferences( + const { + data: collapsedSideNavPreference, + error: preferencesError, + isLoading: preferencesLoading, + } = usePreferences( (preferences) => preferences?.collapsedSideNavProductFamilies ); - const collapsedAccordions = collapsedSideNavPreference ?? [1, 2, 3, 4, 5, 6]; // by default, we collapse all categories if no preference is set; + const collapsedAccordions = collapsedSideNavPreference ?? [ + 1, 2, 3, 4, 5, 6, 7, + ]; // by default, we collapse all categories if no preference is set; const { mutateAsync: updatePreferences } = useMutatePreferences(); @@ -307,8 +313,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { to: '/maintenance', }, { - display: 'Settings', - to: '/settings', + display: 'Account Settings', + to: '/account-settings', }, ], name: 'Administration', @@ -332,7 +338,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { ); const accordionClicked = (index: number) => { - let updatedCollapsedAccordions: number[] = [0, 1, 2, 3, 4, 5]; + let updatedCollapsedAccordions: number[] = [1, 2, 3, 4, 5, 6, 7]; if (collapsedAccordions.includes(index)) { updatedCollapsedAccordions = collapsedAccordions.filter( (accIndex) => accIndex !== index @@ -398,6 +404,10 @@ export const PrimaryNav = (props: PrimaryNavProps) => { // 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(() => { + if (preferencesLoading || preferencesError) { + return; + } + if (collapsedSideNavPreference) { return; } @@ -421,6 +431,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { location.search, productFamilyLinkGroups, collapsedSideNavPreference, + preferencesLoading, + preferencesError, ]); let activeProductFamily = ''; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 016fa3471c3..b89dbe71c46 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -68,7 +68,7 @@ export const RegionSelect = < }); const selectedRegion = value - ? regionOptions.find((r) => r.id === value) + ? (regionOptions.find((r) => r.id === value) ?? null) : null; const disabledRegions = regionOptions.reduce< diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index fc98211822a..ab38fcd89ea 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -62,7 +62,7 @@ export interface RegionSelectProps< /** * The ID of the selected region. */ - value: string | undefined; + value: null | string; width?: number; } diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index 3060be39440..a882a95e0c4 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -100,6 +100,7 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { {tabs.map((tab, idx) => ( diff --git a/packages/manager/src/components/TagCell.stories.tsx b/packages/manager/src/components/TagCell.stories.tsx index 65e74b84845..6aef31c30d1 100644 --- a/packages/manager/src/components/TagCell.stories.tsx +++ b/packages/manager/src/components/TagCell.stories.tsx @@ -21,6 +21,7 @@ export const PanelView: StoryObj = { = { { it('should display the tooltip if disabled and tooltipText is true', async () => { const { getByTestId } = renderWithTheme( - + ); const disabledButton = getByTestId('button'); expect(disabledButton).toBeInTheDocument(); @@ -33,7 +39,7 @@ describe('TagCell Component', () => { expect( screen.getByText( - 'You must be an unrestricted User in order to add or modify tags on Linodes.' + 'You must be an unrestricted User in order to add or modify tags on a Linode.' ) ).toBeVisible(); }); diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index d912c7e92a9..005a4b56070 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -24,6 +24,11 @@ export interface TagCellProps { */ disabled?: boolean; + /** + * Entity name to display on the tooltip when the "Add Button" is disabled. + */ + entity?: string; + /** * An optional label to display in the overflow drawer header. */ @@ -68,7 +73,7 @@ const checkOverflow = (el: HTMLElement) => { }; export const TagCell = (props: TagCellProps) => { - const { disabled, sx, tags, updateTags, view } = props; + const { disabled, sx, tags, updateTags, view, entity } = props; const [addingTag, setAddingTag] = React.useState(false); const [loading, setLoading] = React.useState(false); @@ -98,11 +103,7 @@ export const TagCell = (props: TagCellProps) => { onClick={() => setAddingTag(true)} panel={props.panel} title="Add a tag" - tooltipText={`${ - disabled - ? 'You must be an unrestricted User in order to add or modify tags on Linodes.' - : '' - }`} + tooltipText={`${disabled ? `You must be an unrestricted User in order to add or modify tags on a ${entity}.` : ''}`} > Add a tag @@ -118,7 +119,9 @@ export const TagCell = (props: TagCellProps) => { height: 40, justifyContent: view === 'panel' ? 'flex-start' : 'flex-end', marginBottom: view === 'panel' ? 4 : 0, - width: '100%', + ...(addingTag && { + flexGrow: 1, + }), }} > {view === 'panel' && !addingTag && } diff --git a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx index 7ff7ca9704c..29bb8314cee 100644 --- a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx +++ b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx @@ -5,6 +5,7 @@ import type { JSX } from 'react'; import { FormGroup } from 'src/components/FormGroup'; import { Link } from 'src/components/Link'; +import { useFlags } from 'src/hooks/useFlags'; import type { TextFieldProps, TypographyProps } from '@linode/ui'; import type { Theme } from '@mui/material'; @@ -52,6 +53,8 @@ export const TypeToConfirm = (props: TypeToConfirmProps) => { (preferences) => preferences?.type_to_confirm ?? true ); + const { iamRbacPrimaryNavChanges } = useFlags(); + /* There is an edge case where preferences?.type_to_confirm is undefined when the user has not yet set a preference as seen in /profile/settings?preferenceEditor=true. @@ -115,7 +118,17 @@ export const TypeToConfirm = (props: TypeToConfirmProps) => { sx={{ marginTop: 1 }} > To {disableOrEnable} type-to-confirm, go to the Type-to-Confirm - section of My Settings. + section of{' '} + + {iamRbacPrimaryNavChanges ? 'Preferences' : 'My Settings'} + + . ) : null} diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx index 36cad05e79e..9a4c36b1985 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx @@ -28,6 +28,7 @@ interface EntityInfo { | 'Bucket' | 'Database' | 'Domain' + | 'Image' | 'Kubernetes' | 'Linode' | 'Load Balancer' @@ -87,7 +88,7 @@ interface TypeToConfirmDialogProps { */ reversePrimaryButtonPosition?: boolean; /** Props for the secondary button */ - secondaryButtonProps?: Omit; + secondaryButtonProps?: ActionButtonsProps; } type CombinedProps = TypeToConfirmDialogProps & @@ -176,10 +177,10 @@ export const TypeToConfirmDialog = (props: CombinedProps) => { }; const cancelProps: ActionButtonsProps = { - ...secondaryButtonProps, 'data-testid': 'cancel', label: 'Cancel', onClick: () => onClose?.({}, 'escapeKeyDown'), + ...secondaryButtonProps, }; return { @@ -207,7 +208,7 @@ export const TypeToConfirmDialog = (props: CombinedProps) => { } const typeInstructions = - entity.action === 'cancellation' + entity.action === 'cancellation' && entity.type === 'AccountSetting' ? 'type your Username ' : `type the name of the ${entity.type} ${entity.subType || ''} `; diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 3331ec02c96..4a2d90d9755 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -61,6 +61,7 @@ const options: { flag: keyof Flags; label: string }[] = [ flag: 'vmHostMaintenance', label: 'VM Host Maintenance Policy', }, + { flag: 'volumeSummaryPage', label: 'Volume Summary Page' }, { flag: 'vpcIpv6', label: 'VPC IPv6' }, ]; diff --git a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx index d617bf4f69d..c6195dbb5e7 100644 --- a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx +++ b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx @@ -13,6 +13,7 @@ import { getBaselinePreset, getCustomAccountData, getCustomEventsData, + getCustomGrantsData, getCustomMaintenanceData, getCustomNotificationsData, getCustomProfileData, @@ -26,6 +27,7 @@ import { saveBaselinePreset, saveCustomAccountData, saveCustomEventsData, + saveCustomGrantsData, saveCustomMaintenanceData, saveCustomNotificationsData, saveCustomProfileData, @@ -42,6 +44,7 @@ import type { Account, AccountMaintenance, Event, + Grants, Notification, PermissionType, Profile, @@ -92,6 +95,9 @@ export const ServiceWorkerTool = () => { const [customEventsData, setCustomEventsData] = React.useState< Event[] | null | undefined >(getCustomEventsData()); + const [customGrantsData, setCustomGrantsData] = React.useState< + Grants | null | undefined + >(getCustomGrantsData()); const [customMaintenanceData, setCustomMaintenanceData] = React.useState< AccountMaintenance[] | null | undefined >(getCustomMaintenanceData()); @@ -118,6 +124,7 @@ export const ServiceWorkerTool = () => { React.useEffect(() => { const currentAccountData = getCustomAccountData(); + const currentGrantsData = getCustomGrantsData(); const currentProfileData = getCustomProfileData(); const currentUserAccountPermissionsData = getCustomUserAccountPermissionsData(); @@ -128,6 +135,8 @@ export const ServiceWorkerTool = () => { const currentNotificationsData = getCustomNotificationsData(); const hasCustomAccountChanges = JSON.stringify(currentAccountData) !== JSON.stringify(customAccountData); + const hasCustomGrantsChanges = + JSON.stringify(currentGrantsData) !== JSON.stringify(customGrantsData); const hasCustomProfileChanges = JSON.stringify(currentProfileData) !== JSON.stringify(customProfileData); const hasCustomEventsChanges = @@ -148,6 +157,7 @@ export const ServiceWorkerTool = () => { if ( hasCustomAccountChanges || + hasCustomGrantsChanges || hasCustomProfileChanges || hasCustomEventsChanges || hasCustomMaintenanceChanges || @@ -164,6 +174,7 @@ export const ServiceWorkerTool = () => { customAccountData, customEventsData, customMaintenanceData, + customGrantsData, customNotificationsData, customProfileData, customUserAccountPermissionsData, @@ -183,8 +194,13 @@ export const ServiceWorkerTool = () => { saveCustomAccountData(customAccountData); } - if (extraPresets.includes('profile:custom') && customProfileData) { - saveCustomProfileData(customProfileData); + if (extraPresets.includes('profile-grants:custom')) { + if (customProfileData) { + saveCustomProfileData(customProfileData); + } + if (customGrantsData) { + saveCustomGrantsData(customGrantsData); + } } if (extraPresets.includes('events:custom') && customEventsData) { saveCustomEventsData(customEventsData); @@ -238,6 +254,7 @@ export const ServiceWorkerTool = () => { setSeedsCountMap(getSeedsCountMap()); setPresetsCountMap(getExtraPresetsMap()); setCustomAccountData(getCustomAccountData()); + setCustomGrantsData(getCustomGrantsData()); setCustomProfileData(getCustomProfileData()); setCustomEventsData(getCustomEventsData()); setCustomMaintenanceData(getCustomMaintenanceData()); @@ -261,6 +278,7 @@ export const ServiceWorkerTool = () => { setExtraPresets([]); setPresetsCountMap({}); setCustomAccountData(null); + setCustomGrantsData(null); setCustomProfileData(null); setCustomEventsData(null); setCustomMaintenanceData(null); @@ -275,6 +293,7 @@ export const ServiceWorkerTool = () => { saveExtraPresetsMap({}); saveCustomAccountData(null); saveCustomProfileData(null); + saveCustomGrantsData(null); saveCustomEventsData(null); saveCustomMaintenanceData(null); saveCustomNotificationsData(null); @@ -492,6 +511,7 @@ export const ServiceWorkerTool = () => { { handlers={extraPresets} onCustomAccountChange={setCustomAccountData} onCustomEventsChange={setCustomEventsData} + onCustomGrantsChange={setCustomGrantsData} onCustomMaintenanceChange={setCustomMaintenanceData} onCustomNotificationsChange={setCustomNotificationsData} onCustomProfileChange={setCustomProfileData} diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx index db8e6857af8..025ebed2d24 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx @@ -9,7 +9,7 @@ import { ExtraPresetMaintenance } from './ExtraPresetMaintenance'; import { ExtraPresetNotifications } from './ExtraPresetNotifications'; import { ExtraPresetOptionCheckbox } from './ExtraPresetOptionCheckbox'; import { ExtraPresetOptionSelect } from './ExtraPresetOptionSelect'; -import { ExtraPresetProfile } from './ExtraPresetProfile'; +import { ExtraPresetProfileAndGrants } from './ExtraPresetProfileAndGrants'; import { ExtraPresetUserAccountPermissions } from './ExtraPresetUserAccountPermissions'; import { ExtraPresetUserEntityPermissions } from './ExtraPresetUserEntityPermissions'; @@ -17,6 +17,7 @@ import type { Account, AccountMaintenance, Event, + Grants, Notification, PermissionType, Profile, @@ -25,6 +26,7 @@ import type { export interface ExtraPresetOptionsProps { customAccountData?: Account | null; customEventsData?: Event[] | null; + customGrantsData?: Grants | null; customMaintenanceData?: AccountMaintenance[] | null; customNotificationsData?: Notification[] | null; customProfileData?: null | Profile; @@ -33,6 +35,7 @@ export interface ExtraPresetOptionsProps { handlers: string[]; onCustomAccountChange?: (data: Account | null | undefined) => void; onCustomEventsChange?: (data: Event[] | null | undefined) => void; + onCustomGrantsChange?: (data: Grants | null | undefined) => void; onCustomMaintenanceChange?: ( data: AccountMaintenance[] | null | undefined ) => void; @@ -59,6 +62,7 @@ export const ExtraPresetOptions = ({ customAccountData, customProfileData, customEventsData, + customGrantsData, customMaintenanceData, customNotificationsData, customUserAccountPermissionsData, @@ -67,6 +71,7 @@ export const ExtraPresetOptions = ({ onCustomAccountChange, onCustomProfileChange, onCustomEventsChange, + onCustomGrantsChange, onCustomMaintenanceChange, onCustomNotificationsChange, onCustomUserAccountPermissionsChange, @@ -120,11 +125,13 @@ export const ExtraPresetOptions = ({ onTogglePreset={onTogglePreset} /> )} - {currentGroupType === 'profile' && ( - )} diff --git a/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx b/packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx similarity index 50% rename from packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx rename to packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx index a2183e6b800..696b43cabff 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx @@ -1,44 +1,57 @@ import { Dialog } from '@linode/ui'; -import { profileFactory } from '@linode/utilities'; +import { grantsFactory, profileFactory } from '@linode/utilities'; import * as React from 'react'; import { extraMockPresets } from 'src/mocks/presets'; -import { setCustomProfileData } from 'src/mocks/presets/extra/account/customProfile'; +import { + setCustomGrantsData, + setCustomProfileData, +} from 'src/mocks/presets/extra/account/customProfileAndGrants'; -import { saveCustomProfileData } from '../utils'; +import { saveCustomGrantsData, saveCustomProfileData } from '../utils'; import { JsonTextArea } from './JsonTextArea'; -import type { Profile } from '@linode/api-v4'; +import type { Grants, Profile } from '@linode/api-v4'; -const profilePreset = extraMockPresets.find((p) => p.id === 'profile:custom'); +const profilePreset = extraMockPresets.find( + (p) => p.id === 'profile-grants:custom' +); interface ExtraPresetProfileProps { + customGrantsData: Grants | null | undefined; customProfileData: null | Profile | undefined; handlers: string[]; - onFormChange?: (data: null | Profile | undefined) => void; + onFormChangeGrants?: (data: Grants | null | undefined) => void; + onFormChangeProfile?: (data: null | Profile | undefined) => void; onTogglePreset: ( e: React.ChangeEvent, presetId: string ) => void; } -export const ExtraPresetProfile = ({ +export const ExtraPresetProfileAndGrants = ({ + customGrantsData, customProfileData, handlers, - onFormChange, + onFormChangeGrants, + onFormChangeProfile, onTogglePreset, }: ExtraPresetProfileProps) => { - const isEnabled = handlers.includes('profile:custom'); - const [formData, setFormData] = React.useState(() => ({ + const isEnabled = handlers.includes('profile-grants:custom'); + const [profileFormData, setProfileFormData] = React.useState(() => ({ ...profileFactory.build({ restricted: false, }), ...customProfileData, })); + const [grantsFormData, setGrantsFormData] = React.useState(() => ({ + ...grantsFactory.build(), + ...customGrantsData, + })); const [isEditingCustomProfile, setIsEditingCustomProfile] = React.useState(false); - const handleInputChange = ( + const handleProfileInputChange = ( e: React.ChangeEvent< HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement > @@ -52,41 +65,77 @@ export const ExtraPresetProfile = ({ ].includes(name); const newValue = isRadioToggleField ? value === 'true' : value; - const newFormData = { - ...formData, + const newProfileFormData = { + ...profileFormData, [name]: newValue, }; - setFormData(newFormData); + setProfileFormData(newProfileFormData); if (isEnabled) { - onFormChange?.(newFormData); + onFormChangeProfile?.(newProfileFormData); + } + }; + + const handleGrantsInputChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + + try { + const newGrantsFormData = { + ...grantsFormData, + [name]: value, + }; + setGrantsFormData(newGrantsFormData); + + if (isEnabled) { + onFormChangeGrants?.(newGrantsFormData); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to parse JSON from input value:', value, err); } }; const handleTogglePreset = (e: React.ChangeEvent) => { if (!e.target.checked) { saveCustomProfileData(null); + saveCustomGrantsData(null); } else { - saveCustomProfileData(formData); + saveCustomProfileData(profileFormData); + saveCustomGrantsData(grantsFormData); } - onTogglePreset(e, 'profile:custom'); + onTogglePreset(e, 'profile-grants:custom'); }; React.useEffect(() => { if (!isEnabled) { - setFormData({ + setProfileFormData({ ...profileFactory.build(), }); setCustomProfileData(null); - } else if (isEnabled && customProfileData) { - setFormData((prev) => ({ - ...prev, - ...customProfileData, - })); - setCustomProfileData(customProfileData); + setGrantsFormData({ + ...grantsFactory.build(), + }); + setCustomGrantsData(null); + } else if (isEnabled) { + if (customProfileData) { + setProfileFormData((prev) => ({ + ...prev, + ...customProfileData, + })); + setCustomProfileData(customProfileData); + } + if (customGrantsData) { + setGrantsFormData((prev) => ({ + ...prev, + ...customGrantsData, + })); + setCustomGrantsData(customGrantsData); + } } - }, [isEnabled, customProfileData]); + }, [isEnabled, customProfileData, customGrantsData]); if (!profilePreset) { return null; @@ -120,20 +169,21 @@ export const ExtraPresetProfile = ({ setIsEditingCustomProfile(false)} open={isEditingCustomProfile} - title="Edit Custom Profile" + title="Edit Custom Profile and Grants" >
setIsEditingCustomProfile(false)} > +

Profile

@@ -142,9 +192,9 @@ export const ExtraPresetProfile = ({ Email @@ -153,9 +203,9 @@ export const ExtraPresetProfile = ({ Verified Phone Number @@ -164,20 +214,20 @@ export const ExtraPresetProfile = ({ Email Notifications
@@ -207,9 +257,9 @@ export const ExtraPresetProfile = ({ Timezone @@ -218,19 +268,19 @@ export const ExtraPresetProfile = ({ Restricted
@@ -244,8 +294,8 @@ export const ExtraPresetProfile = ({ @@ -283,8 +333,8 @@ export const ExtraPresetProfile = ({ height={150} label="Referrals" name="referrals" - onChange={handleInputChange} - value={formData.referrals} + onChange={handleProfileInputChange} + value={profileFormData.referrals} /> @@ -292,8 +342,106 @@ export const ExtraPresetProfile = ({ height={80} label="Authorized Keys (one per line)" name="authorized_keys" - onChange={handleInputChange} - value={formData.authorized_keys} + onChange={handleProfileInputChange} + value={profileFormData.authorized_keys} + /> + +
+

Grants

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.test.tsx similarity index 100% rename from packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx rename to packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.test.tsx diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx new file mode 100644 index 00000000000..1a49f6e87c4 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx @@ -0,0 +1,62 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType } from '@linode/api-v4'; +import { useCreateDestinationMutation } from '@linode/queries'; +import { destinationSchema } from '@linode/validation'; +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { DestinationForm } from 'src/features/DataStream/Destinations/DestinationForm/DestinationForm'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; +import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; + +export const DestinationCreate = () => { + const { mutateAsync: createDestination } = useCreateDestinationMutation(); + const navigate = useNavigate(); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/destinations/create', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/destinations', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Create Destination', + }; + + const form = useForm({ + defaultValues: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + mode: 'onBlur', + resolver: yupResolver(destinationSchema), + }); + + const onSubmit = () => { + const payload = form.getValues(); + createDestination(payload).then(() => { + navigate({ to: '/datastream/destinations' }); + }); + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx new file mode 100644 index 00000000000..f78db66ab0c --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -0,0 +1,63 @@ +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import React from 'react'; +import { describe } from 'vitest'; + +import { destinationFactory } from 'src/factories/datastream'; +import { DestinationEdit } from 'src/features/DataStream/Destinations/DestinationForm/DestinationEdit'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const loadingTestId = 'circle-progress'; +const destinationId = 123; +const mockDestination = destinationFactory.build({ + id: destinationId, + label: `Destination ${destinationId}`, +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ destinationId: 123 }), + }; +}); + +describe('DestinationEdit', () => { + const assertInputHasValue = (inputLabel: string, inputValue: string) => { + expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); + }; + + it('should render edited destination when destination fetched properly', async () => { + server.use( + http.get(`*/monitor/streams/destinations/${destinationId}`, () => { + return HttpResponse.json(mockDestination); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + + assertInputHasValue('Destination Type', 'Linode Object Storage'); + await waitFor(() => { + assertInputHasValue('Destination Name', 'Destination 123'); + }); + assertInputHasValue('Host', '3000'); + assertInputHasValue('Bucket', 'Bucket Name'); + await waitFor(() => { + assertInputHasValue('Region', 'US, Chicago, IL (us-ord)'); + }); + assertInputHasValue('Access Key ID', 'Access Id'); + assertInputHasValue('Secret Access Key', 'Access Secret'); + assertInputHasValue('Log Path Prefix', 'file'); + }); +}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx new file mode 100644 index 00000000000..21bc2480e39 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx @@ -0,0 +1,124 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType } from '@linode/api-v4'; +import { + useDestinationQuery, + useUpdateDestinationMutation, +} from '@linode/queries'; +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import { destinationSchema } from '@linode/validation'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { DestinationForm } from 'src/features/DataStream/Destinations/DestinationForm/DestinationForm'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; +import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; + +export const DestinationEdit = () => { + const navigate = useNavigate(); + const { destinationId } = useParams({ + from: '/datastream/destinations/$destinationId/edit', + }); + const { mutateAsync: updateDestination } = useUpdateDestinationMutation(); + const { + data: destination, + isLoading, + error, + } = useDestinationQuery(Number(destinationId)); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/destinations/edit', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/destinations', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Edit Destination', + }; + + const form = useForm({ + defaultValues: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + mode: 'onBlur', + resolver: yupResolver(destinationSchema), + }); + + useEffect(() => { + if (destination) { + form.reset({ + ...destination, + }); + } + }, [destination, form]); + + const onSubmit = () => { + const payload = { + id: destinationId, + ...form.getValues(), + }; + + updateDestination(payload) + .then(() => { + navigate({ to: '/datastream/destinations' }); + return enqueueSnackbar( + `Destination ${payload.label} edited successfully`, + { + variant: 'success', + } + ); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue editing your destination` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + return ( + <> + + + {isLoading && ( + + + + )} + {error && ( + + )} + {!isLoading && !error && ( + + + + )} + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx new file mode 100644 index 00000000000..c1288dc2c06 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx @@ -0,0 +1,101 @@ +import { destinationType } from '@linode/api-v4'; +import { Autocomplete, Box, Button, Paper, TextField } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; +import { Controller, useWatch } from 'react-hook-form'; + +import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; +import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; +import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; + +import type { + DestinationFormType, + FormMode, +} from 'src/features/DataStream/Shared/types'; + +type DestinationFormProps = { + destinationId?: string; + mode: FormMode; + onSubmit: SubmitHandler; +}; + +export const DestinationForm = (props: DestinationFormProps) => { + const { mode, onSubmit, destinationId } = props; + const theme = useTheme(); + + const { control, handleSubmit } = useFormContext(); + + const selectedDestinationType = useWatch({ + control, + name: 'type', + }); + + return ( + <> + +
+ {destinationId && ( + + )} + ( + { + field.onChange(value); + }} + options={destinationTypeOptions} + value={getDestinationTypeOption(field.value)} + /> + )} + rules={{ required: true }} + /> + ( + { + field.onChange(value); + }} + placeholder="Destination Name..." + value={field.value} + /> + )} + rules={{ required: true }} + /> + {selectedDestinationType === destinationType.LinodeObjectStorage && ( + + )} + +
+ + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute.ts b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts similarity index 84% rename from packages/manager/src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute.ts rename to packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts index 7f6ca4650f1..0f044abab07 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute.ts +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts @@ -1,6 +1,6 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { DestinationCreate } from 'src/features/DataStream/Destinations/DestinationCreate/DestinationCreate'; +import { DestinationCreate } from 'src/features/DataStream/Destinations/DestinationForm/DestinationCreate'; export const destinationCreateLazyRoute = createLazyRoute( '/datastream/destinations/create' diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts new file mode 100644 index 00000000000..6d1ec02cf30 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { DestinationEdit } from 'src/features/DataStream/Destinations/DestinationForm/DestinationEdit'; + +export const destinationEditLazyRoute = createLazyRoute( + '/datastream/destinations/$destinationId/edit' +)({ + component: DestinationEdit, +}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx index da462042c57..3f4c0f1ecfc 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx @@ -5,16 +5,18 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; +import { DestinationActionMenu } from 'src/features/DataStream/Destinations/DestinationActionMenu'; +import type { DestinationHandlers } from './DestinationActionMenu'; import type { Destination } from '@linode/api-v4'; -interface DestinationTableRowProps { +interface DestinationTableRowProps extends DestinationHandlers { destination: Destination; } export const DestinationTableRow = React.memo( (props: DestinationTableRowProps) => { - const { destination } = props; + const { destination, onDelete, onEdit } = props; return ( @@ -31,6 +33,13 @@ export const DestinationTableRow = React.memo( + + + ); } diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx index b7e78477b20..1d16ac50963 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx @@ -1,48 +1,81 @@ -import { waitForElementToBeRemoved } from '@testing-library/react'; +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { beforeEach, describe, expect } from 'vitest'; import { destinationFactory } from 'src/factories/datastream'; import { DestinationsLanding } from 'src/features/DataStream/Destinations/DestinationsLanding'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => vi.fn()), + useDeleteDestinationMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDeleteDestinationMutation: queryMocks.useDeleteDestinationMutation, + }; +}); + +const destination = destinationFactory.build({ id: 1 }); +const destinations = [destination, ...destinationFactory.buildList(30)]; + describe('Destinations Landing Table', () => { + const renderComponentAndWaitForLoadingComplete = async () => { + renderWithTheme(, { + initialRoute: '/datastream/destinations', + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + }; + + beforeEach(() => { + mockMatchMedia(); + }); + it('should render destinations landing tab header and table with items PaginationFooter', async () => { server.use( http.get('*/monitor/streams/destinations', () => { - return HttpResponse.json( - makeResourcePage(destinationFactory.buildList(30)) - ); + return HttpResponse.json(makeResourcePage(destinations)); }) ); - - const { getByText, queryByTestId, getAllByTestId, getByPlaceholderText } = - renderWithTheme(, { - initialRoute: '/datastream/destinations', - }); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await renderComponentAndWaitForLoadingComplete(); // search text input - getByPlaceholderText('Search for a Destination'); + screen.getByPlaceholderText('Search for a Destination'); // button - getByText('Create Destination'); + screen.getByText('Create Destination'); // Table column headers - getByText('Name'); - getByText('Type'); - getByText('ID'); - getByText('Last Modified'); + screen.getByText('Name'); + screen.getByText('Type'); + screen.getByText('ID'); + screen.getByText('Creation Time'); + screen.getByText('Last Modified'); // PaginationFooter - const paginationFooterSelectPageSizeInput = getAllByTestId( + const paginationFooterSelectPageSizeInput = screen.getAllByTestId( 'textfield-input' )[1] as HTMLInputElement; expect(paginationFooterSelectPageSizeInput.value).toBe('Show 25'); @@ -55,18 +88,64 @@ describe('Destinations Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme( - , - { - initialRoute: '/datastream/destinations', - } + await renderComponentAndWaitForLoadingComplete(); + + screen.getByText((text) => + text.includes('Create a destination for cloud logs') ); + }); - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const clickOnActionMenu = async () => { + const actionMenu = screen.getByLabelText( + `Action menu for Destination ${destination.label}` + ); + await userEvent.click(actionMenu); + }; + + const clickOnActionMenuItem = async (itemText: string) => { + await userEvent.click(screen.getByText(itemText)); + }; + + describe('given action menu', () => { + beforeEach(() => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(destinations)); + }) + ); + }); - getByText((text) => text.includes('Create a destination for cloud logs')); + describe('when Edit clicked', () => { + it('should navigate to edit page', async () => { + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + await renderComponentAndWaitForLoadingComplete(); + + await clickOnActionMenu(); + await clickOnActionMenuItem('Edit'); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/datastream/destinations/1/edit', + }); + }); + }); + + describe('when Delete clicked', () => { + it('should delete destination', async () => { + const mockDeleteDestinationMutation = vi.fn().mockResolvedValue({}); + queryMocks.useDeleteDestinationMutation.mockReturnValue({ + mutateAsync: mockDeleteDestinationMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + expect(mockDeleteDestinationMutation).toHaveBeenCalledWith({ + id: 1, + }); + }); + }); }); }); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx index f329d54c893..48ff5497690 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx @@ -1,11 +1,16 @@ -import { useDestinationsQuery } from '@linode/queries'; +import { + useDeleteDestinationMutation, + useDestinationsQuery, +} from '@linode/queries'; import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; import { TableBody, TableHead, TableRow } from '@mui/material'; import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { TableCell } from 'src/components/TableCell'; import { TableSortCell } from 'src/components/TableSortCell'; import { DESTINATIONS_TABLE_DEFAULT_ORDER, @@ -17,9 +22,14 @@ import { DestinationTableRow } from 'src/features/DataStream/Destinations/Destin import { DataStreamTabHeader } from 'src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { Destination } from '@linode/api-v4'; +import type { DestinationHandlers } from 'src/features/DataStream/Destinations/DestinationActionMenu'; export const DestinationsLanding = () => { const navigate = useNavigate(); + const { mutateAsync: deleteDestination } = useDeleteDestinationMutation(); const destinationsUrl = '/datastream/destinations'; const search = useSearch({ from: destinationsUrl, @@ -93,6 +103,37 @@ export const DestinationsLanding = () => { ); } + const handleEdit = ({ id }: Destination) => { + navigate({ to: `/datastream/destinations/${id}/edit` }); + }; + + const handleDelete = ({ id, label }: Destination) => { + deleteDestination({ + id, + }) + .then(() => { + return enqueueSnackbar(`Destination ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue deleting your destination` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handlers: DestinationHandlers = { + onEdit: handleEdit, + onDelete: handleDelete, + }; + return ( <> { > Last Modified + @@ -155,6 +197,7 @@ export const DestinationsLanding = () => { ))} diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx b/packages/manager/src/features/DataStream/Shared/LabelValue.tsx similarity index 52% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx rename to packages/manager/src/features/DataStream/Shared/LabelValue.tsx index 5e4fb9a05a6..ddb57224006 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx +++ b/packages/manager/src/features/DataStream/Shared/LabelValue.tsx @@ -2,30 +2,37 @@ import { Box, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -type DestinationDetailProps = { +type LabelValueProps = { + compact?: boolean; + 'data-testid'?: string; label: string; value: string; }; -export const DestinationDetail = (props: DestinationDetailProps) => { - const { label, value } = props; +export const LabelValue = (props: LabelValueProps) => { + const { compact = false, label, value, 'data-testid': dataTestId } = props; const theme = useTheme(); return ( - - {label}: - {value} + + + {label}: + + {value} ); }; -const StyledLabel = styled(Typography, { - label: 'StyledLabel', -})(({ theme }) => ({ - font: theme.font.bold, - width: 160, -})); - const StyledValue = styled(Box, { label: 'StyledValue', })(({ theme }) => ({ diff --git a/packages/manager/src/features/DataStream/Shared/types.ts b/packages/manager/src/features/DataStream/Shared/types.ts index 57d8fb68233..cd5392286bd 100644 --- a/packages/manager/src/features/DataStream/Shared/types.ts +++ b/packages/manager/src/features/DataStream/Shared/types.ts @@ -1,18 +1,18 @@ -import { destinationType, streamStatus } from '@linode/api-v4'; +import { destinationType, streamStatus, streamType } from '@linode/api-v4'; -import type { CreateDestinationPayload } from '@linode/api-v4'; +import type { + CreateDestinationPayload, + UpdateDestinationPayload, +} from '@linode/api-v4'; -export interface DestinationTypeOption { - label: string; - value: string; -} +export type FormMode = 'create' | 'edit'; export interface LabelValueOption { label: string; value: string; } -export const destinationTypeOptions: DestinationTypeOption[] = [ +export const destinationTypeOptions: LabelValueOption[] = [ { value: destinationType.CustomHttps, label: 'Custom HTTPS', @@ -23,7 +23,18 @@ export const destinationTypeOptions: DestinationTypeOption[] = [ }, ]; -export const streamStatusOptions = [ +export const streamTypeOptions: LabelValueOption[] = [ + { + value: streamType.AuditLogs, + label: 'Audit Logs', + }, + { + value: streamType.LKEAuditLogs, + label: 'Kubernetes Audit Logs', + }, +]; + +export const streamStatusOptions: LabelValueOption[] = [ { value: streamStatus.Active, label: 'Enabled', @@ -34,4 +45,6 @@ export const streamStatusOptions = [ }, ]; -export type CreateDestinationForm = CreateDestinationPayload; +export type DestinationFormType = + | CreateDestinationPayload + | UpdateDestinationPayload; diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx new file mode 100644 index 00000000000..806aa9549e0 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx @@ -0,0 +1,52 @@ +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import * as React from 'react'; + +import { streamFactory } from 'src/factories/datastream'; +import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import type { StreamStatus } from '@linode/api-v4'; + +const fakeHandler = vi.fn(); + +describe('StreamActionMenu', () => { + const renderComponent = (status: StreamStatus) => { + renderWithTheme( + + ); + }; + + describe('when stream is active', () => { + it('should include proper Stream actions', async () => { + renderComponent('active'); + + const actionMenuButton = screen.queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + for (const action of ['Edit', 'Disable', 'Delete']) { + expect(screen.getByText(action)).toBeVisible(); + } + }); + }); + + describe('when stream is inactive', () => { + it('should include proper Stream actions', async () => { + renderComponent('inactive'); + + const actionMenuButton = screen.queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + for (const action of ['Edit', 'Enable', 'Delete']) { + expect(screen.getByText(action)).toBeVisible(); + } + }); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx new file mode 100644 index 00000000000..a5d061c54cf --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx @@ -0,0 +1,46 @@ +import { type Stream, streamStatus } from '@linode/api-v4'; +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +export interface Handlers { + onDelete: (stream: Stream) => void; + onDisableOrEnable: (stream: Stream) => void; + onEdit: (stream: Stream) => void; +} + +interface StreamActionMenuProps extends Handlers { + stream: Stream; +} + +export const StreamActionMenu = (props: StreamActionMenuProps) => { + const { stream, onDelete, onDisableOrEnable, onEdit } = props; + + const menuActions = [ + { + onClick: () => { + onEdit(stream); + }, + title: 'Edit', + }, + { + onClick: () => { + onDisableOrEnable(stream); + }, + title: stream.status === streamStatus.Active ? 'Disable' : 'Enable', + }, + { + onClick: () => { + onDelete(stream); + }, + title: 'Delete', + }, + ]; + + return ( + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx deleted file mode 100644 index cc366229d01..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { destinationType } from '@linode/api-v4'; -import { screen } from '@testing-library/react'; -import React from 'react'; -import { describe, expect } from 'vitest'; - -import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -describe('StreamCreateSubmitBar', () => { - const createStream = () => {}; - - const renderComponent = () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - }; - - it('should render checkout bar with enabled checkout button', async () => { - renderComponent(); - const submitButton = screen.getByText('Create Stream'); - - expect(submitButton).toBeEnabled(); - }); - - it('should render Delivery summary with destination type', () => { - renderComponent(); - const deliveryTitle = screen.getByText('Delivery'); - const deliveryType = screen.getByText('Linode Object Storage'); - - expect(deliveryTitle).toBeInTheDocument(); - expect(deliveryType).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx deleted file mode 100644 index 275a4dfb64c..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useRegionsQuery } from '@linode/queries'; -import React from 'react'; - -import { DestinationDetail } from 'src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail'; - -import type { LinodeObjectStorageDetails } from '@linode/api-v4'; - -export const DestinationLinodeObjectStorageDetailsSummary = ( - props: LinodeObjectStorageDetails -) => { - const { bucket_name, host, region, path } = props; - const { data: regions } = useRegionsQuery(); - - const regionValue = regions?.find(({ id }) => id === region)?.label || region; - - return ( - <> - - - - - - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx deleted file mode 100644 index 3e689d3025a..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { destinationType, streamType } from '@linode/api-v4'; -import { useCreateStreamMutation } from '@linode/queries'; -import { omitProps, Stack } from '@linode/ui'; -import { createStreamAndDestinationFormSchema } from '@linode/validation'; -import Grid from '@mui/material/Grid'; -import { useNavigate } from '@tanstack/react-router'; -import * as React from 'react'; -import { FormProvider, useForm, useWatch } from 'react-hook-form'; - -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { LandingHeader } from 'src/components/LandingHeader'; -import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; -import { StreamCreateDelivery } from 'src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery'; -import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; - -import { StreamCreateClusters } from './StreamCreateClusters'; -import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; - -import type { CreateStreamPayload } from '@linode/api-v4'; -import type { LandingHeaderProps } from 'src/components/LandingHeader'; -import type { - CreateStreamAndDestinationForm, - CreateStreamForm, -} from 'src/features/DataStream/Streams/StreamCreate/types'; - -export const StreamCreate = () => { - const { mutateAsync: createStream } = useCreateStreamMutation(); - const navigate = useNavigate(); - - const form = useForm({ - defaultValues: { - stream: { - type: streamType.AuditLogs, - details: {}, - }, - destination: { - type: destinationType.LinodeObjectStorage, - details: { - region: '', - }, - }, - }, - mode: 'onBlur', - resolver: yupResolver(createStreamAndDestinationFormSchema), - }); - - const { control, handleSubmit } = form; - const selectedStreamType = useWatch({ - control, - name: 'stream.type', - }); - - const landingHeaderProps: LandingHeaderProps = { - breadcrumbProps: { - pathname: '/datastream/streams/create', - crumbOverrides: [ - { - label: 'DataStream', - linkTo: '/datastream/streams', - position: 1, - }, - ], - }, - removeCrumbX: 2, - title: 'Create Stream', - }; - - const onSubmit = () => { - const { - stream: { label, type, destinations, details }, - } = form.getValues(); - const payload: CreateStreamForm = { - label, - type, - destinations, - details, - }; - - if (type === streamType.LKEAuditLogs && details) { - if (details.is_auto_add_all_clusters_enabled) { - payload.details = omitProps(details, ['cluster_ids']); - } else { - payload.details = omitProps(details, [ - 'is_auto_add_all_clusters_enabled', - ]); - } - } - - createStream(payload as CreateStreamPayload).then(() => { - sendCreateStreamEvent('Stream Create Page'); - navigate({ to: '/datastream/streams' }); - }); - }; - - return ( - <> - - - -
- - - - - {selectedStreamType === streamType.LKEAuditLogs && ( - - )} - - - - - - - -
-
- - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx deleted file mode 100644 index dacb024ab50..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { streamType } from '@linode/api-v4'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; - -describe('StreamCreateGeneralInfo', () => { - it('should render Name input and allow to type text', async () => { - renderWithThemeAndHookFormContext({ - component: , - }); - - // Type test value inside the input - const nameInput = screen.getByPlaceholderText('Stream name...'); - await userEvent.type(nameInput, 'Test'); - - await waitFor(() => { - expect(nameInput.getAttribute('value')).toEqual('Test'); - }); - }); - - it('should render Stream type input and allow to select different options', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - stream: { - type: streamType.AuditLogs, - }, - }, - }, - }); - - const streamTypesAutocomplete = screen.getByRole('combobox'); - - expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); - - // Open the dropdown - await userEvent.click(streamTypesAutocomplete); - - // Select the "Kubernetes Audit Logs" option - const kubernetesAuditLogs = await screen.findByText( - 'Kubernetes Audit Logs' - ); - await userEvent.click(kubernetesAuditLogs); - - await waitFor(() => { - expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); - }); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts b/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts deleted file mode 100644 index c89de4c8bbf..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CreateStreamPayload } from '@linode/api-v4'; -import type { CreateDestinationForm } from 'src/features/DataStream/Shared/types'; - -export interface CreateStreamForm - extends Omit { - destinations: (number | undefined)[]; -} - -export interface CreateStreamAndDestinationForm { - destination: CreateDestinationForm; - stream: CreateStreamForm; -} diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts similarity index 100% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx similarity index 85% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx index 4665e078caa..7eefd4a16b1 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx @@ -5,20 +5,20 @@ import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { describe, expect } from 'vitest'; -import { StreamCreateCheckoutBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar'; -import { StreamCreateGeneralInfo } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo'; +import { StreamFormCheckoutBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar'; +import { StreamFormGeneralInfo } from 'src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo'; import { renderWithTheme, renderWithThemeAndHookFormContext, } from 'src/utilities/testHelpers'; -describe('StreamCreateCheckoutBar', () => { +describe('StreamFormCheckoutBar', () => { const getDeliveryPriceContext = () => screen.getByText(/\/unit/i).textContent; const createStream = () => {}; const renderComponent = () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -61,8 +61,8 @@ describe('StreamCreateCheckoutBar', () => { return (
- - + +
); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx similarity index 86% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx index c48bcaa3c3e..cc4dab35826 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx @@ -5,17 +5,17 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { CheckoutBar } from 'src/components/CheckoutBar/CheckoutBar'; import { displayPrice } from 'src/components/DisplayPrice'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles'; +import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; export interface Props { createStream: () => void; } -export const StreamCreateCheckoutBar = (props: Props) => { +export const StreamFormCheckoutBar = (props: Props) => { const { createStream } = props; - const { control } = useFormContext(); + const { control } = useFormContext(); const destinationType = useWatch({ control, name: 'destination.type' }); const formValues = useWatch({ control, diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx new file mode 100644 index 00000000000..df6554a590b --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx @@ -0,0 +1,60 @@ +import { destinationType, streamType } from '@linode/api-v4'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import type { FormMode } from 'src/features/DataStream/Shared/types'; + +describe('StreamFormSubmitBar', () => { + const createStream = () => {}; + + const renderComponent = (mode: FormMode) => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + }, + }); + }; + + describe('when in create mode', () => { + it('should render checkout bar with enabled Create Stream button', async () => { + renderComponent('create'); + const submitButton = screen.getByText('Create Stream'); + + expect(submitButton).toBeEnabled(); + }); + }); + + describe('when in edit mode', () => { + it('should render checkout bar with enabled Edit Stream button', async () => { + renderComponent('edit'); + const submitButton = screen.getByText('Edit Stream'); + + expect(submitButton).toBeEnabled(); + }); + }); + + it('should render Delivery summary with destination type', () => { + renderComponent('create'); + const deliveryTitle = screen.getByText('Delivery'); + const deliveryType = screen.getByText('Linode Object Storage'); + + expect(deliveryTitle).toBeInTheDocument(); + expect(deliveryType).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx similarity index 62% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx index 9c009208eb8..74d5eb83e2f 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx @@ -2,19 +2,24 @@ import { Box, Button, Divider, Paper, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles'; +import { + getDestinationTypeOption, + isFormInEditMode, +} from 'src/features/DataStream/dataStreamUtils'; +import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { FormMode } from 'src/features/DataStream/Shared/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; -type StreamCreateSidebarProps = { - createStream: () => void; +type StreamFormSubmitBarProps = { + mode: FormMode; + onSubmit: () => void; }; -export const StreamCreateSubmitBar = (props: StreamCreateSidebarProps) => { - const { createStream } = props; +export const StreamFormSubmitBar = (props: StreamFormSubmitBarProps) => { + const { onSubmit, mode } = props; - const { control } = useFormContext(); + const { control } = useFormContext(); const destinationType = useWatch({ control, name: 'destination.type' }); return ( @@ -32,7 +37,7 @@ export const StreamCreateSubmitBar = (props: StreamCreateSidebarProps) => { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx similarity index 78% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx index cff5103b0ed..b66fe12a369 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx @@ -1,5 +1,5 @@ import { regionFactory } from '@linode/utilities'; -import { screen, waitFor, within } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import React from 'react'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -33,26 +33,23 @@ describe('DestinationLinodeObjectStorageDetailsSummary', () => { ); + // Host: expect(screen.getByText('test host')).toBeVisible(); - + // Bucket: expect(screen.getByText('test bucket')).toBeVisible(); - + // Log Path: expect(screen.getByText('test/path')).toBeVisible(); - + // Region: await waitFor(() => { expect(screen.getByText('US, Chicago, IL')).toBeVisible(); }); - - expect( - within(screen.getByText('Access Key ID:').closest('div')!).getByText( - '*****************' - ) - ).toBeInTheDocument(); - - expect( - within(screen.getByText('Secret Access Key:').closest('div')!).getByText( - '*****************' - ) - ).toBeInTheDocument(); + // Access Key ID: + expect(screen.getByTestId('access-key-id')).toHaveTextContent( + '*****************' + ); + // Secret Access Key: + expect(screen.getByTestId('secret-access-key')).toHaveTextContent( + '*****************' + ); }); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx new file mode 100644 index 00000000000..f031a4997ef --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx @@ -0,0 +1,34 @@ +import { useRegionsQuery } from '@linode/queries'; +import React from 'react'; + +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; + +import type { LinodeObjectStorageDetails } from '@linode/api-v4'; + +export const DestinationLinodeObjectStorageDetailsSummary = ( + props: LinodeObjectStorageDetails +) => { + const { bucket_name, host, region, path } = props; + const { data: regions } = useRegionsQuery(); + + const regionValue = regions?.find(({ id }) => id === region)?.label || region; + + return ( + <> + + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx similarity index 96% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index 5e58470ad1a..71e0ed84dca 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -9,13 +9,13 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { StreamCreateDelivery } from './StreamCreateDelivery'; +import { StreamFormDelivery } from './StreamFormDelivery'; const loadingTestId = 'circle-progress'; const mockDestinations = destinationFactory.buildList(5); -describe('StreamCreateDelivery', () => { +describe('StreamFormDelivery', () => { beforeEach(async () => { server.use( http.get('*/monitor/streams/destinations', () => { @@ -26,7 +26,7 @@ describe('StreamCreateDelivery', () => { it('should render disabled Destination Type input with proper selection', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -50,7 +50,7 @@ describe('StreamCreateDelivery', () => { it('should render Destination Name input and allow to select an existing option', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -81,7 +81,7 @@ describe('StreamCreateDelivery', () => { const renderComponentAndAddNewDestinationName = async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx similarity index 85% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index 223a7bb8fb7..d4d7cbf4f24 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -16,13 +16,13 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; -import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary'; +import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary'; import type { DestinationType, LinodeObjectStorageDetails, } from '@linode/api-v4'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; type DestinationName = { create?: boolean; @@ -40,15 +40,12 @@ const controlPaths = { region: 'destination.details.region', }; -export const StreamCreateDelivery = () => { +export const StreamFormDelivery = () => { const theme = useTheme(); - const { control, setValue } = - useFormContext(); + const { control, setValue } = useFormContext(); const [showDestinationForm, setShowDestinationForm] = React.useState(false); - const [showExistingDestination, setShowExistingDestination] = - React.useState(false); const { data: destinations, isLoading, error } = useAllDestinationsQuery(); const destinationNameOptions: DestinationName[] = (destinations || []).map( @@ -79,7 +76,7 @@ export const StreamCreateDelivery = () => { render={({ field, fieldState }) => ( { label="Destination Name" onBlur={field.onBlur} onChange={(_, newValue) => { - const selectedExistingDestination = !!( - newValue?.label && newValue?.id + setValue( + 'stream.destinations', + newValue?.id ? [newValue?.id] : [] ); - if (selectedExistingDestination) { - setValue('stream.destinations', [newValue?.id as number]); - } field.onChange(newValue?.label || newValue); - setValue('stream.destinations', [newValue?.id as number]); setShowDestinationForm(!!newValue?.create); - setShowExistingDestination(selectedExistingDestination); }} options={destinationNameOptions.filter( ({ type }) => type === selectedDestinationType @@ -158,7 +151,7 @@ export const StreamCreateDelivery = () => { controlPaths={controlPaths} /> )} - {showExistingDestination && ( + {!!selectedDestinations?.length && ( id === selectedDestinations[0] diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx new file mode 100644 index 00000000000..c5fa4ee0007 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx @@ -0,0 +1,86 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType, streamType } from '@linode/api-v4'; +import { useCreateStreamMutation } from '@linode/queries'; +import { streamAndDestinationFormSchema } from '@linode/validation'; +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; +import { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; +import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; +import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; + +import type { CreateStreamPayload } from '@linode/api-v4'; +import type { + StreamAndDestinationFormType, + StreamFormType, +} from 'src/features/DataStream/Streams/StreamForm/types'; + +export const StreamCreate = () => { + const { mutateAsync: createStream } = useCreateStreamMutation(); + const navigate = useNavigate(); + + const form = useForm({ + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + mode: 'onBlur', + resolver: yupResolver(streamAndDestinationFormSchema), + }); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/streams/create', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/streams', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Create Stream', + }; + + const onSubmit = () => { + const { + stream: { label, type, destinations, details }, + } = form.getValues(); + const payload: StreamFormType = { + label, + type, + destinations, + details: getStreamPayloadDetails(type, details), + }; + + createStream(payload as CreateStreamPayload).then(() => { + sendCreateStreamEvent('Stream Create Page'); + navigate({ to: '/datastream/streams' }); + }); + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx new file mode 100644 index 00000000000..41699fa8205 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx @@ -0,0 +1,84 @@ +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import React from 'react'; +import { describe } from 'vitest'; + +import { destinationFactory, streamFactory } from 'src/factories/datastream'; +import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const loadingTestId = 'circle-progress'; +const streamId = 123; +const mockDestinations = [destinationFactory.build({ id: 1 })]; +const mockStream = streamFactory.build({ + id: streamId, + label: `Data Stream ${streamId}`, + destinations: mockDestinations, +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ streamId: 123 }), + }; +}); + +describe('StreamEdit', () => { + const assertInputHasValue = (inputLabel: string, inputValue: string) => { + expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); + }; + + it('should render edited stream when stream fetched properly', async () => { + server.use( + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }), + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + + await waitFor(() => { + assertInputHasValue('Name', 'Data Stream 123'); + }); + assertInputHasValue('Stream Type', 'Audit Logs'); + await waitFor(() => { + assertInputHasValue('Destination Type', 'Linode Object Storage'); + }); + assertInputHasValue('Destination Name', 'Destination 1'); + + // Host: + expect(screen.getByText('3000')).toBeVisible(); + // Bucket: + expect(screen.getByText('Bucket Name')).toBeVisible(); + // Region: + await waitFor(() => { + expect(screen.getByText('US, Chicago, IL')).toBeVisible(); + }); + // Access Key ID: + expect(screen.getByTestId('access-key-id')).toHaveTextContent( + '*****************' + ); + // Secret Access Key: + expect(screen.getByTestId('secret-access-key')).toHaveTextContent( + '*****************' + ); + // Log Path: + expect(screen.getByText('file')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx new file mode 100644 index 00000000000..9a3f6bf846e --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx @@ -0,0 +1,131 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType, streamType } from '@linode/api-v4'; +import { useStreamQuery, useUpdateStreamMutation } from '@linode/queries'; +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import { streamAndDestinationFormSchema } from '@linode/validation'; +import { useNavigate, useParams } from '@tanstack/react-router'; +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 { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; +import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; + +import type { UpdateStreamPayloadWithId } from '@linode/api-v4'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; + +export const StreamEdit = () => { + const navigate = useNavigate(); + const { streamId } = useParams({ + from: '/datastream/streams/$streamId/edit', + }); + const { mutateAsync: updateStream } = useUpdateStreamMutation(); + const { data: stream, isLoading, error } = useStreamQuery(Number(streamId)); + + const form = useForm({ + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + mode: 'onBlur', + resolver: yupResolver(streamAndDestinationFormSchema), + }); + + useEffect(() => { + if (stream) { + const details = + Object.keys(stream.details).length > 0 + ? { + is_auto_add_all_clusters_enabled: false, + cluster_ids: [], + ...stream.details, + } + : {}; + + form.reset({ + stream: { + ...stream, + details, + destinations: stream.destinations.map(({ id }) => id), + }, + destination: stream.destinations?.[0], + }); + } + }, [stream, form]); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/streams/edit', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/streams', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Edit Stream', + }; + + const onSubmit = () => { + const { + stream: { label, type, destinations, details }, + } = form.getValues(); + + // TODO: DPS-33120 create destination call if new destination created + + const payload: UpdateStreamPayloadWithId = { + id: stream!.id, + label, + type: stream!.type, + status: stream!.status, + destinations: destinations as number[], // TODO: remove type assertion after DPS-33120 + details: getStreamPayloadDetails(type, details), + }; + + updateStream(payload).then(() => { + navigate({ to: '/datastream/streams' }); + }); + }; + + return ( + <> + + + {isLoading && ( + + + + )} + {error && ( + + )} + {!isLoading && !error && ( + + + + )} + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx new file mode 100644 index 00000000000..9ec617576de --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx @@ -0,0 +1,52 @@ +import { streamType } from '@linode/api-v4'; +import { Stack } from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; +import { StreamFormDelivery } from 'src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery'; + +import { StreamFormClusters } from './StreamFormClusters'; +import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; + +import type { FormMode } from 'src/features/DataStream/Shared/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; + +type StreamFormProps = { + mode: FormMode; + onSubmit: SubmitHandler; + streamId?: string; +}; + +export const StreamForm = (props: StreamFormProps) => { + const { mode, onSubmit, streamId } = props; + + const { control, handleSubmit } = + useFormContext(); + + const selectedStreamType = useWatch({ + control, + name: 'stream.type', + }); + + return ( +
+ + + + + {selectedStreamType === streamType.LKEAuditLogs && ( + + )} + + + + + + + +
+ ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx similarity index 96% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx index 9bb723fba9e..6ed13dd08f6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx @@ -5,15 +5,18 @@ import { describe, expect, it } from 'vitest'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { StreamCreateClusters } from './StreamCreateClusters'; +import { StreamFormClusters } from './StreamFormClusters'; const renderComponentWithoutSelectedClusters = () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { - details: {}, + details: { + cluster_ids: [], + is_auto_add_all_clusters_enabled: false, + }, }, }, }, @@ -49,7 +52,7 @@ const expectCheckboxStateToBe = ( } }; -describe('StreamCreateClusters', () => { +describe('StreamFormClusters', () => { it('should render all clusters in table', async () => { renderComponentWithoutSelectedClusters(); @@ -159,12 +162,13 @@ describe('StreamCreateClusters', () => { describe('when form has already selected clusters', () => { it('should render table with properly selected clusters', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { details: { cluster_ids: [3], + is_auto_add_all_clusters_enabled: false, }, }, }, diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx similarity index 90% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx index af894c9a200..b04eb9c9753 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx @@ -1,5 +1,5 @@ import { Box, Checkbox, Notice, Paper, Typography } from '@linode/ui'; -import { usePrevious } from '@linode/utilities'; +import { isNotNullOrUndefined, usePrevious } from '@linode/utilities'; import React, { useEffect, useState } from 'react'; import type { ControllerRenderProps } from 'react-hook-form'; import { useWatch } from 'react-hook-form'; @@ -14,9 +14,9 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; -import { clusters } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData'; +import { clusters } from 'src/features/DataStream/Streams/StreamForm/StreamFormClustersData'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; // TODO: remove type after fetching the clusters will be done export type Cluster = { @@ -28,9 +28,9 @@ export type Cluster = { type OrderByKeys = 'label' | 'logGeneration' | 'region'; -export const StreamCreateClusters = () => { +export const StreamFormClusters = () => { const { control, setValue, formState } = - useFormContext(); + useFormContext(); const [order, setOrder] = useState<'asc' | 'desc'>('asc'); const [orderBy, setOrderBy] = useState('label'); @@ -40,16 +40,31 @@ export const StreamCreateClusters = () => { .filter(({ logGeneration }) => logGeneration) .map(({ id }) => id); - const isAutoAddAllClustersEnabled = useWatch({ + const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ control, - name: 'stream.details.is_auto_add_all_clusters_enabled', + name: [ + 'stream.details.is_auto_add_all_clusters_enabled', + 'stream.details.cluster_ids', + ], }); const previousIsAutoAddAllClustersEnabled = usePrevious( isAutoAddAllClustersEnabled ); useEffect(() => { - if (isAutoAddAllClustersEnabled !== previousIsAutoAddAllClustersEnabled) { + setValue( + 'stream.details.cluster_ids', + isAutoAddAllClustersEnabled + ? idsWithLogGenerationEnabled + : clusterIds || [] + ); + }, []); + + useEffect(() => { + if ( + isNotNullOrUndefined(previousIsAutoAddAllClustersEnabled) && + isAutoAddAllClustersEnabled !== previousIsAutoAddAllClustersEnabled + ) { setValue( 'stream.details.cluster_ids', isAutoAddAllClustersEnabled ? idsWithLogGenerationEnabled : [] @@ -73,7 +88,7 @@ export const StreamCreateClusters = () => { const getTableContent = ( field: ControllerRenderProps< - CreateStreamAndDestinationForm, + StreamAndDestinationFormType, 'stream.details.cluster_ids' > ) => { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts similarity index 92% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts index baaf487c7dd..246a5b189e6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts @@ -1,4 +1,4 @@ -import type { Cluster } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateClusters'; +import type { Cluster } from 'src/features/DataStream/Streams/StreamForm/StreamFormClusters'; export const clusters: Cluster[] = [ { diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx new file mode 100644 index 00000000000..aeea11d63e9 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx @@ -0,0 +1,101 @@ +import { streamType } from '@linode/api-v4'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; + +describe('StreamFormGeneralInfo', () => { + describe('when in create mode', () => { + it('should render Name input and allow to type text', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // Type test value inside the input + const nameInput = screen.getByPlaceholderText('Stream name...'); + await userEvent.type(nameInput, 'Test'); + + await waitFor(() => { + expect(nameInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render Stream type input and allow to select different options', async () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, + }, + }, + }); + + const streamTypesAutocomplete = screen.getByRole('combobox'); + + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); + + // Open the dropdown + await userEvent.click(streamTypesAutocomplete); + + // Select the "Kubernetes Audit Logs" option + const kubernetesAuditLogs = await screen.findByText( + 'Kubernetes Audit Logs' + ); + await userEvent.click(kubernetesAuditLogs); + + await waitFor(() => { + expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); + }); + }); + }); + + describe('when in edit mode and with streamId prop', () => { + const streamId = '123'; + it('should render ID', () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // ID: + expect(screen.getByText(streamId)).toBeVisible(); + }); + + it('should render Name input and allow to type text', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // Type test value inside the input + const nameInput = screen.getByPlaceholderText('Stream name...'); + await userEvent.type(nameInput, 'Test'); + + await waitFor(() => { + expect(nameInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render disabled Stream type input', async () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, + }, + }, + }); + + const streamTypesAutocomplete = screen.getByRole('combobox'); + + expect(streamTypesAutocomplete).toBeDisabled(); + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); + }); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx similarity index 65% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx index e73ec41fe91..8ade80467c4 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -3,22 +3,25 @@ import { Autocomplete, Paper, TextField, Typography } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { type CreateStreamAndDestinationForm } from './types'; +import { + getStreamTypeOption, + isFormInEditMode, +} from 'src/features/DataStream/dataStreamUtils'; +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; +import { streamTypeOptions } from 'src/features/DataStream/Shared/types'; -export const StreamCreateGeneralInfo = () => { - const { control, setValue } = - useFormContext(); +import type { StreamAndDestinationFormType } from './types'; +import type { FormMode } from 'src/features/DataStream/Shared/types'; - const streamTypeOptions = [ - { - value: streamType.AuditLogs, - label: 'Audit Logs', - }, - { - value: streamType.LKEAuditLogs, - label: 'Kubernetes Audit Logs', - }, - ]; +type StreamFormGeneralInfoProps = { + mode: FormMode; + streamId?: string; +}; + +export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { + const { mode, streamId } = props; + + const { control, setValue } = useFormContext(); const updateStreamDetails = (value: string) => { if (value === streamType.LKEAuditLogs) { @@ -31,6 +34,7 @@ export const StreamCreateGeneralInfo = () => { return ( General Information + {streamId && } { render={({ field, fieldState }) => ( { updateStreamDetails(value); }} options={streamTypeOptions} - value={streamTypeOptions.find(({ value }) => value === field.value)} + value={getStreamTypeOption(field.value)} /> )} rules={{ required: true }} diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts similarity index 90% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts index 0ec5b500ee8..eda795aea08 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts @@ -1,6 +1,6 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { StreamCreate } from 'src/features/DataStream/Streams/StreamCreate/StreamCreate'; +import { StreamCreate } from 'src/features/DataStream/Streams/StreamForm/StreamCreate'; export const streamCreateLazyRoute = createLazyRoute( '/datastream/streams/create' diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts new file mode 100644 index 00000000000..a9ad91972cb --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; + +export const streamEditLazyRoute = createLazyRoute( + '/datastream/streams/$streamId/edit' +)({ + component: StreamEdit, +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts new file mode 100644 index 00000000000..f028763eb85 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts @@ -0,0 +1,12 @@ +import type { CreateStreamPayload } from '@linode/api-v4'; +import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; + +export interface StreamFormType + extends Omit { + destinations: (number | undefined)[]; +} + +export interface StreamAndDestinationFormType { + destination: DestinationFormType; + stream: StreamFormType; +} diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx new file mode 100644 index 00000000000..025fa1636d6 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx @@ -0,0 +1,54 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { streamFactory } from 'src/factories/datastream'; +import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; +import { + mockMatchMedia, + renderWithTheme, + wrapWithTableBody, +} from 'src/utilities/testHelpers'; + +const fakeHandler = vi.fn(); + +describe('StreamTableRow', () => { + const stream = { ...streamFactory.build(), id: 1 }; + + it('should render a stream row', async () => { + mockMatchMedia(); + renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Name: + screen.getByText('Data Stream 1'); + // Stream Type: + screen.getByText('Audit Logs'); + // Status: + screen.getByText('Enabled'); + // Destination Type: + screen.getByText('Linode Object Storage'); + // ID: + screen.getByText('1'); + // Creation Time: + screen.getByText(/2025-07-30/); + + const actionMenu = screen.getByLabelText( + `Action menu for Stream ${stream.label}` + ); + await userEvent.click(actionMenu); + + expect(screen.getByText('Edit')).toBeVisible(); + expect(screen.getByText('Disable')).toBeVisible(); + expect(screen.getByText('Delete')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx b/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx index 44d010c6f59..4a5c0f0cacb 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx @@ -5,30 +5,49 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { + getDestinationTypeOption, + getStreamTypeOption, +} from 'src/features/DataStream/dataStreamUtils'; +import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +import type { Handlers as StreamHandlers } from './StreamActionMenu'; import type { Stream, StreamStatus } from '@linode/api-v4'; -interface StreamTableRowProps { +interface StreamTableRowProps extends StreamHandlers { stream: Stream; } export const StreamTableRow = React.memo((props: StreamTableRowProps) => { - const { stream } = props; + const { stream, onDelete, onDisableOrEnable, onEdit } = props; return ( {stream.label} + {getStreamTypeOption(stream.type)?.label} {humanizeStreamStatus(stream.status)} {stream.id} - {stream.destinations[0].label} + + {getDestinationTypeOption(stream.destinations[0]?.type)?.label} + + + + + + ); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx index c10cb9f70d3..e14ef8f8872 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx @@ -1,56 +1,94 @@ -import { waitForElementToBeRemoved, within } from '@testing-library/react'; +import { + screen, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { expect } from 'vitest'; +import { beforeEach, describe, expect } from 'vitest'; import { streamFactory } from 'src/factories/datastream'; import { StreamsLanding } from 'src/features/DataStream/Streams/StreamsLanding'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; -describe('Streams Landing Table', () => { - it('should render streams landing tab header and table with items PaginationFooter', async () => { - server.use( - http.get('*/monitor/streams', () => { - return HttpResponse.json(makeResourcePage(streamFactory.buildList(30))); - }) - ); +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => vi.fn()), + useUpdateStreamMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), + useDeleteStreamMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), +})); - const { - getByText, - queryByTestId, - getAllByTestId, - getByPlaceholderText, - getByLabelText, - getByRole, - } = renderWithTheme(, { +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useUpdateStreamMutation: queryMocks.useUpdateStreamMutation, + useDeleteStreamMutation: queryMocks.useDeleteStreamMutation, + }; +}); + +const stream = streamFactory.build({ id: 1 }); +const streams = [stream, ...streamFactory.buildList(30)]; + +describe('Streams Landing Table', () => { + const renderComponentAndWaitForLoadingComplete = async () => { + renderWithTheme(, { initialRoute: '/datastream/streams', }); - const loadingElement = queryByTestId(loadingTestId); + const loadingElement = screen.queryByTestId(loadingTestId); if (loadingElement) { await waitForElementToBeRemoved(loadingElement); } + }; + + beforeEach(() => { + mockMatchMedia(); + }); + + it('should render streams landing tab header and table with items PaginationFooter', async () => { + server.use( + http.get('*/monitor/streams', () => { + return HttpResponse.json(makeResourcePage(streams)); + }) + ); + + await renderComponentAndWaitForLoadingComplete(); // search text input - getByPlaceholderText('Search for a Stream'); + screen.getByPlaceholderText('Search for a Stream'); // select - getByLabelText('Status'); + screen.getByLabelText('Status'); // button - getByText('Create Stream'); + screen.getByText('Create Stream'); // Table column headers - getByText('Name'); - within(getByRole('table')).getByText('Status'); - getByText('ID'); - getByText('Destination Type'); + screen.getByText('Name'); + screen.getByText('Stream Type'); + within(screen.getByRole('table')).getByText('Status'); + screen.getByText('ID'); + screen.getByText('Destination Type'); + screen.getByText('Creation Time'); // PaginationFooter - const paginationFooterSelectPageSizeInput = getAllByTestId( + const paginationFooterSelectPageSizeInput = screen.getAllByTestId( 'textfield-input' )[2] as HTMLInputElement; expect(paginationFooterSelectPageSizeInput.value).toBe('Show 25'); @@ -63,17 +101,109 @@ describe('Streams Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(, { - initialRoute: '/datastream/streams', - }); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await renderComponentAndWaitForLoadingComplete(); - getByText((text) => + screen.getByText((text) => text.includes('Create a data stream and configure delivery of cloud logs') ); }); + + const clickOnActionMenu = async () => { + const actionMenu = screen.getByLabelText( + `Action menu for Stream ${stream.label}` + ); + await userEvent.click(actionMenu); + }; + + const clickOnActionMenuItem = async (itemText: string) => { + await userEvent.click(screen.getByText(itemText)); + }; + + describe('given action menu', () => { + beforeEach(() => { + server.use( + http.get('*/monitor/streams', () => { + return HttpResponse.json(makeResourcePage(streams)); + }) + ); + }); + + describe('when Edit clicked', () => { + it('should navigate to edit page', async () => { + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + await renderComponentAndWaitForLoadingComplete(); + + await clickOnActionMenu(); + await clickOnActionMenuItem('Edit'); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/datastream/streams/1/edit', + }); + }); + }); + + describe('when Disable clicked', () => { + it('should update stream with proper parameters', async () => { + const mockUpdateStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useUpdateStreamMutation.mockReturnValue({ + mutateAsync: mockUpdateStreamMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Disable'); + + expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ + id: 1, + status: 'inactive', + label: 'Data Stream 1', + destinations: [123], + details: {}, + type: 'audit_logs', + }); + }); + }); + + describe('when Enable clicked', () => { + it('should update stream with proper parameters', async () => { + const mockUpdateStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useUpdateStreamMutation.mockReturnValue({ + mutateAsync: mockUpdateStreamMutation, + }); + + stream.status = 'inactive'; + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Enable'); + + expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ + id: 1, + status: 'active', + label: 'Data Stream 1', + destinations: [123], + details: {}, + type: 'audit_logs', + }); + }); + }); + + describe('when Delete clicked', () => { + it('should delete stream', async () => { + const mockDeleteStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useDeleteStreamMutation.mockReturnValue({ + mutateAsync: mockDeleteStreamMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + expect(mockDeleteStreamMutation).toHaveBeenCalledWith({ + id: 1, + }); + }); + }); + }); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx index 39089819cd8..6d131412af3 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx @@ -1,8 +1,14 @@ -import { useStreamsQuery } from '@linode/queries'; +import { streamStatus } from '@linode/api-v4'; +import { + useDeleteStreamMutation, + useStreamsQuery, + useUpdateStreamMutation, +} from '@linode/queries'; import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; import { TableBody, TableCell, TableHead, TableRow } from '@mui/material'; import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -18,9 +24,14 @@ import { StreamsLandingEmptyState } from 'src/features/DataStream/Streams/Stream import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { Handlers as StreamHandlers } from './StreamActionMenu'; +import type { Stream } from '@linode/api-v4'; export const StreamsLanding = () => { const navigate = useNavigate(); + const streamsUrl = '/datastream/streams'; const search = useSearch({ from: '/datastream/streams', @@ -42,6 +53,9 @@ export const StreamsLanding = () => { preferenceKey: `streams-order`, }); + const { mutateAsync: updateStream } = useUpdateStreamMutation(); + const { mutateAsync: deleteStream } = useDeleteStreamMutation(); + const filter = { ['+order']: order, ['+order_by']: orderBy, @@ -106,6 +120,78 @@ export const StreamsLanding = () => { return ; } + const handleEdit = ({ id }: Stream) => { + navigate({ to: `/datastream/streams/${id}/edit` }); + }; + + const handleDelete = ({ id, label }: Stream) => { + deleteStream({ + id, + }) + .then(() => { + return enqueueSnackbar(`Stream ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue deleting your stream` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handleDisableOrEnable = ({ + id, + destinations, + details, + label, + type, + status, + }: Stream) => { + updateStream({ + id, + destinations: destinations.map(({ id: destinationId }) => destinationId), + details, + label, + type, + status: + status === streamStatus.Active + ? streamStatus.Inactive + : streamStatus.Active, + }) + .then(() => { + return enqueueSnackbar( + `Stream ${label} ${status === streamStatus.Active ? 'disabled' : 'enabled'}`, + { + variant: 'success', + } + ); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue ${status === streamStatus.Active ? 'disabling' : 'enabling'} your stream` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handlers: StreamHandlers = { + onDisableOrEnable: handleDisableOrEnable, + onEdit: handleEdit, + onDelete: handleDelete, + }; + return ( <> { direction={order} handleClick={handleOrderChange} label="label" + sx={{ width: '30%' }} > Name + Stream Type { > ID - Destination Type + Destination Type + + { Creation Time + {streams?.data.map((stream) => ( - + ))} diff --git a/packages/manager/src/features/DataStream/dataStreamUtils.ts b/packages/manager/src/features/DataStream/dataStreamUtils.ts index 7caef631238..50111ba9186 100644 --- a/packages/manager/src/features/DataStream/dataStreamUtils.ts +++ b/packages/manager/src/features/DataStream/dataStreamUtils.ts @@ -1,8 +1,42 @@ -import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; +import { isEmpty, streamType } from '@linode/api-v4'; +import { omitProps } from '@linode/ui'; -import type { DestinationTypeOption } from 'src/features/DataStream/Shared/types'; +import { + destinationTypeOptions, + streamTypeOptions, +} from 'src/features/DataStream/Shared/types'; + +import type { StreamDetails, StreamType } from '@linode/api-v4'; +import type { + FormMode, + LabelValueOption, +} from 'src/features/DataStream/Shared/types'; export const getDestinationTypeOption = ( destinationTypeValue: string -): DestinationTypeOption | undefined => +): LabelValueOption | undefined => destinationTypeOptions.find(({ value }) => value === destinationTypeValue); + +export const getStreamTypeOption = ( + streamTypeValue: string +): LabelValueOption | undefined => + streamTypeOptions.find(({ value }) => value === streamTypeValue); + +export const isFormInEditMode = (mode: FormMode) => mode === 'edit'; + +export const getStreamPayloadDetails = ( + type: StreamType, + details: StreamDetails +): StreamDetails => { + let payloadDetails: StreamDetails = {}; + + if (!isEmpty(details) && type === streamType.LKEAuditLogs) { + if (details.is_auto_add_all_clusters_enabled) { + payloadDetails = omitProps(details, ['cluster_ids']); + } else { + payloadDetails = omitProps(details, ['is_auto_add_all_clusters_enabled']); + } + } + + return payloadDetails; +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 85a68949b96..18508468a8b 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -222,7 +222,7 @@ export const DatabaseBackups = () => { option.value === value.value } label="" - onChange={(_, newTime) => setSelectedTime(newTime)} + onChange={(_, newTime) => setSelectedTime(newTime ?? null)} options={TIME_OPTIONS} placeholder="Choose a time" renderOption={(props, option) => { @@ -238,7 +238,7 @@ export const DatabaseBackups = () => { 'data-qa-time-select': true, }, }} - value={selectedTime} + value={selectedTime ?? null} /> diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx index 27d2eaf56a1..a950718f3ea 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx @@ -144,6 +144,7 @@ export const DomainDetail = () => { { return (
- void; handleTokenClick: (token: string, entities: TransferEntities) => void; - permissions?: Record; + permissions?: Record; status?: string; token: string; transferType?: 'pending' | 'received' | 'sent'; diff --git a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx index 1c780b34de3..b04242a8b96 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx @@ -8,6 +8,8 @@ const queryMocks = vi.hoisted(() => ({ userPermissions: vi.fn(() => ({ data: { cancel_service_transfer: false, + accept_service_transfer: false, + create_service_transfer: false, }, })), })); @@ -35,7 +37,11 @@ describe('TransfersPendingActionMenu', () => { it('should enable "Cancel" button if the user has cancel_service_transfer permission', async () => { queryMocks.userPermissions.mockReturnValue({ - data: { cancel_service_transfer: true }, + data: { + cancel_service_transfer: true, + accept_service_transfer: false, + create_service_transfer: false, + }, }); const { getByRole } = renderWithTheme( diff --git a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx index c1916b49afd..a91ed8b5457 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx @@ -3,12 +3,12 @@ import * as React from 'react'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import type { PermissionType } from '@linode/api-v4'; +import type { TransfersPermissions } from './TransfersTable'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { onCancelClick: () => void; - permissions?: Partial>; + permissions?: Record; } export const TransfersPendingActionMenu = (props: Props) => { diff --git a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx index fc55a31b867..f1ccffc65c7 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx @@ -22,6 +22,7 @@ import type { TransferEntities, } from '@linode/api-v4/lib/types'; +type PermissionsSubset = T; interface Props { error: APIError[] | null; handlePageChange: (v: number, showSpinner?: boolean | undefined) => void; @@ -29,12 +30,18 @@ interface Props { isLoading: boolean; page: number; pageSize: number; - permissions?: Record; + permissions?: Record; results: number; transfers?: EntityTransfer[]; transferType: 'pending' | 'received' | 'sent'; } +export type TransfersPermissions = PermissionsSubset< + | 'accept_service_transfer' + | 'cancel_service_transfer' + | 'create_service_transfer' +>; + export const TransfersTable = React.memo((props: Props) => { const { error, diff --git a/packages/manager/src/features/Events/factories/datastream.tsx b/packages/manager/src/features/Events/factories/datastream.tsx index 26f01242c16..691826b52a4 100644 --- a/packages/manager/src/features/Events/factories/datastream.tsx +++ b/packages/manager/src/features/Events/factories/datastream.tsx @@ -13,6 +13,22 @@ export const stream: PartialEventMap<'stream'> = { ), }, + stream_delete: { + notification: (e) => ( + <> + Stream has been{' '} + deleted. + + ), + }, + stream_update: { + notification: (e) => ( + <> + Stream has been{' '} + updated. + + ), + }, }; export const destination: PartialEventMap<'destination'> = { @@ -24,4 +40,20 @@ export const destination: PartialEventMap<'destination'> = { ), }, + destination_delete: { + notification: (e) => ( + <> + Destination has been{' '} + deleted. + + ), + }, + destination_update: { + notification: (e) => ( + <> + Destination has been{' '} + updated. + + ), + }, }; diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index 2fb511dac4f..9e340bba786 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -8,7 +8,7 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useTabs } from 'src/hooks/useTabs'; -import { IAM_DOCS_LINK } from './Shared/constants'; +import { IAM_DOCS_LINK, ROLES_LEARN_MORE_LINK } from './Shared/constants'; export const IdentityAccessLanding = React.memo(() => { const location = useLocation(); @@ -29,7 +29,7 @@ export const IdentityAccessLanding = React.memo(() => { breadcrumbProps: { pathname: '/iam', }, - docsLink: IAM_DOCS_LINK, + docsLink: tabIndex === 0 ? IAM_DOCS_LINK : ROLES_LEARN_MORE_LINK, entity: 'Identity and Access', title: 'Identity and Access', }; diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index 0b5d18d52b3..195083ef74f 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -22,6 +22,15 @@ export const IAM_DOCS_LINK = export const ROLES_LEARN_MORE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-available-roles'; +export const USER_DETAILS_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-manage-access'; + +export const USER_ROLES_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-manage-access#check-and-update-users-role-assignment'; + +export const USER_ENTITIES_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-manage-access#check-and-update-users-entity-assignment'; + export const PAID_ENTITY_TYPES = [ 'database', 'linode', diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index 61aed47fc32..d5092b1a1e5 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -513,7 +513,7 @@ export const mergeAssignedRolesIntoExistingRoles = ( selectedPlusExistingRoles.entity_access.push({ id: e.value, roles: [r.role?.value as EntityRoleType], - type: r.role?.entity_type, + type: r.role?.entity_type as AccessType, }); } }); diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 26c7059e0a9..ffda5a30755 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -7,7 +7,12 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useTabs } from 'src/hooks/useTabs'; -import { IAM_DOCS_LINK, IAM_LABEL } from '../Shared/constants'; +import { + IAM_LABEL, + USER_DETAILS_LINK, + USER_ENTITIES_LINK, + USER_ROLES_LINK, +} from '../Shared/constants'; export const UserDetailsLanding = () => { const { username } = useParams({ from: '/iam/users/$username' }); @@ -26,6 +31,9 @@ export const UserDetailsLanding = () => { }, ]); + const docsLinks = [USER_DETAILS_LINK, USER_ROLES_LINK, USER_ENTITIES_LINK]; + const docsLink = docsLinks[tabIndex] ?? USER_DETAILS_LINK; + return ( <> { }, pathname: location.pathname, }} - docsLink={IAM_DOCS_LINK} + docsLink={docsLink} removeCrumbX={4} spacingBottom={4} title={username} diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index 9bbf6fb060a..357a5bdc8e3 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -24,13 +24,15 @@ export const AssignedEntities = ({ useCalculateHiddenItems(role.entity_names!); const handleResize = React.useMemo( - () => debounce(() => calculateHiddenItems(), 100), + () => debounce(() => calculateHiddenItems(), 250), [calculateHiddenItems] ); React.useEffect(() => { - // Ensure calculateHiddenItems runs after layout stabilization on initial render - const rafId = requestAnimationFrame(() => calculateHiddenItems()); + // Double RAF for good measure - see https://stackoverflow.com/questions/44145740/how-does-double-requestanimationframe-work + const rafId = requestAnimationFrame(() => { + requestAnimationFrame(() => calculateHiddenItems()); + }); window.addEventListener('resize', handleResize); @@ -49,14 +51,27 @@ export const AssignedEntities = ({ [role.entity_names, role.entity_ids] ); + const isLastVisibleItem = React.useCallback( + (index: number) => { + return combinedEntities.length - numHiddenItems - 1 === index; + }, + [combinedEntities.length, numHiddenItems] + ); + const items = combinedEntities?.map( (entity: CombinedEntity, index: number) => ( -
{ itemRefs.current[index] = el; }} - style={{ display: 'inline-block', marginRight: 8 }} + sx={{ + display: 'inline', + marginRight: + numHiddenItems > 0 && isLastVisibleItem(index) + ? theme.tokens.spacing.S16 + : theme.tokens.spacing.S8, + }} > 0 && isLastVisibleItem(index) ? '"..."' : '""', + position: 'absolute', + top: 0, + right: -16, + width: 14, + }, }} /> -
+ ) ); @@ -87,19 +111,18 @@ export const AssignedEntities = ({ sx={{ alignItems: 'center', display: 'flex', + position: 'relative', }} > -
{items} -
+ {numHiddenItems > 0 && ( => { + const unrestricted = isRestricted === false; // explicit === false + return { + delete_nodebalancer: unrestricted || grantLevel === 'read_write', + delete_nodebalancer_config: unrestricted || grantLevel === 'read_write', + delete_nodebalancer_config_node: + unrestricted || grantLevel === 'read_write', + update_nodebalancer: unrestricted || grantLevel === 'read_write', + create_nodebalancer_config: unrestricted || grantLevel === 'read_write', + update_nodebalancer_config: unrestricted || grantLevel === 'read_write', + rebuild_nodebalancer_config: unrestricted || grantLevel === 'read_write', + create_nodebalancer_config_node: + unrestricted || grantLevel === 'read_write', + update_nodebalancer_config_node: + unrestricted || grantLevel === 'read_write', + update_nodebalancer_firewalls: unrestricted || grantLevel === 'read_write', + view_nodebalancer: unrestricted || grantLevel !== null, + list_nodebalancer_firewalls: unrestricted || grantLevel !== null, + view_nodebalancer_statistics: unrestricted || grantLevel !== null, + list_nodebalancer_configs: unrestricted || grantLevel !== null, + view_nodebalancer_config: unrestricted || grantLevel !== null, + list_nodebalancer_config_nodes: unrestricted || grantLevel !== null, + view_nodebalancer_config_node: unrestricted || grantLevel !== null, + }; +}; diff --git a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts index b33d7c6c170..aee3d1918f1 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts @@ -1,6 +1,8 @@ import { accountGrantsToPermissions } from './accountGrantsToPermissions'; import { firewallGrantsToPermissions } from './firewallGrantsToPermissions'; import { linodeGrantsToPermissions } from './linodeGrantsToPermissions'; +import { nodeBalancerGrantsToPermissions } from './nodeBalancerGrantsToPermissions'; +import { volumeGrantsToPermissions } from './volumeGrantsToPermissions'; import type { EntityBase } from '../usePermissions'; import type { @@ -24,23 +26,38 @@ export const entityPermissionMapFrom = ( const entityPermissionsMap: EntityPermissionMap = {}; if (grants) { grants[grantType]?.forEach((entity) => { + /** Entity Permissions Maps */ + const firewallPermissionsMap = firewallGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + const linodePermissionsMap = linodeGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + const volumePermissionsMap = volumeGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + const nodebalancerPermissionsMap = nodeBalancerGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + + /** Add entity permissions to map */ switch (grantType) { case 'firewall': - // eslint-disable-next-line no-case-declarations - const firewallPermissionsMap = firewallGrantsToPermissions( - entity?.permissions, - profile?.restricted - ) as PermissionMap; entityPermissionsMap[entity.id] = firewallPermissionsMap; break; case 'linode': - // eslint-disable-next-line no-case-declarations - const linodePermissionsMap = linodeGrantsToPermissions( - entity?.permissions, - profile?.restricted - ) as PermissionMap; entityPermissionsMap[entity.id] = linodePermissionsMap; break; + case 'nodebalancer': + entityPermissionsMap[entity.id] = nodebalancerPermissionsMap; + break; + case 'volume': + entityPermissionsMap[entity.id] = volumePermissionsMap; + break; } }); } @@ -50,13 +67,20 @@ export const entityPermissionMapFrom = ( /** Convert the existing Grant model to the new IAM RBAC model. */ export const fromGrants = ( accessType: AccessType, - permissionsToCheck: PermissionType[], + permissionsToCheck: readonly PermissionType[], grants?: Grants, isRestricted?: boolean, entityId?: number ): PermissionMap => { + /** Find the entity in the grants */ + const firewall = grants?.firewall.find((f) => f.id === entityId); + const linode = grants?.linode.find((f) => f.id === entityId); + const volume = grants?.volume.find((f) => f.id === entityId); + const nodebalancer = grants?.nodebalancer.find((f) => f.id === entityId); + let usersPermissionsMap = {} as PermissionMap; + /** Convert the entity permissions to the new IAM RBAC model */ switch (accessType) { case 'account': usersPermissionsMap = accountGrantsToPermissions( @@ -65,21 +89,29 @@ export const fromGrants = ( ) as PermissionMap; break; case 'firewall': - // eslint-disable-next-line no-case-declarations - const firewall = grants?.firewall.find((f) => f.id === entityId); usersPermissionsMap = firewallGrantsToPermissions( firewall?.permissions, isRestricted ) as PermissionMap; break; case 'linode': - // eslint-disable-next-line no-case-declarations - const linode = grants?.linode.find((f) => f.id === entityId); usersPermissionsMap = linodeGrantsToPermissions( linode?.permissions, isRestricted ) as PermissionMap; break; + case 'nodebalancer': + usersPermissionsMap = nodeBalancerGrantsToPermissions( + nodebalancer?.permissions, + isRestricted + ) as PermissionMap; + break; + case 'volume': + usersPermissionsMap = volumeGrantsToPermissions( + volume?.permissions, + isRestricted + ) as PermissionMap; + break; default: throw new Error(`Unknown access type: ${accessType}`); } @@ -97,7 +129,7 @@ export const fromGrants = ( export const toEntityPermissionMap = ( entities: EntityBase[] | undefined, entitiesPermissions: (PermissionType[] | undefined)[] | undefined, - permissionsToCheck: PermissionType[], + permissionsToCheck: readonly PermissionType[], isRestricted?: boolean ): EntityPermissionMap => { const entityPermissionsMap: EntityPermissionMap = {}; @@ -118,7 +150,7 @@ export const toEntityPermissionMap = ( /** Combines the permissions a user wants to check with the permissions returned from the backend */ export const toPermissionMap = ( - permissionsToCheck: PermissionType[], + permissionsToCheck: readonly PermissionType[], usersPermissions: PermissionType[], isRestricted?: boolean ): PermissionMap => { diff --git a/packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts b/packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts new file mode 100644 index 00000000000..cbb52de867b --- /dev/null +++ b/packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts @@ -0,0 +1,18 @@ +import type { GrantLevel, VolumeAdmin } from '@linode/api-v4'; + +/** Map the existing Grant model to the new IAM RBAC model. */ +export const volumeGrantsToPermissions = ( + grantLevel?: GrantLevel, + isRestricted?: boolean +): Record => { + const unrestricted = isRestricted === false; // explicit === false since the profile can be undefined + return { + attach_volume: unrestricted || grantLevel === 'read_write', + clone_volume: unrestricted || grantLevel === 'read_write', + delete_volume: unrestricted || grantLevel === 'read_write', + detach_volume: unrestricted || grantLevel === 'read_write', + resize_volume: unrestricted || grantLevel === 'read_write', + update_volume: unrestricted || grantLevel === 'read_write', + view_volume: unrestricted || grantLevel !== null, + }; +}; diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.test.ts b/packages/manager/src/features/IAM/hooks/usePermissions.test.ts index e0b19f6028b..1fed21f3720 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.test.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.test.ts @@ -23,13 +23,22 @@ vi.mock(import('@linode/queries'), async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useIsIAMEnabled: queryMocks.useIsIAMEnabled, useUserAccountPermissions: queryMocks.useUserAccountPermissions, useUserEntityPermissions: queryMocks.useUserEntityPermissions, useGrants: queryMocks.useGrants, }; }); +vi.mock('src/features/IAM/hooks/useIsIAMEnabled', async () => { + const actual = await vi.importActual( + 'src/features/IAM/hooks/useIsIAMEnabled' + ); + return { + ...actual, + useIsIAMEnabled: queryMocks.useIsIAMEnabled, + }; +}); + vi.mock('./adapters', () => ({ fromGrants: vi.fn( ( @@ -127,4 +136,83 @@ describe('usePermissions', () => { false ); }); + + it('returns correct map when IAM beta is false', () => { + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: false, + }); + const flags = { iam: { beta: false, enabled: true } }; + + renderHook(() => usePermissions('account', ['create_linode']), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'account', + undefined, + true + ); + }); + + it('returns correct map when beta is true and neither the access type nor the permissions are in the limited availability scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook(() => usePermissions('linode', ['update_linode'], 123), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(false); + expect(queryMocks.useUserAccountPermissions).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'linode', + 123, + true + ); + }); + + it('returns correct map when beta is true and the access type is in the limited availability scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook(() => usePermissions('volume', ['resize_volume'], 123), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(true); + expect(queryMocks.useUserAccountPermissions).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'volume', + 123, + false + ); + }); + + it('returns correct map when beta is true and one of the permissions is in the limited availability scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook(() => usePermissions('account', ['create_volume']), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(true); + expect(queryMocks.useUserAccountPermissions).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'account', + undefined, + false + ); + }); }); diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index 06e43dafb6a..155cbf37bd1 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -1,8 +1,4 @@ -import { - type AccessType, - getUserEntityPermissions, - type PermissionType, -} from '@linode/api-v4'; +import { getUserEntityPermissions } from '@linode/api-v4'; import { useGrants, useProfile, @@ -20,41 +16,80 @@ import { import { useIsIAMEnabled } from './useIsIAMEnabled'; import type { + AccessType, + AccountAdmin, AccountEntity, APIError, EntityType, GrantType, + PermissionType, Profile, } from '@linode/api-v4'; import type { UseQueryResult } from '@linode/queries'; -export type PermissionsResult = { - data: Record; +const BETA_ACCESS_TYPE_SCOPE: AccessType[] = ['account', 'linode', 'firewall']; +const LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE = [ + 'create_image', + 'upload_image', + 'create_vpc', + 'create_volume', + 'create_nodebalancer', +]; + +export type PermissionsResult = { + data: Record; } & Omit, 'data'>; -export const usePermissions = ( +export const usePermissions = ( accessType: AccessType, - permissionsToCheck: PermissionType[], + permissionsToCheck: T, entityId?: number, enabled: boolean = true -): PermissionsResult => { - const { isIAMEnabled } = useIsIAMEnabled(); +): PermissionsResult => { + const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const { data: profile } = useProfile(); + + /** + * BETA and LA features should use the new permission model. + * However, beta features are limited to a subset of AccessTypes and account permissions. + * - Use Beta Permissions if: + * - The feature is beta + * - The access type is in the BETA_ACCESS_TYPE_SCOPE + * - The account permission is not in the LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE + * - Use LA Permissions if: + * - The feature is not beta + */ + const useBetaPermissions = + isIAMEnabled && + isIAMBeta && + BETA_ACCESS_TYPE_SCOPE.includes(accessType) && + LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some( + (blacklistedPermission) => + permissionsToCheck.includes(blacklistedPermission as AccountAdmin) // some of the account admin in the blacklist have not been added yet + ) === false; + const useLAPermissions = isIAMEnabled && !isIAMBeta; + const shouldUsePermissionMap = useBetaPermissions || useLAPermissions; + + const { data: grants } = useGrants( + (!isIAMEnabled || !shouldUsePermissionMap) && enabled + ); const { data: userAccountPermissions, ...restAccountPermissions } = useUserAccountPermissions( - isIAMEnabled && accessType === 'account' && enabled + shouldUsePermissionMap && accessType === 'account' && enabled ); - const { data: userEntityPermisssions, ...restEntityPermissions } = - useUserEntityPermissions(accessType, entityId!, isIAMEnabled && enabled); + const { data: userEntityPermissions, ...restEntityPermissions } = + useUserEntityPermissions( + accessType, + entityId!, + shouldUsePermissionMap && enabled + ); const usersPermissions = - accessType === 'account' ? userAccountPermissions : userEntityPermisssions; - - const { data: profile } = useProfile(); - const { data: grants } = useGrants(!isIAMEnabled && enabled); + accessType === 'account' ? userAccountPermissions : userEntityPermissions; - const permissionMap = isIAMEnabled + const permissionMap = shouldUsePermissionMap ? toPermissionMap( permissionsToCheck, usersPermissions!, diff --git a/packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx b/packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx new file mode 100644 index 00000000000..ca9c62d65dd --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx @@ -0,0 +1,59 @@ +import { useDeleteImageMutation, useImageQuery } from '@linode/queries'; +import { useSnackbar } from 'notistack'; +import React from 'react'; + +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; + +interface Props { + imageId: string | undefined; + onClose: () => void; + open: boolean; +} + +export const DeleteImageDialog = (props: Props) => { + const { imageId, open, onClose } = props; + const { enqueueSnackbar } = useSnackbar(); + + const { + data: image, + isLoading, + error, + } = useImageQuery(imageId ?? '', Boolean(imageId)); + + const { mutate: deleteImage, isPending } = useDeleteImageMutation({ + onSuccess() { + enqueueSnackbar('Image has been scheduled for deletion.', { + variant: 'info', + }); + onClose(); + }, + }); + + const isPendingUpload = image?.status === 'pending_upload'; + + return ( + deleteImage({ imageId: imageId ?? '' })} + onClose={onClose} + open={open} + secondaryButtonProps={{ + label: isPendingUpload ? 'Keep Image' : 'Cancel', + }} + title={ + isPendingUpload + ? 'Cancel Upload' + : `Delete Image ${image?.label ?? imageId}` + } + /> + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index bdd27451cd9..46c43812b10 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -109,6 +109,27 @@ describe('Image Table Row', () => { ).toBeNull(); }); + it('should not show an unencrypted icon when an Image is still "pending_upload"', () => { + // The API does not populate the "distributed-sites" capability until the image is done creating. + // We must account for this because the image would show as "Unencrypted" while it is creating, + // then suddenly show as encrypted once it was done creating. We don't want that. + // Therefore, we decided we won't show the unencrypted icon until the image is done uploading to + // prevent confusion. + const image = imageFactory.build({ + capabilities: ['cloud-init'], + status: 'pending_upload', + type: 'manual', + }); + + const { queryByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect( + queryByLabelText('This image is not encrypted.', { exact: false }) + ).toBeNull(); + }); + it('should show N/A if Image does not have any regions', () => { const image = imageFactory.build({ regions: [] }); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index 8c2e96b0925..aad89a214db 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -78,6 +78,7 @@ export const ImageRow = (props: Props) => { {type === 'manual' && status !== 'creating' && + status !== 'pending_upload' && !image.capabilities.includes('distributed-sites') && ( } diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 76856ace0d6..cd2843065d1 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -1,16 +1,9 @@ -import { - imageQueries, - useDeleteImageMutation, - useImageQuery, - useImagesQuery, -} from '@linode/queries'; +import { imageQueries, useImageQuery, useImagesQuery } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; import { - ActionsPanel, CircleProgress, Drawer, ErrorState, - Notice, Paper, Stack, Typography, @@ -18,11 +11,9 @@ import { import { Hidden } from '@linode/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; -import { useSnackbar } from 'notistack'; import React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; @@ -45,7 +36,6 @@ import { isEventInProgressDiskImagize, } from 'src/queries/events/event.helpers'; import { useEventsInfiniteQuery } from 'src/queries/events/events'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { AUTOMATIC_IMAGES_DEFAULT_ORDER, @@ -57,6 +47,7 @@ import { MANUAL_IMAGES_PREFERENCE_KEY, } from '../constants'; import { getEventsForImages } from '../utils'; +import { DeleteImageDialog } from './DeleteImageDialog'; import { EditImageDrawer } from './EditImageDrawer'; import { ManageImageReplicasForm } from './ImageRegions/ManageImageRegionsForm'; import { ImageRow } from './ImageRow'; @@ -64,7 +55,7 @@ import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; -import type { Filter, Image, ImageStatus } from '@linode/api-v4'; +import type { Filter, Image } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; import type { ImageAction } from 'src/routes/images'; @@ -84,37 +75,19 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface ImageDialogState { - error?: string; - status?: ImageStatus; - submitting: boolean; -} - -const defaultDialogState: ImageDialogState = { - error: undefined, - submitting: false, -}; - export const ImagesLanding = () => { const { classes } = useStyles(); - const { - action, - imageId: selectedImageId, - }: { action: ImageAction; imageId: string } = useParams({ - strict: false, + const params = useParams({ + from: '/images/$imageId/$action', + shouldThrow: false, }); const search = useSearch({ from: '/images' }); const { query } = search; const navigate = useNavigate(); - const { enqueueSnackbar } = useSnackbar(); const isCreateImageRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_images', }); const queryClient = useQueryClient(); - const [dialogState, setDialogState] = - React.useState(defaultDialogState); - const dialogStatus = - dialogState.status === 'pending_upload' ? 'cancel' : 'delete'; /** * At the time of writing: `label`, `tags`, `size`, `status`, `region` are filterable. @@ -258,8 +231,7 @@ export const ImagesLanding = () => { data: selectedImage, isLoading: isFetchingSelectedImage, error: selectedImageError, - } = useImageQuery(selectedImageId, !!selectedImageId); - const { mutateAsync: deleteImage } = useDeleteImageMutation(); + } = useImageQuery(params?.imageId ?? '', !!params?.imageId); const { events } = useEventsInfiniteQuery(); @@ -302,7 +274,6 @@ export const ImagesLanding = () => { }; const handleCloseDialog = () => { - setDialogState(defaultDialogState); navigate({ search: (prev) => prev, to: '/images' }); }; @@ -310,50 +281,6 @@ export const ImagesLanding = () => { actionHandler(image, 'manage-replicas'); }; - const handleDeleteImage = (image: Image) => { - if (!image.id) { - setDialogState((dialog) => ({ - ...dialog, - error: 'Image is not available.', - })); - } - - setDialogState((dialog) => ({ - ...dialog, - error: undefined, - submitting: true, - })); - - deleteImage({ imageId: image.id }) - .then(() => { - handleCloseDialog(); - /** - * request generated by the Pagey HOC. - * - * We're making a request here because the image is being - * optimistically deleted on the API side, so a GET to /images - * will not return the image scheduled for deletion. This request - * is ensuring the image is removed from the list, to prevent the user - * from taking any action on the Image. - */ - enqueueSnackbar('Image has been scheduled for deletion.', { - variant: 'info', - }); - }) - .catch((err) => { - const _error = getErrorStringOrDefault( - err, - 'There was an error deleting the image.' - ); - setDialogState({ - ...dialogState, - error: _error, - submitting: false, - }); - handleCloseDialog(); - }); - }; - const onCancelFailedClick = () => { queryClient.invalidateQueries({ queryKey: imageQueries.paginated._def, @@ -614,20 +541,20 @@ export const ImagesLanding = () => { imageError={selectedImageError} isFetching={isFetchingSelectedImage} onClose={handleCloseDialog} - open={action === 'edit'} + open={params?.action === 'edit'} /> { onClose={handleCloseDialog} /> - handleDeleteImage(selectedImage!), - }} - secondaryButtonProps={{ - 'data-testid': 'cancel', - label: dialogStatus === 'cancel' ? 'Keep Image' : 'Cancel', - onClick: handleCloseDialog, - }} - /> - } - entityError={selectedImageError} - isFetching={isFetchingSelectedImage} + - {dialogState.error && ( - - )} - - {dialogStatus === 'cancel' - ? 'Are you sure you want to cancel this Image upload?' - : 'Are you sure you want to delete this Image?'} - - + open={params?.action === 'delete'} + /> ); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx index 7f2bd1e34ce..796f6b82dc5 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx @@ -49,9 +49,9 @@ describe('ClusterNetworkingPanel', () => { }); // Confirm stack type section - expect(getByText('IP Version')).toBeVisible(); + expect(getByText('IP Stack')).toBeVisible(); expect(getByText('IPv4')).toBeVisible(); - expect(getByText('IPv4 + IPv6')).toBeVisible(); + expect(getByText('IPv4 + IPv6 (dual-stack)')).toBeVisible(); // Confirm VPC section expect(getByText('VPC')).toBeVisible(); @@ -71,7 +71,9 @@ describe('ClusterNetworkingPanel', () => { // Confirm stack type default expect(getByRole('radio', { name: 'IPv4' })).toBeChecked(); - expect(getByRole('radio', { name: 'IPv4 + IPv6' })).not.toBeChecked(); + expect( + getByRole('radio', { name: 'IPv4 + IPv6 (dual-stack)' }) + ).not.toBeChecked(); // Confirm VPC default expect( diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx index 5bdfa0639c5..fc654c7e491 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx @@ -59,11 +59,11 @@ export const ClusterNetworkingPanel = (props: Props) => { onChange={(e) => field.onChange(e.target.value)} value={field.value ?? null} > - IP Version + IP Stack } label="IPv4" value="ipv4" /> } - label="IPv4 + IPv6" + label="IPv4 + IPv6 (dual-stack)" value="ipv4-ipv6" /> @@ -83,7 +83,6 @@ export const ClusterNetworkingPanel = (props: Props) => { ) => { setIsUsingOwnVpc(e.target.value === 'yes'); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 63382255dd2..5ea7c98df7e 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -527,7 +527,7 @@ export const CreateCluster = () => { ? 'Only regions that support LKE Enterprise clusters are listed.' : undefined } - value={selectedRegion?.id} + value={selectedRegion?.id || null} /> { {isAPLEnabled && ( { > Cluster ID:{' '} - {clusterId} + {isLkeEnterprisePhase2FeatureEnabled && vpc && ( { {vpc?.label ?? `${vpcId}`}   - {vpcId && vpc?.label ? `(ID: ${vpcId})` : undefined} + {vpcId && vpc?.label && ( + + (ID: ) + + )} )} @@ -212,6 +217,7 @@ export const KubeEntityDetailFooter = React.memo((props: FooterProps) => { > { clusterLabel={cluster.label} clusterRegionId={cluster.region} clusterTier={cluster.tier ?? 'standard'} + clusterVersion={cluster.k8s_version} isLkeClusterRestricted={isClusterReadOnly} /> diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx index bb7e2988ca8..c7df8c0d6f0 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx @@ -1,5 +1,11 @@ +import { linodeTypeFactory } from '@linode/utilities'; +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { accountFactory, firewallFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AddNodePoolDrawer } from './AddNodePoolDrawer'; @@ -16,23 +22,145 @@ const props: Props = { }; describe('AddNodePoolDrawer', () => { - it('should render plan heading', async () => { - const { findByText } = renderWithTheme(); + describe('Plans', () => { + it('should render plan heading', async () => { + const { findByText } = renderWithTheme(); - await findByText('Dedicated CPU'); - }); + await findByText('Dedicated CPU'); + }); + + it('should display the GPU tab for standard clusters', async () => { + const { findByText } = renderWithTheme(); + + expect(await findByText('GPU')).toBeInTheDocument(); + }); - it('should display the GPU tab for standard clusters', async () => { - const { findByText } = renderWithTheme(); + it('should not display the GPU tab for enterprise clusters', async () => { + const { queryByText } = renderWithTheme( + + ); - expect(await findByText('GPU')).toBeInTheDocument(); + expect(queryByText('GPU')).toBeNull(); + }); }); - it('should not display the GPU tab for enterprise clusters', async () => { - const { queryByText } = renderWithTheme( - - ); + describe('Firewall', () => { + // LKE-E Post LA must be enabled for the Firewall option to show up + const flags = { + lkeEnterprise: { + enabled: true, + ga: false, + la: true, + postLa: true, + phase2Mtc: false, + }, + }; + + it('should not display "Firewall" as an option by default', async () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Firewall')).toBeNull(); + }); + + it('should display "Firewall" as an option for enterprise clusters if the postLA flag is on and the account has the capability', async () => { + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }), + http.get('*/v4*/linode/types', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme( + , + { flags } + ); + + expect(await findByText('Firewall')).toBeVisible(); + + const defaultOption = getByLabelText('Use default firewall'); + const existingFirewallOption = getByLabelText('Select existing firewall'); + + expect(defaultOption).toBeInTheDocument(); + expect(existingFirewallOption).toBeInTheDocument(); + + expect(defaultOption).toBeEnabled(); + expect(existingFirewallOption).toBeEnabled(); + }); + + it('should allow the user to pick an existing firewall for enterprise clusters if the postLA flag is on and the account has the capability', async () => { + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + const firewall = firewallFactory.build({ id: 12 }); + const type = linodeTypeFactory.build({ + label: 'Linode 4GB', + class: 'dedicated', + }); + + const onCreatePool = vi.fn(); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }), + http.get('*/v4*/linode/types', () => { + return HttpResponse.json(makeResourcePage([type])); + }), + http.get('*/v4*/networking/firewalls', () => { + return HttpResponse.json(makeResourcePage([firewall])); + }), + http.post( + '*/v4*/lke/clusters/:clusterId/pools', + async ({ request }) => { + const data = await request.json(); + onCreatePool(data); + return HttpResponse.json(data); + } + ) + ); + + const { findByText, findByLabelText, getByRole, getByPlaceholderText } = + renderWithTheme( + , + { flags } + ); + + expect(await findByText('Linode 4 GB')).toBeVisible(); + + await userEvent.click(getByRole('button', { name: 'Add 1' })); + await userEvent.click(getByRole('button', { name: 'Add 1' })); + await userEvent.click(getByRole('button', { name: 'Add 1' })); + + const existingFirewallOption = await findByLabelText( + 'Select existing firewall' + ); + + await userEvent.click(existingFirewallOption); + + const firewallSelect = getByPlaceholderText('Select firewall'); + + await userEvent.click(firewallSelect); + + await userEvent.click(await findByText(firewall.label)); + + await userEvent.click(getByRole('button', { name: 'Add pool' })); - expect(queryByText('GPU')).toBeNull(); + await waitFor(() => { + expect(onCreatePool).toHaveBeenCalledWith({ + firewall_id: 12, + count: 3, + type: type.id, + update_strategy: 'on_recycle', + }); + }); + }); }); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index 55dfe7ddeb2..c7cbc89b2ec 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -1,3 +1,8 @@ +import { + type CreateNodePoolData, + type KubernetesTier, + type Region, +} from '@linode/api-v4'; import { useAllTypes, useRegionsQuery } from '@linode/queries'; import { Box, Button, Drawer, Notice, Stack, Typography } from '@linode/ui'; import { @@ -7,10 +12,9 @@ import { scrollErrorIntoView, } from '@linode/utilities'; import React from 'react'; -import { Controller, useForm, useWatch } from 'react-hook-form'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { ErrorMessage } from 'src/components/ErrorMessage'; -// import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect'; import { ADD_NODE_POOLS_DESCRIPTION, ADD_NODE_POOLS_ENTERPRISE_DESCRIPTION, @@ -25,16 +29,9 @@ import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { PremiumCPUPlanNotice } from '../../CreateCluster/PremiumCPUPlanNotice'; import { KubernetesPlansPanel } from '../../KubernetesPlansPanel/KubernetesPlansPanel'; -import { useIsLkeEnterpriseEnabled } from '../../kubeUtils'; -import { NodePoolUpdateStrategySelect } from '../../NodePoolUpdateStrategySelect'; +import { NodePoolConfigOptions } from '../../KubernetesPlansPanel/NodePoolConfigOptions'; import { hasInvalidNodePoolPrice } from './utils'; -import type { - CreateNodePoolData, - KubernetesTier, - Region, -} from '@linode/api-v4'; - export interface Props { clusterId: number; clusterLabel: string; @@ -54,14 +51,13 @@ export const AddNodePoolDrawer = (props: Props) => { open, } = props; - const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); const { data: types, isLoading: isTypesLoading } = useAllTypes(open); const { - error, isPending, mutateAsync: createPool, + error, } = useCreateNodePoolMutation(clusterId); // Only want to use current types here and filter out nanodes @@ -93,7 +89,7 @@ export const AddNodePoolDrawer = (props: Props) => { type && count && isNumber(pricePerNode) ? count * pricePerNode : undefined; const hasInvalidPrice = hasInvalidNodePoolPrice(pricePerNode, totalPrice); - const shouldShowPricingInfo = type && count > 0; + const shouldShowPricingInfo = Boolean(type) && count > 0; React.useEffect(() => { if (open) { @@ -145,121 +141,100 @@ export const AddNodePoolDrawer = (props: Props) => { open={open} slotProps={{ paper: { - sx: { maxWidth: '790px !important' }, + sx: { maxWidth: '810px !important' }, }, }} title={`Add a Node Pool: ${clusterLabel}`} wide > - {form.formState.errors.root?.message && ( - - - - )} -
- { - if (plan === type) { - return count; - } - return 0; - }} - hasSelectedRegion={hasSelectedRegion} - isPlanPanelDisabled={isPlanPanelDisabled} - isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} - isSubmitting={isPending} - notice={} - onSelect={(type) => form.setValue('type', type)} - regionsData={regions ?? []} - resetValues={() => form.reset()} - selectedId={type} - selectedRegionId={clusterRegionId} - selectedTier={clusterTier} - types={extendedTypes} - updatePlanCount={updatePlanCount} - /> - {count > 0 && count < 3 && ( - - )} - {hasInvalidPrice && shouldShowPricingInfo && ( - - )} - {isLkeEnterprisePostLAFeatureEnabled && - clusterTier === 'enterprise' && ( - - Configuration - ( - - )} + + + + {form.formState.errors.root?.message && ( + + + + )} + { + if (plan === type) { + return count; + } + return 0; + }} + hasSelectedRegion={hasSelectedRegion} + isPlanPanelDisabled={isPlanPanelDisabled} + isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} + isSubmitting={isPending} + notice={ + + } + onSelect={(type) => form.setValue('type', type)} + regionsData={regions ?? []} + resetValues={() => { + form.setValue('type', ''); + form.setValue('count', 0); + }} + selectedId={type} + selectedRegionId={clusterRegionId} + selectedTier={clusterTier} + types={extendedTypes} + updatePlanCount={updatePlanCount} + /> + {count > 0 && count < 3 && ( + - {/* - ( - - field.onChange(firewall?.id ?? null) - } - value={field.value ?? null} - /> - )} + )} + {hasInvalidPrice && shouldShowPricingInfo && ( + - */} - - )} - - {shouldShowPricingInfo && ( - - This pool will add{' '} - - ${renderMonthlyPriceToCorrectDecimalPlace(totalPrice)}/month ( - {pluralize('node', 'nodes', count)} at $ - {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} - /month) - {' '} - to this cluster. - - )} - - - + )} + + + {shouldShowPricingInfo && ( + + This pool will add{' '} + + ${renderMonthlyPriceToCorrectDecimalPlace(totalPrice)}/month + ({pluralize('node', 'nodes', count)} at $ + {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} + /month) + {' '} + to this cluster. + + )} + + + + + ); }; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscaleNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscaleNodePoolDrawer.tsx index 4739d66e2df..cd6520888f3 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscaleNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscaleNodePoolDrawer.tsx @@ -1,4 +1,3 @@ -import { useSpecificTypes } from '@linode/queries'; import { ActionsPanel, Box, @@ -18,12 +17,12 @@ import { makeStyles } from 'tss-react/mui'; import { EnhancedNumberInput } from 'src/components/EnhancedNumberInput/EnhancedNumberInput'; import { Link } from 'src/components/Link'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; -import { extendType } from 'src/utilities/extendType'; import { MAX_NODES_PER_POOL_ENTERPRISE_TIER, MAX_NODES_PER_POOL_STANDARD_TIER, } from '../../constants'; +import { useNodePoolDisplayLabel } from './utils'; import type { AutoscaleSettings, @@ -88,6 +87,7 @@ export const AutoscaleNodePoolDrawer = (props: Props) => { open, } = props; const autoscaler = nodePool?.autoscaler; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); const { classes, cx } = useStyles(); const { enqueueSnackbar } = useSnackbar(); @@ -96,12 +96,6 @@ export const AutoscaleNodePoolDrawer = (props: Props) => { nodePool?.id ?? -1 ); - const typesQuery = useSpecificTypes(nodePool?.type ? [nodePool.type] : []); - - const planType = typesQuery[0]?.data - ? extendType(typesQuery[0].data) - : undefined; - const { clearErrors, control, @@ -189,7 +183,7 @@ export const AutoscaleNodePoolDrawer = (props: Props) => { {errors.root?.message ? ( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.tsx new file mode 100644 index 00000000000..0f29fe72f34 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.tsx @@ -0,0 +1,40 @@ +import { Drawer } from '@linode/ui'; +import React from 'react'; + +import { useNodePoolDisplayLabel } from '../utils'; +import { ConfigureNodePoolForm } from './ConfigureNodePoolForm'; + +import type { KubeNodePoolResponse, KubernetesCluster } from '@linode/api-v4'; + +interface Props { + clusterId: KubernetesCluster['id']; + clusterTier: KubernetesCluster['tier']; + clusterVersion: KubernetesCluster['k8s_version']; + nodePool: KubeNodePoolResponse | undefined; + onClose: () => void; + open: boolean; +} + +export const ConfigureNodePoolDrawer = (props: Props) => { + const { nodePool, onClose, clusterId, open, clusterTier, clusterVersion } = + props; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); + + return ( + + {nodePool && ( + + )} + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.utils.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.utils.ts new file mode 100644 index 00000000000..af734e20bff --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.utils.ts @@ -0,0 +1,24 @@ +import type { KubeNodePoolResponse, KubernetesCluster } from '@linode/api-v4'; + +interface NodePoolVersionOptions { + clusterVersion: KubernetesCluster['k8s_version']; + nodePoolVersion: KubeNodePoolResponse['k8s_version']; +} + +/** + * This function returns Autocomplete `options` for possible Node Pool versions. + * + * The only valid k8s_version options for a Node Pool are + * - The Node Pool's current version + * - The Cluster's k8s_version + */ +export function getNodePoolVersionOptions(options: NodePoolVersionOptions) { + // The only valid versions are the Node Pool's version and the Cluster's version + const versions = [options.nodePoolVersion, options.clusterVersion]; + + // Filter out undefined versions. In some cases, Node Pool's `k8s_version` may be undefined + const definedVersions = versions.filter((version) => version !== undefined); + + // Get unique versions because the Node Pool's version and the cluster's version can be the same + return Array.from(new Set(definedVersions)); +} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.test.tsx new file mode 100644 index 00000000000..cfb639fbf48 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.test.tsx @@ -0,0 +1,224 @@ +import { linodeTypeFactory } from '@linode/utilities'; +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { accountFactory, nodePoolFactory } from 'src/factories'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { getNodePoolVersionOptions } from './ConfigureNodePoolDrawer.utils'; +import { ConfigureNodePoolForm } from './ConfigureNodePoolForm'; + +const flags = { + lkeEnterprise: { + postLa: true, + enabled: true, + la: true, + ga: false, + phase2Mtc: false, + }, +}; + +describe('ConfigureNodePoolForm', () => { + // @todo Enable this test when we allow users to edit their Node Pool's label in the UI (ECE-353) + it.skip("renders a label field containing the Node Pool's label", () => { + const nodePool = nodePoolFactory.build({ label: 'my-node-pool-1' }); + + const { getByLabelText } = renderWithTheme( + + ); + + const labelTextField = getByLabelText('Label'); + + expect(labelTextField).toBeEnabled(); + expect(labelTextField).toBeVisible(); + expect(labelTextField).toHaveDisplayValue('my-node-pool-1'); + }); + + // @todo Enable this test when we allow users to edit their Node Pool's label in the UI (ECE-353) + it.skip("uses the Node Pool's type as the label field's placeholder if the node pool does not have an explicit label", async () => { + const type = linodeTypeFactory.build({ label: 'Fake Linode 2GB' }); + const nodePool = nodePoolFactory.build({ label: '', type: type.id }); + + server.use( + http.get(`*/v4*/linode/types/${type.id}`, () => HttpResponse.json(type)) + ); + + const { findByPlaceholderText } = renderWithTheme( + + ); + + expect(await findByPlaceholderText('Fake Linode 2 GB')).toBeVisible(); + }); + + it('renders an Update Strategy select if the cluster is enterprise, the account has the capability, and the postLa feature flag is enabled', async () => { + const nodePool = nodePoolFactory.build({ + update_strategy: 'rolling_update', + }); + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + + server.use(http.get('*/v4*/account', () => HttpResponse.json(account))); + + const { findByLabelText } = renderWithTheme( + , + { flags } + ); + + const updateStrategyField = await findByLabelText('Update Strategy'); + + expect(updateStrategyField).toBeEnabled(); + expect(updateStrategyField).toBeVisible(); + expect(updateStrategyField).toHaveDisplayValue('Rolling Updates'); + }); + + it('renders an Kubernetes Version select if the cluster is enterprise, the account has the capability, and the postLa feature flag is enabled', async () => { + const nodePool = nodePoolFactory.build({ + k8s_version: 'v1.31.8+lke5', + }); + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + + server.use(http.get('*/v4*/account', () => HttpResponse.json(account))); + + const { getByRole, findByLabelText } = renderWithTheme( + , + { flags } + ); + + const kubernetesVersionField = await findByLabelText('Kubernetes Version'); + + expect(kubernetesVersionField).toBeEnabled(); + expect(kubernetesVersionField).toBeVisible(); + expect(kubernetesVersionField).toHaveDisplayValue('v1.31.8+lke5'); + + // Open the version select + await userEvent.click(kubernetesVersionField); + + // Verify the Node Pool's version and the cluster's version show as version options + expect(getByRole('option', { name: 'v1.31.8+lke5' })).toBeVisible(); + expect(getByRole('option', { name: 'v1.31.8+lke6' })).toBeVisible(); + }); + + it('makes a PUT request to /v4beta/lke/clusters/:id/pools/:id and calls onDone when the form is saved', async () => { + const clusterId = 1; + const nodePool = nodePoolFactory.build({ k8s_version: 'v1.31.8+lke5' }); + const onDone = vi.fn(); + const onUpdateNodePool = vi.fn(); + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + + server.use( + http.get('*/v4*/account', () => HttpResponse.json(account)), + http.put( + `*/v4*/lke/clusters/${clusterId}/pools/${nodePool.id}`, + async ({ request }) => { + onUpdateNodePool(await request.json()); + return HttpResponse.json(nodePool); + } + ) + ); + + const { getByRole, findByLabelText } = renderWithTheme( + , + { + flags, + } + ); + + const saveButton = getByRole('button', { name: 'Save' }); + + // The save button should be disabled until the user makes a change + expect(saveButton).toBeDisabled(); + + // Must await because we must load and check /v4/account 's capabilities to know if LKE-E should be enabled + await userEvent.click(await findByLabelText('Kubernetes Version')); + + // Select the cluster's newer version + await userEvent.click(getByRole('option', { name: 'v1.31.8+lke6' })); + + // The save button should be enabled now that the user changed the Node Pool's label + expect(saveButton).toBeEnabled(); + + await userEvent.click(saveButton); + + // Verify the onDone prop was called + await waitFor(() => { + expect(onDone).toHaveBeenCalled(); + }); + + // Verify the PUT request happend with the expected payload + expect(onUpdateNodePool).toHaveBeenCalledWith({ + k8s_version: 'v1.31.8+lke6', + }); + }); + + it("calls onDone when 'Cancel' is clicked", async () => { + const nodePool = nodePoolFactory.build(); + const onDone = vi.fn(); + + const { getByRole } = renderWithTheme( + + ); + + await userEvent.click(getByRole('button', { name: 'Cancel' })); + + expect(onDone).toHaveBeenCalled(); + }); +}); + +describe('getNodePoolVersionOptions', () => { + it('Returns Autocomplete options given the required params ', () => { + expect( + getNodePoolVersionOptions({ + clusterVersion: 'v1.0.0', + nodePoolVersion: 'v0.0.9', + }) + ).toStrictEqual(['v0.0.9', 'v1.0.0']); + }); + + it('only returns one option if the versions are the same', () => { + expect( + getNodePoolVersionOptions({ + clusterVersion: 'v0.0.9', + nodePoolVersion: 'v0.0.9', + }) + ).toStrictEqual(['v0.0.9']); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx new file mode 100644 index 00000000000..e450fdfef2e --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx @@ -0,0 +1,118 @@ +import { Button, Notice, Stack } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { NodePoolConfigOptions } from 'src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigOptions'; +import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; + +import { getNodePoolVersionOptions } from './ConfigureNodePoolDrawer.utils'; + +import type { + KubeNodePoolResponse, + KubernetesCluster, + UpdateNodePoolData, +} from '@linode/api-v4'; + +interface Props { + /** + * The ID of the LKE cluster + */ + clusterId: KubernetesCluster['id']; + /** + * The tier of the LKE cluster + */ + clusterTier: KubernetesCluster['tier']; + /** + * The version of the LKE cluster + */ + clusterVersion: KubernetesCluster['k8s_version']; + /** + * The Node Pool to configure + */ + nodePool: KubeNodePoolResponse; + /** + * A function that will be called when the user saves or cancels + */ + onDone?: () => void; +} + +export const ConfigureNodePoolForm = (props: Props) => { + const { clusterId, onDone, nodePool, clusterVersion, clusterTier } = props; + const { enqueueSnackbar } = useSnackbar(); + + const form = useForm({ + defaultValues: { + // @TODO allow users to edit Node Pool `label` and `tags` because the API supports it. (ECE-353) + // label: nodePool.label, + // tags: nodePool.tags, + firewall_id: nodePool.firewall_id, + update_strategy: nodePool.update_strategy, + k8s_version: nodePool.k8s_version, + }, + }); + + const { mutateAsync: updateNodePool } = useUpdateNodePoolMutation( + clusterId, + nodePool.id + ); + + const versions = getNodePoolVersionOptions({ + clusterVersion, + nodePoolVersion: nodePool.k8s_version, + }); + + const onSubmit = async (values: UpdateNodePoolData) => { + try { + await updateNodePool(values); + enqueueSnackbar('Node Pool configuration successfully updated. ', { + variant: 'success', + }); + onDone?.(); + } catch (errors) { + for (const error of errors) { + form.setError(error.field ?? 'root', { message: error.reason }); + } + } + }; + + return ( + +
+ + {form.formState.errors.root?.message && ( + + )} + + + + + + +
+
+ ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx index f0f4495c81e..ba4b80211a3 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx @@ -1,4 +1,3 @@ -import { useSpecificTypes } from '@linode/queries'; import { ActionsPanel, Button, @@ -13,8 +12,8 @@ import { FormProvider, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; -import { extendType } from 'src/utilities/extendType'; +import { useNodePoolDisplayLabel } from '../utils'; import { LabelInput } from './LabelInput'; import { LabelTable } from './LabelTable'; import { TaintInput } from './TaintInput'; @@ -37,11 +36,11 @@ interface LabelsAndTaintsFormFields { export const LabelAndTaintDrawer = (props: Props) => { const { clusterId, nodePool, onClose, open } = props; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); + const [shouldShowLabelForm, setShouldShowLabelForm] = React.useState(false); const [shouldShowTaintForm, setShouldShowTaintForm] = React.useState(false); - const typesQuery = useSpecificTypes(nodePool?.type ? [nodePool.type] : []); - const { isPending, mutateAsync: updateNodePool } = useUpdateNodePoolMutation( clusterId, nodePool?.id ?? -1 @@ -109,15 +108,11 @@ export const LabelAndTaintDrawer = (props: Props) => { form.reset(); }; - const planType = typesQuery[0]?.data - ? extendType(typesQuery[0].data) - : undefined; - return ( {formState.errors.root?.message ? ( void; handleClickAutoscale: (poolId: number) => void; + handleClickConfigureNodePool: (poolId: number) => void; handleClickLabelsAndTaints: (poolId: number) => void; handleClickResize: (poolId: number) => void; isLkeClusterRestricted: boolean; isOnlyNodePool: boolean; + label: string; nodes: PoolNodeResponse[]; openDeletePoolDialog: (poolId: number) => void; openRecycleAllNodesDialog: (poolId: number) => void; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; + poolFirewallId: KubeNodePoolResponse['firewall_id']; poolId: number; poolVersion: KubeNodePoolResponse['k8s_version']; statusFilter: StatusFilter; tags: string[]; - typeLabel: string; + type: string; } export const NodePool = (props: Props) => { @@ -52,6 +57,7 @@ export const NodePool = (props: Props) => { encryptionStatus, handleAccordionClick, handleClickAutoscale, + handleClickConfigureNodePool, handleClickLabelsAndTaints, handleClickResize, isLkeClusterRestricted, @@ -61,12 +67,17 @@ export const NodePool = (props: Props) => { openRecycleAllNodesDialog, openRecycleNodeDialog, poolId, + poolFirewallId, poolVersion, statusFilter, tags, - typeLabel, + label, + type, } = props; + const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); + const nodePoolLabel = useNodePoolDisplayLabel({ label, type }); + return ( { divider={} spacing={{ sm: 1.5, xs: 1 }} > - {typeLabel} + {nodePoolLabel} {pluralize('Node', 'Nodes', count)} @@ -100,6 +111,17 @@ export const NodePool = (props: Props) => { )} handleClickConfigureNodePool(poolId), + title: 'Configure Pool', + }, + ] + : []), { disabled: isLkeClusterRestricted, onClick: () => handleClickLabelsAndTaints(poolId), @@ -152,16 +174,17 @@ export const NodePool = (props: Props) => { clusterCreated={clusterCreated} clusterTier={clusterTier} isLkeClusterRestricted={isLkeClusterRestricted} + nodePoolType={type} nodes={nodes} openRecycleNodeDialog={openRecycleNodeDialog} statusFilter={statusFilter} - typeLabel={typeLabel} /> { tags: [], poolId: 1, poolVersion: undefined, + poolFirewallId: undefined, isLkeClusterRestricted: false, }; it('shows the Pool ID', async () => { const { getByText } = renderWithTheme(); - expect(getByText('Pool ID')).toBeVisible(); + expect(getByText('Pool ID:')).toBeVisible(); expect(getByText(props.poolId)).toBeVisible(); }); @@ -47,7 +48,7 @@ describe('Node Pool Footer', () => { /> ); - expect(getByText('Version')).toBeVisible(); + expect(getByText('Version:')).toBeVisible(); expect(getByText('v1.31.8+lke5')).toBeVisible(); }); @@ -60,10 +61,67 @@ describe('Node Pool Footer', () => { /> ); - expect(queryByText('Version')).not.toBeInTheDocument(); + expect(queryByText('Version:')).not.toBeInTheDocument(); expect(queryByText('v1.31.8+lke5')).not.toBeInTheDocument(); }); + it("shows the node pool's firewall for an LKE Enterprise cluster", async () => { + server.use( + http.get('*/firewalls/*', () => { + return HttpResponse.json( + firewallFactory.build({ id: 123, label: 'my-lke-e-firewall' }) + ); + }) + ); + const { getByText, findByText } = renderWithTheme( + + ); + + expect(getByText('Firewall:')).toBeVisible(); + expect( + await findByText('my-lke-e-firewall', { exact: false }) + ).toBeVisible(); + expect(getByText('123')).toBeVisible(); + }); + + it("does not show the node pool's firewall when undefined for a LKE Enterprise cluster ", async () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Firewall:')).not.toBeInTheDocument(); + }); + + // This check handles the current API behavior for a default firewall (0). TODO: remove this once LKE-7686 is fixed. + it("does not show the node pool's firewall when 0 for a LKE Enterprise cluster ", async () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Firewall:')).not.toBeInTheDocument(); + }); + + it("does not show the node pool's firewall for a standard LKE cluster", async () => { + server.use( + http.get('*/firewalls/*', () => { + return HttpResponse.json( + firewallFactory.build({ id: 123, label: 'my-lke-e-firewall' }) + ); + }) + ); + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Firewall:')).not.toBeInTheDocument(); + expect(queryByText('my-lke-e-firewall')).not.toBeInTheDocument(); + expect(queryByText('123')).not.toBeInTheDocument(); + }); + it('does not display the encryption status of the pool if the account lacks the capability or the feature flag is off', async () => { const { queryByText } = renderWithTheme(, { flags: { linodeDiskEncryption: false }, diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolFooter.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolFooter.tsx index e41b34f7ffb..917e0637269 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolFooter.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolFooter.tsx @@ -1,9 +1,11 @@ +import { useFirewallQuery } from '@linode/queries'; import { Box, Divider, Stack, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; +import { Link } from 'src/components/Link'; import { TagCell } from 'src/components/TagCell/TagCell'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; @@ -21,6 +23,7 @@ export interface Props { clusterTier: KubernetesTier; encryptionStatus: EncryptionStatus; isLkeClusterRestricted: boolean; + poolFirewallId: KubeNodePoolResponse['firewall_id']; poolId: number; poolVersion: KubeNodePoolResponse['k8s_version']; tags: string[]; @@ -33,6 +36,7 @@ export const NodePoolFooter = (props: Props) => { encryptionStatus, isLkeClusterRestricted, poolId, + poolFirewallId, tags, clusterId, } = props; @@ -44,6 +48,11 @@ export const NodePoolFooter = (props: Props) => { poolId ); + const { data: firewall } = useFirewallQuery( + poolFirewallId ?? -1, + Boolean(poolFirewallId) + ); + const { isDiskEncryptionFeatureEnabled } = useIsDiskEncryptionFeatureEnabled(); @@ -65,17 +74,34 @@ export const NodePoolFooter = (props: Props) => { divider={ } - flexWrap={{ sm: 'unset', xs: 'wrap' }} + flexWrap="wrap" + maxWidth="100%" rowGap={1} > - Pool ID + Pool ID: {clusterTier === 'enterprise' && poolVersion && ( - Version {poolVersion} + Version: {poolVersion} )} + {clusterTier === 'enterprise' && + poolFirewallId && + poolFirewallId > 0 && ( // This check handles the current API behavior for a default firewall (0). TODO: remove this once LKE-7686 is fixed. + + Firewall:{' '} + + {firewall?.label ?? poolFirewallId} + {' '} + {firewall?.label && ( + + (ID:{' '} + ) + + )} + + )} {isDiskEncryptionFeatureEnabled && ( )} @@ -83,6 +109,8 @@ export const NodePoolFooter = (props: Props) => {
{ clusterLabel, clusterRegionId, clusterTier, + clusterVersion, isLkeClusterRestricted, } = props; @@ -90,6 +91,8 @@ export const NodePoolsDisplay = (props: Props) => { const [selectedPoolId, setSelectedPoolId] = useState(-1); const selectedPool = pools?.find((pool) => pool.id === selectedPoolId); + const [isConfigureNodePoolDrawerOpen, setIsConfigureNodePoolDrawerOpen] = + useState(false); const [isDeleteNodePoolOpen, setIsDeleteNodePoolOpen] = useState(false); const [isLabelsAndTaintsDrawerOpen, setIsLabelsAndTaintsDrawerOpen] = useState(false); @@ -104,9 +107,6 @@ export const NodePoolsDisplay = (props: Props) => { const [numPoolsToDisplay, setNumPoolsToDisplay] = React.useState(5); const _pools = pools?.slice(0, numPoolsToDisplay); - const typesQuery = useSpecificTypes(_pools?.map((pool) => pool.type) ?? []); - const types = extendTypesQueryResult(typesQuery); - const [statusFilter, setStatusFilter] = React.useState('all'); const handleShowMore = () => { @@ -123,6 +123,11 @@ export const NodePoolsDisplay = (props: Props) => { setAddDrawerOpen(true); }; + const handleOpenConfigureNodePoolDrawer = (poolId: number) => { + setSelectedPoolId(poolId); + setIsConfigureNodePoolDrawerOpen(true); + }; + const handleOpenAutoscaleDrawer = (poolId: number) => { setSelectedPoolId(poolId); setIsAutoscaleDrawerOpen(true); @@ -267,14 +272,8 @@ export const NodePoolsDisplay = (props: Props) => { {poolsError && } {_pools?.map((thisPool) => { - const { count, disk_encryption, id, nodes, tags } = thisPool; - - const thisPoolType = types?.find( - (thisType) => thisType.id === thisPool.type - ); - - const typeLabel = thisPoolType?.formattedLabel ?? 'Unknown type'; - + const { count, disk_encryption, id, nodes, tags, label, type } = + thisPool; return ( { encryptionStatus={disk_encryption} handleAccordionClick={() => handleAccordionClick(id)} handleClickAutoscale={handleOpenAutoscaleDrawer} + handleClickConfigureNodePool={handleOpenConfigureNodePoolDrawer} handleClickLabelsAndTaints={handleOpenLabelsAndTaintsDrawer} handleClickResize={handleOpenResizeDrawer} isLkeClusterRestricted={isLkeClusterRestricted} isOnlyNodePool={pools?.length === 1} key={id} + label={label} nodes={nodes ?? []} openDeletePoolDialog={(id) => { setSelectedPoolId(id); @@ -304,15 +305,16 @@ export const NodePoolsDisplay = (props: Props) => { setSelectedPoolId(id); setIsRecycleAllPoolNodesOpen(true); }} - openRecycleNodeDialog={(nodeId, linodeLabel) => { + openRecycleNodeDialog={(nodeId) => { setSelectedNodeId(nodeId); setIsRecycleNodeOpen(true); }} + poolFirewallId={thisPool.firewall_id} poolId={thisPool.id} poolVersion={thisPool.k8s_version} statusFilter={statusFilter} tags={tags} - typeLabel={typeLabel} + type={type} /> ); })} @@ -330,6 +332,14 @@ export const NodePoolsDisplay = (props: Props) => { onClose={() => setAddDrawerOpen(false)} open={addDrawerOpen} /> + setIsConfigureNodePoolDrawerOpen(false)} + open={isConfigureNodePoolDrawerOpen} + /> void; - typeLabel: string; + type: string; } export const NodeRow = React.memo((props: NodeRowProps) => { @@ -43,10 +47,12 @@ export const NodeRow = React.memo((props: NodeRowProps) => { nodeId, nodeStatus, openRecycleNodeDialog, - typeLabel, + type, shouldShowVpcIPAddressColumns, } = props; + const { data: linodeType } = useTypeQuery(type); + const { data: ips, error: ipsError } = useLinodeIPsQuery( instanceId ?? -1, Boolean(instanceId) @@ -80,7 +86,7 @@ export const NodeRow = React.memo((props: NodeRowProps) => { ? 'active' : 'inactive'; - const labelText = label ?? typeLabel; + const labelText = label ?? linodeType?.label ?? type; const statusText = nodeStatus === 'not_ready' diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts index 8c99fb5f63f..e76774ca7e8 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts @@ -9,6 +9,7 @@ export const NodePoolTableFooter = styled(Box, { justifyContent: 'space-between', alignItems: 'center', columnGap: theme.spacingFunction(32), + flexWrap: 'wrap', rowGap: theme.spacingFunction(8), paddingTop: theme.spacingFunction(8), paddingButtom: theme.spacingFunction(8), diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx index 844eb56ba00..f809a66aeb6 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -32,7 +32,7 @@ describe('NodeTable', () => { nodes, openRecycleNodeDialog: vi.fn(), statusFilter: 'all', - typeLabel: 'g6-standard-1', + nodePoolType: 'g6-standard-1', }; it('includes label, status, and IP columns', async () => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index 9601333061c..346b287c3a6 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -27,21 +27,21 @@ export interface Props { clusterCreated: string; clusterTier: KubernetesTier; isLkeClusterRestricted: boolean; + nodePoolType: string; nodes: PoolNodeResponse[]; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; statusFilter: StatusFilter; - typeLabel: string; } export const NodeTable = React.memo((props: Props) => { const { clusterCreated, clusterTier, + nodePoolType, nodes, openRecycleNodeDialog, isLkeClusterRestricted, statusFilter, - typeLabel, } = props; const { data: profile } = useProfile(); @@ -215,7 +215,7 @@ export const NodeTable = React.memo((props: Props) => { shouldShowVpcIPAddressColumns={ shouldShowVpcIPAddressColumns } - typeLabel={typeLabel} + type={nodePoolType} /> ); })} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx index 29e192b1803..30c58390be7 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx @@ -1,28 +1,24 @@ +import { linodeTypeFactory } from '@linode/utilities'; import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { nodePoolFactory, typeFactory } from 'src/factories'; +import { nodePoolFactory } from 'src/factories'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; import type { Props } from './ResizeNodePoolDrawer'; +const type = linodeTypeFactory.build({ + id: 'fake-linode-type-id', + label: 'Linode 2GB', +}); const pool = nodePoolFactory.build({ - type: 'g6-standard-1', + type: type.id, }); const smallPool = nodePoolFactory.build({ count: 2 }); -vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); - return { - ...actual, - useSpecificTypes: vi - .fn() - .mockReturnValue([{ data: typeFactory.build({ label: 'Linode 1 GB' }) }]), - }; -}); - const props: Props = { clusterTier: 'standard', kubernetesClusterId: 1, @@ -33,10 +29,31 @@ const props: Props = { }; describe('ResizeNodePoolDrawer', () => { - it("should render the pool's type and size", async () => { + // @TODO enable this test when we begin surfacing Node Pool `label` in the UI (ECE-353) + it.skip("should render a title containing the Node Pool's label when the node pool has a label", async () => { + const nodePool = nodePoolFactory.build({ label: 'my-mock-node-pool-1' }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Resize Pool: my-mock-node-pool-1')).toBeVisible(); + }); + + it("should render a title containing the Node Pool's type initially when the node pool does not have a label", () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Resize Pool: fake-linode-type-id Plan')).toBeVisible(); + }); + + it("should render a title containing the Node Pool's type's label once the type data has loaded when the node pool does not have a label", async () => { + server.use( + http.get('*/v4*/linode/types/:id', () => HttpResponse.json(type)) + ); + const { findByText } = renderWithTheme(); - await findByText(/linode 1 GB/i); + expect(await findByText('Resize Pool: Linode 2 GB Plan')).toBeVisible(); }); it('should display a warning if the user tries to resize a node pool to < 3 nodes', async () => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index df91b3347db..f575f526968 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -1,4 +1,4 @@ -import { useSpecificTypes } from '@linode/queries'; +import { useTypeQuery } from '@linode/queries'; import { ActionsPanel, CircleProgress, @@ -17,14 +17,13 @@ import { MAX_NODES_PER_POOL_STANDARD_TIER, } from 'src/features/Kubernetes/constants'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; -import { extendType } from 'src/utilities/extendType'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; import { getKubernetesMonthlyPrice } from 'src/utilities/pricing/kubernetes'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../constants'; -import { hasInvalidNodePoolPrice } from './utils'; +import { hasInvalidNodePoolPrice, useNodePoolDisplayLabel } from './utils'; import type { KubeNodePoolResponse, @@ -69,11 +68,12 @@ export const ResizeNodePoolDrawer = (props: Props) => { } = props; const { classes } = useStyles(); - const typesQuery = useSpecificTypes(nodePool?.type ? [nodePool.type] : []); - const isLoadingTypes = typesQuery[0]?.isLoading ?? false; - const planType = typesQuery[0]?.data - ? extendType(typesQuery[0].data) - : undefined; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); + + const { data: planType, isLoading: isLoadingTypes } = useTypeQuery( + nodePool?.type ?? '', + Boolean(nodePool) + ); const { error, @@ -142,7 +142,7 @@ export const ResizeNodePoolDrawer = (props: Props) => { {isLoadingTypes ? ( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts index ac3166ae4f3..6b2db97bbb5 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts @@ -1,4 +1,11 @@ -import { hasInvalidNodePoolPrice } from './utils'; +import { linodeTypeFactory } from '@linode/utilities'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { nodePoolFactory } from 'src/factories/kubernetesCluster'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { wrapWithTheme as wrapper } from 'src/utilities/testHelpers'; + +import { hasInvalidNodePoolPrice, useNodePoolDisplayLabel } from './utils'; describe('hasInvalidNodePoolPrice', () => { it('returns false if the prices are both zero, which is valid', () => { @@ -17,3 +24,69 @@ describe('hasInvalidNodePoolPrice', () => { expect(hasInvalidNodePoolPrice(null, null)).toBe(true); }); }); + +describe('useNodePoolDisplayLabel', () => { + // @TODO remove skip this when it's time to surface Node Pool labels in the UI (ECE-353) + it.skip("returns the node pools's label if it has one", () => { + const nodePool = nodePoolFactory.build({ label: 'my-node-pool-1' }); + + const { result } = renderHook(() => useNodePoolDisplayLabel(nodePool), { + wrapper, + }); + + expect(result.current).toBe('my-node-pool-1'); + }); + + it("returns the node pools's type ID initialy if it does not have an explicit label", () => { + const nodePool = nodePoolFactory.build({ + label: '', + type: 'g6-fake-type-1', + }); + + const { result } = renderHook(() => useNodePoolDisplayLabel(nodePool), { + wrapper, + }); + + expect(result.current).toBe('g6-fake-type-1'); + }); + + it('appends a suffix to the Linode type if one is provided', () => { + const nodePool = nodePoolFactory.build({ + label: '', + type: 'g6-fake-type-1', + }); + + const { result } = renderHook( + () => useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }), + { + wrapper, + } + ); + + expect(result.current).toBe('g6-fake-type-1 Plan'); + }); + + it("returns the node pools's type's `label` once it loads if it does not have an explicit label", async () => { + const type = linodeTypeFactory.build({ + id: 'g6-fake-type-1', + label: 'Fake Linode 2GB', + }); + + server.use( + http.get('*/v4*/linode/types/:id', () => HttpResponse.json(type)) + ); + + const nodePool = nodePoolFactory.build({ + label: '', + type: type.id, + }); + + const { result } = renderHook(() => useNodePoolDisplayLabel(nodePool), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBe('Fake Linode 2 GB'); + }); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts index 19ffd7cfe44..520f11934cc 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts @@ -1,5 +1,12 @@ +import { useTypeQuery } from '@linode/queries'; +import { formatStorageUnits } from '@linode/utilities'; + import type { NodeRow } from './NodeRow'; -import type { Linode, PoolNodeResponse } from '@linode/api-v4'; +import type { + KubeNodePoolResponse, + Linode, + PoolNodeResponse, +} from '@linode/api-v4'; /** * Checks whether prices are valid - 0 is valid, but undefined and null prices are invalid. @@ -37,3 +44,62 @@ export const nodeToRow = ( shouldShowVpcIPAddressColumns, }; }; + +interface NodePoolDisplayLabelOptions { + /** + * If set to `true`, the hook will only return the node pool's type's `id` or `label` + * and never its actual `label` + */ + ignoreNodePoolsLabel?: boolean; + /** + * Appends a suffix to the Node Pool's type `id` or `label` if it is returned + */ + suffix?: string; +} + +/** + * Given a Node Pool, this hook will return the Node Pool's display label. + * + * We use this helper rather than just using `label` on the Node Pool because the `label` + * field is optional was added later on to the API. For Node Pools without explicit labels, + * we identify them in the UI by their plan's label. + * + * @returns The Node Pool's label + */ +export const useNodePoolDisplayLabel = ( + nodePool: Pick | undefined, + options?: NodePoolDisplayLabelOptions +) => { + const { data: type } = useTypeQuery( + nodePool?.type ?? '', + Boolean(nodePool?.type) + ); + + if (!nodePool) { + return ''; + } + + // @TODO uncomment this when it's time to surface Node Pool labels in the UI (ECE-353) + // If the Node Pool has an explict label, return it. + // if (nodePool.label && !options?.ignoreNodePoolsLabel) { + // return nodePool.label; + // } + + // If the Node Pool's type is loaded, return that type's formatted label. + if (type) { + const typeLabel = formatStorageUnits(type.label); + + if (options?.suffix) { + return `${typeLabel} ${options.suffix}`; + } + + return typeLabel; + } + + // As a last resort, fallback to the Node Pool's type ID. + if (options?.suffix) { + return `${nodePool.type} ${options.suffix}`; + } + + return nodePool.type; +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx index 12b682e555d..10052d81f98 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx @@ -38,10 +38,7 @@ export const KubernetesPlanSelectionTable = ( } = props; return ( - +
{tableCells.map(({ cellName, center, noWrap, testId }) => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx index c0fef24aaa0..851a8200397 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx @@ -210,8 +210,7 @@ export const NodePoolConfigDrawer = (props: Props) => { )} - - {selectedTier === 'enterprise' && } + { +interface KubernetesVersionFieldOptions { + show: boolean; + versions: string[]; +} + +interface Props { + clusterTier: KubernetesTier; + firewallSelectOptions?: NodePoolFirewallSelectProps; + versionFieldOptions?: KubernetesVersionFieldOptions; +} + +export const NodePoolConfigOptions = (props: Props) => { + const { versionFieldOptions, clusterTier, firewallSelectOptions } = props; + const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); const { control } = useFormContext(); + const versionOptions = + versionFieldOptions?.versions.map((version) => ({ + label: version, + })) ?? []; + + // @TODO uncomment and wire this up when we begin surfacing the Text Field for a Node Pool's `label` (ECE-353) + // const labelPlaceholder = useNodePoolDisplayLabel(nodePool, { + // ignoreNodePoolsLabel: true, + // }); + return ( - <> + + {/* + // @TODO allow users to edit Node Pool `label` and `tags` because the API supports it. (ECE-353) ( - ( + )} /> - - + ( + field.onChange(tags.map((tag) => tag.value))} + tagError={fieldState.error?.message} + value={ + field.value?.map((tag) => ({ label: tag, value: tag })) ?? [] + } + /> + )} + /> + */} + {/* LKE Enterprise cluster node pools have more configurability */} + {clusterTier === 'enterprise' && isLkeEnterprisePostLAFeatureEnabled && ( + <> + ( + + )} + /> + {versionFieldOptions?.show && ( + ( + field.onChange(version.label)} + options={versionOptions} + value={versionOptions.find( + (option) => option.label === field.value + )} + /> + )} + /> + )} + + + )} + ); }; diff --git a/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx b/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx index 4f39d6561d9..a7d73cffcbc 100644 --- a/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx +++ b/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx @@ -1,75 +1,142 @@ import { + FormControl, FormControlLabel, + FormHelperText, Radio, RadioGroup, Stack, - Typography, + TooltipIcon, } from '@linode/ui'; import React from 'react'; -import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; + +import { FormLabel } from 'src/components/FormLabel'; import { FirewallSelect } from '../Firewalls/components/FirewallSelect'; import type { CreateNodePoolData } from '@linode/api-v4'; -export const NodePoolFirewallSelect = () => { +export interface NodePoolFirewallSelectProps { + /** + * When standard LKE supports firewall, we will allow Firewalls to be add & removed + * Use this prop to allow/prevent Firwall from being removed on a Node Pool + */ + allowFirewallRemoval?: boolean; + /** + * An optional tooltip message that shows beside the "Use default firewall" radio label + */ + defaultFirewallRadioTooltip?: string; + /** + * Disables the "Use default firewall" option + */ + disableDefaultFirewallRadio?: boolean; +} + +export const NodePoolFirewallSelect = (props: NodePoolFirewallSelectProps) => { + const { + defaultFirewallRadioTooltip, + disableDefaultFirewallRadio, + allowFirewallRemoval, + } = props; const { control } = useFormContext(); - const watchedFirewallId = useWatch({ control, name: 'firewall_id' }); + const { field, fieldState, formState } = useController({ + control, + name: 'firewall_id', + rules: { + validate: (value) => { + if (isUsingOwnFirewall && value === null) { + if (disableDefaultFirewallRadio) { + return 'You must select a Firewall.'; + } + return 'You must either select a Firewall or select the default firewall.'; + } + return true; + }, + }, + }); const [isUsingOwnFirewall, setIsUsingOwnFirewall] = React.useState( - Boolean(watchedFirewallId) + Boolean(field.value) ); return ( - - ({ - font: theme.tokens.alias.Typography.Label.Bold.S, - })} - > - Firewall - - ) => { - setIsUsingOwnFirewall(e.target.value === 'yes'); - }} - value={isUsingOwnFirewall} - > - } - label="Use default firewall" - value="no" - /> - } - label="Select existing firewall" - value="yes" - /> - - {isUsingOwnFirewall && ( - ( - field.onChange(firewall?.id ?? null)} - placeholder="Select firewall" - value={field.value} - /> - )} - rules={{ - validate: (value) => { - if (isUsingOwnFirewall && !value) { - return 'You must either select a Firewall or select the default firewall.'; + + + + Firewall + + { + setIsUsingOwnFirewall(value === 'yes'); + + if (value === 'yes') { + // If the user chooses to use an existing firewall... + if (formState.defaultValues?.firewall_id) { + // If the Node Pool has a `firewall_id` set, restore that value (For the edit Node Pool flow) + field.onChange(formState.defaultValues?.firewall_id); + } else { + // Set `firewall_id` to `null` so that our validation forces the user to pick a firewall or pick the default backend-generated one + field.onChange(null); } - return true; - }, + } else { + field.onChange(formState.defaultValues?.firewall_id); + } + }} + value={isUsingOwnFirewall ? 'yes' : 'no'} + > + } + disabled={disableDefaultFirewallRadio} + label={ + <> + Use default firewall + {defaultFirewallRadioTooltip && ( + + )} + + } + value="no" + /> + } + label="Select existing firewall" + value="yes" + /> + + {!isUsingOwnFirewall && ( + + {fieldState.error?.message} + + )} + + {isUsingOwnFirewall && ( + { + if (firewall) { + field.onChange(firewall.id); + } else { + // `0` tells the backend to remove the firewall + field.onChange(0); + } }} + placeholder="Select firewall" + value={field.value} /> )} diff --git a/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx b/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx index fb181d04ecd..3d10e981f0e 100644 --- a/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx +++ b/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx @@ -4,19 +4,17 @@ import React from 'react'; import { UPDATE_STRATEGY_OPTIONS } from './constants'; interface Props { - label?: string; - noMarginTop?: boolean; onChange: (value: string | undefined) => void; value: string | undefined; } export const NodePoolUpdateStrategySelect = (props: Props) => { - const { onChange, value, noMarginTop, label } = props; + const { onChange, value } = props; return ( onChange(updateStrategy?.value)} options={UPDATE_STRATEGY_OPTIONS} placeholder="Select an Update Strategy" diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx index 4cf2120d0fa..4bdd304b90d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx @@ -88,7 +88,7 @@ export const MaintenancePolicy = () => { : MAINTENANCE_POLICY_SELECT_REGION_TEXT : undefined, }} - value={field.value ?? undefined} + value={field.value ?? null} /> )} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx index 9ea728a96bb..62dc33ee27b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx @@ -1,5 +1,6 @@ import { firewallQueries, useQueryClient } from '@linode/queries'; import { + Box, FormControl, Radio, RadioGroup, @@ -9,11 +10,12 @@ import { import { Grid } from '@mui/material'; import { useSnackbar } from 'notistack'; import React from 'react'; -import { useController, useFormContext } from 'react-hook-form'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; import { FormLabel } from 'src/components/FormLabel'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; +import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import { getDefaultFirewallForInterfacePurpose } from './utilities'; import type { LinodeCreateFormValues } from '../utilities'; @@ -57,6 +59,16 @@ export const InterfaceType = ({ index }: Props) => { name: `linodeInterfaces.${index}.purpose`, }); + const interfaceGeneration = useWatch({ + control, + name: 'interface_generation', + }); + + const createType = useGetLinodeCreateType(); + const isCreatingFromBackup = createType === 'Backups'; + + const disabled = isCreatingFromBackup && interfaceGeneration !== 'linode'; + const onChange = async (value: InterfacePurpose) => { // Change the interface purpose (Public, VPC, VLAN) field.onChange(value); @@ -96,7 +108,20 @@ export const InterfaceType = ({ index }: Props) => { return ( - Network Connection + + Network Connection + {disabled && ( + + )} + The default interface used by this Linode to route network traffic. Additional interfaces can be added after the Linode is created. @@ -109,7 +134,8 @@ export const InterfaceType = ({ index }: Props) => { {interfaceTypes.map((interfaceType) => ( { key={interfaceType.purpose} onClick={() => onChange(interfaceType.purpose)} renderIcon={() => ( - + )} renderVariant={() => ( { const showTwoStepRegion = isGeckoLAEnabled && isDistributedRegionSupported(createType ?? 'OS'); - const onChange = async (region: RegionType) => { + const onChange = async (region: null | RegionType) => { const values = getValues(); - - field.onChange(region.id); + field.onChange(region?.id); if (values.hasSignedEUAgreement) { // Reset the EU agreement checkbox if they checked it so they have to re-agree when they change regions @@ -114,14 +113,14 @@ export const Region = React.memo(() => { if ( values.metadata?.user_data && - !region.capabilities.includes('Metadata') + !region?.capabilities.includes('Metadata') ) { // Clear metadata only if the new region does not support it setValue('metadata.user_data', null); } // Handle maintenance policy based on region capabilities - if (region.capabilities.includes('Maintenance Policy')) { + if (region?.capabilities.includes('Maintenance Policy')) { // If the region supports maintenance policy, set it to the default value // or keep the current value if it's already set if (!values.maintenance_policy) { @@ -140,20 +139,20 @@ export const Region = React.memo(() => { // Because distributed regions do not support some features, // we must disable those features here. Keep in mind, we should // prevent the user from enabling these features in their respective components. - if (region.site_type === 'distributed') { + if (region?.site_type === 'distributed') { setValue('backups_enabled', false); setValue('private_ip', false); } if (isDiskEncryptionFeatureEnabled) { - if (region.site_type === 'distributed') { + if (region?.site_type === 'distributed') { // If a distributed region is selected, make sure we don't send disk_encryption in the payload. setValue('disk_encryption', undefined); } else { // Enable disk encryption by default if the region supports it const defaultDiskEncryptionValue = - region.capabilities.includes('Disk Encryption') || - region.capabilities.includes('LA Disk Encryption') + region?.capabilities.includes('Disk Encryption') || + region?.capabilities.includes('LA Disk Encryption') ? 'enabled' : undefined; @@ -161,7 +160,7 @@ export const Region = React.memo(() => { } } - if (!isLabelFieldDirty) { + if (!isLabelFieldDirty && region) { // Auto-generate the Linode label because the region is included in the generated label const label = await getGeneratedLinodeLabel({ queryClient, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts index aafbbbe43a3..bf7e308d7a3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts @@ -28,6 +28,10 @@ export const linodesCreateTypes = Array.from(linodesCreateTypesMap.keys()); export const useGetLinodeCreateType = () => { const { pathname } = useLocation() as { pathname: LinkProps['to'] }; + return getLinodeCreateType(pathname); +}; + +export const getLinodeCreateType = (pathname: LinkProps['to']) => { switch (pathname) { case '/linodes/create/backups': return 'Backups'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx index 1b7eaa011c7..13fa7e9915e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx @@ -61,7 +61,7 @@ const GEOGRAPHICAL_AREA_OPTIONS: GeographicalAreaOption[] = [ ]; interface Props { - onChange: (region: RegionType) => void; + onChange: (region: null | RegionType) => void; } type CombinedProps = Props & Omit, 'onChange'>; @@ -69,8 +69,10 @@ type CombinedProps = Props & Omit, 'onChange'>; export const TwoStepRegion = (props: CombinedProps) => { const { disabled, disabledRegions, errorText, onChange, value } = props; + const [tabIndex, setTabIndex] = React.useState(0); + const [regionFilter, setRegionFilter] = - React.useState('distributed'); + React.useState('distributed-ALL'); const { data: regions } = useRegionsQuery(); const createType = useGetLinodeCreateType(); @@ -97,7 +99,15 @@ export const TwoStepRegion = (props: CombinedProps) => { } /> - + { + if (index !== tabIndex) { + setTabIndex(index); + // M3-9469: Reset region selection when switching between site types + onChange(null); + } + }} + > Core Distributed @@ -120,7 +130,7 @@ export const TwoStepRegion = (props: CombinedProps) => { onChange={(e, region) => onChange(region)} regionFilter="core" regions={regions ?? []} - value={value} + value={value ?? null} /> @@ -131,8 +141,8 @@ export const TwoStepRegion = (props: CombinedProps) => { { if (selectedOption?.value) { @@ -140,9 +150,11 @@ export const TwoStepRegion = (props: CombinedProps) => { } }} options={GEOGRAPHICAL_AREA_OPTIONS} - value={GEOGRAPHICAL_AREA_OPTIONS.find( - (option) => option.value === regionFilter - )} + value={ + GEOGRAPHICAL_AREA_OPTIONS.find( + (option) => option.value === regionFilter + ) ?? null + } /> { onChange={(e, region) => onChange(region)} regionFilter={regionFilter} regions={regions ?? []} - value={value} + value={value ?? null} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx index fb40be0ac3f..8ae41de6039 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx @@ -71,6 +71,8 @@ export const VPC = () => { : 'Assign this Linode to an existing VPC.'; const createType = useGetLinodeCreateType(); + const isCreatingFromBackup = createType === 'Backups'; + const disabled = !regionSupportsVPCs || isCreatingFromBackup; const vpcFormEventOptions: LinodeCreateFormEventOptions = { createType: createType ?? 'OS', @@ -82,7 +84,16 @@ export const VPC = () => { return ( - VPC + + VPC + {isCreatingFromBackup && ( + + )} + {copy}{' '} { name="interfaces.0.vpc_id" render={({ field, fieldState }) => ( { handleTabChange(index); if (index !== tabIndex) { + const newTab = tabs[index]; + const newLinodeCreateType = getLinodeCreateType(newTab.to); // Get the default values for the new tab and reset the form - defaultValues(linodeCreateType, search, queryClient, { + defaultValues(newLinodeCreateType, search, queryClient, { isLinodeInterfacesEnabled, isVMHostMaintenanceEnabled, }).then(form.reset); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx index 10b3f74db37..9184714117a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx @@ -70,6 +70,38 @@ describe('getLinodeCreatePayload', () => { placement_group: undefined, }); }); + + it('should remove interface from the payload if using legacy interfaces with the new UI and the linode is being created from backups', () => { + const values = { + ...createLinodeRequestFactory.build({ + interface_generation: 'legacy_config', + backup_id: 1, + }), + linodeInterfaces: [{ purpose: 'public', public: {} }], + } as LinodeCreateFormValues; + + expect( + getLinodeCreatePayload(values, { + isShowingNewNetworkingUI: true, + }).interfaces + ).toEqual(undefined); + }); + + it('should not remove interface from the payload when using new interfaces and creating from a backup', () => { + const values = { + ...createLinodeRequestFactory.build({ + interface_generation: 'linode', + backup_id: 1, + }), + linodeInterfaces: [{ purpose: 'public', public: {} }], + } as LinodeCreateFormValues; + + expect( + getLinodeCreatePayload(values, { + isShowingNewNetworkingUI: true, + }).interfaces + ).toEqual([{ public: {}, vpc: null, vlan: null }]); + }); }); describe('getInterfacesPayload', () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 9eafb8457d2..204c157d91b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -98,9 +98,11 @@ export const getLinodeCreatePayload = ( ); values.firewall_id = undefined; } else { - values.interfaces = formValues.linodeInterfaces.map( - getLegacyInterfaceFromLinodeInterface - ); + values.interfaces = formValues.backup_id + ? undefined + : formValues.linodeInterfaces.map( + getLegacyInterfaceFromLinodeInterface + ); } } else { values.interfaces = getInterfacesPayload( diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx index ed4e4aeb4fc..c9323412be5 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx @@ -147,6 +147,7 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { // but for the sake of the user experience, we choose to disable the "Add a tag" button in the UI because // restricted users can't see account tags using GET /v4/tags disabled={!permissions.is_account_admin} + entity="Linode" entityLabel={linodeLabel} sx={{ width: '100%', diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx index 418de59a277..c870d9c0c3b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx @@ -93,7 +93,6 @@ describe('LinodeConfigDialog', () => { it('should return a with NATTED_PUBLIC_IP_HELPER_TEXT under the appropriate conditions', () => { const valueReturned = unrecommendedConfigNoticeSelector({ _interface: vpcInterface, - isLKEEnterpriseCluster: false, primaryInterfaceIndex: editableFields.interfaces.findIndex( (element) => element.primary === true ), @@ -114,7 +113,6 @@ describe('LinodeConfigDialog', () => { const valueReturned = unrecommendedConfigNoticeSelector({ _interface: vpcInterfaceWithoutNAT, - isLKEEnterpriseCluster: false, primaryInterfaceIndex: editableFields.interfaces.findIndex( (element) => element.primary === true ), @@ -140,7 +138,6 @@ describe('LinodeConfigDialog', () => { const valueReturned = unrecommendedConfigNoticeSelector({ _interface: vpcInterfacePrimaryWithoutNAT, - isLKEEnterpriseCluster: false, primaryInterfaceIndex: editableFieldsWithSingleInterface.interfaces.findIndex( (element) => element.primary === true @@ -162,7 +159,6 @@ describe('LinodeConfigDialog', () => { const valueReturned = unrecommendedConfigNoticeSelector({ _interface: publicInterface, - isLKEEnterpriseCluster: false, primaryInterfaceIndex: editableFieldsWithoutVPCInterface.interfaces.findIndex( (element) => element.primary === true diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 9364ebf9614..b7b1982cd61 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -44,10 +44,6 @@ import type { JSX } from 'react'; import { FormLabel } from 'src/components/FormLabel'; import { Link } from 'src/components/Link'; -import { - useIsLkeEnterpriseEnabled, - useKubernetesBetaEndpoint, -} from 'src/features/Kubernetes/kubeUtils'; import { DeviceSelection } from 'src/features/Linodes/LinodesDetail/LinodeRescue/DeviceSelection'; import { titlecase } from 'src/features/Linodes/presentation'; import { @@ -55,7 +51,6 @@ import { NATTED_PUBLIC_IP_HELPER_TEXT, NOT_NATTED_HELPER_TEXT, } from 'src/features/VPCs/constants'; -import { useKubernetesClusterQuery } from 'src/queries/kubernetes'; import { handleFieldErrors, handleGeneralErrors, @@ -65,6 +60,7 @@ import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { InterfaceSelect } from '../LinodeSettings/InterfaceSelect'; import { KernelSelect } from '../LinodeSettings/KernelSelect'; import { getSelectedDeviceOption } from '../utilities'; +import { deviceSlots, pathsOptions, pathsOptionsLabels } from './constants'; import { StyledDivider, StyledFormControl, @@ -175,17 +171,6 @@ const defaultLegacyInterfaceFieldValues: EditableFields = { interfaces: defaultInterfaceList, }; -const pathsOptions = [ - { label: '/dev/sda', value: '/dev/sda' }, - { label: '/dev/sdb', value: '/dev/sdb' }, - { label: '/dev/sdc', value: '/dev/sdc' }, - { label: '/dev/sdd', value: '/dev/sdd' }, - { label: '/dev/sde', value: '/dev/sde' }, - { label: '/dev/sdf', value: '/dev/sdf' }, - { label: '/dev/sdg', value: '/dev/sdg' }, - { label: '/dev/sdh', value: '/dev/sdh' }, -]; - const interfacesToState = (interfaces?: Interface[] | null) => { if (!interfaces || interfaces.length === 0) { return defaultInterfaceList; @@ -243,7 +228,6 @@ const interfacesToPayload = (interfaces?: ExtendedInterface[] | null) => { return filteredInterfaces as Interface[]; }; -const deviceSlots = ['sda', 'sdb', 'sdc', 'sdd', 'sde', 'sdf', 'sdg', 'sdh']; const deviceCounterDefault = 1; // DiskID reserved on the back-end to indicate Finnix. @@ -254,23 +238,15 @@ export const LinodeConfigDialog = (props: Props) => { const { config, linodeId, onClose, open } = props; const { data: linode } = useLinodeQuery(linodeId, open); + const availableMemory = linode?.specs.memory ?? 0; + if (availableMemory < 0) { + // eslint-disable-next-line no-console + console.warn('Invalid memory value:', availableMemory); + } + const deviceLimit = Math.max(8, Math.min(availableMemory / 1024, 64)); const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); - const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); - - const { isAPLAvailabilityLoading, isUsingBetaEndpoint } = - useKubernetesBetaEndpoint(); - - const { data: cluster } = useKubernetesClusterQuery({ - enabled: - isLkeEnterpriseLAFeatureEnabled && - Boolean(linode?.lke_cluster_id) && - !isAPLAvailabilityLoading, - id: linode?.lke_cluster_id ?? -1, - isUsingBetaEndpoint, - }); - const { enqueueSnackbar } = useSnackbar(); const virtModeCaptionId = React.useId(); @@ -908,7 +884,7 @@ export const LinodeConfigDialog = (props: Props) => { values.devices?.[slot as keyof DevicesAsStrings] ?? '' } onChange={handleDevicesChanges} - slots={deviceSlots} + slots={deviceSlots.slice(0, deviceLimit)} /> { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx index dd3411a217c..1946105a39e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx @@ -7,7 +7,25 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { VolumesUpgradeBanner } from './VolumesUpgradeBanner'; +const queryMocks = vi.hoisted(() => ({ + usePermissions: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('VolumesUpgradeBanner', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + update_volume: true, + }); + }); + it('should render if there is an upgradable volume', async () => { const volume = volumeFactory.build(); const notification = notificationFactory.build({ diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx index 6d5342c218a..82c8e6027d2 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx @@ -39,7 +39,7 @@ import { LinodesLandingEmptyState } from './LinodesLandingEmptyState'; import { ListView } from './ListView'; import type { Action } from '../PowerActionsDialogOrDrawer'; -import type { Config } from '@linode/api-v4/lib/linodes/types'; +import type { Config, PermissionType } from '@linode/api-v4/lib/linodes/types'; import type { APIError } from '@linode/api-v4/lib/types'; import type { AnyRouter, @@ -77,6 +77,9 @@ export interface LinodeHandlers { onOpenResizeDialog: () => void; } +type PermissionsSubset = T; +type LinodesPermissions = PermissionsSubset<'create_linode'>; + export interface LinodesLandingProps { filteredLinodesLoading: boolean; handleRegionFilter: (regionFilter: RegionFilter) => void; @@ -92,7 +95,7 @@ export interface LinodesLandingProps { orderBy: string; sortedData: LinodeWithMaintenance[] | null; }; - permissions: Record; + permissions: Record; regionFilter: RegionFilter; search: SearchParamOptions['search']; someLinodesHaveScheduledMaintenance: boolean; diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index d443725d612..b998ebfcfe9 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -203,7 +203,7 @@ export const ConfigureForm = React.memo((props: Props) => { textFieldProps={{ helperText, }} - value={selectedRegion} + value={selectedRegion ?? null} /> {shouldDisplayPriceComparison && selectedRegion && ( { loading={configsLoading} onChange={(_, option) => setSelectConfigID(option?.value ?? null)} options={configOptions} - value={configOptions.find( - (option) => option.value === selectedConfigID - )} + value={ + configOptions.find((option) => option.value === selectedConfigID) ?? + null + } /> )} {props.action === 'Power Off' && ( diff --git a/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx index 69ffc219779..f7e8cdc5ffc 100644 --- a/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx +++ b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx @@ -1,7 +1,6 @@ import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; @@ -30,7 +29,6 @@ export const LoginHistoryLanding = () => { <> - diff --git a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx index 7c4b14afe93..cd78b4716bd 100644 --- a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx +++ b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx @@ -1,7 +1,6 @@ import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; @@ -30,7 +29,6 @@ export const MaintenanceLanding = () => { <> - diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx index cefd263bc45..6f6850b45d8 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx @@ -13,7 +13,7 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useTabs } from 'src/hooks/useTabs'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -38,11 +38,11 @@ export const NodeBalancerDetail = () => { isLoading, } = useNodeBalancerQuery(Number(id), Boolean(id)); - const isNodeBalancerReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'nodebalancer', - id: nodebalancer?.id, - }); + const { data: permissions } = usePermissions( + 'nodebalancer', + ['update_nodebalancer'], + nodebalancer?.id + ); const { handleTabChange, tabIndex, tabs } = useTabs([ { @@ -90,13 +90,14 @@ export const NodeBalancerDetail = () => { }, pathname: `/nodebalancers/${nodebalancer.label}`, }} + disabledBreadcrumbEditButton={!permissions.update_nodebalancer} docsLabel="Docs" docsLink="https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-nodebalancers" spacingBottom={4} title={nodebalancer.label} /> {errorMap.none && } - {isNodeBalancerReadOnly && ( + {!permissions.update_nodebalancer && ( ({ .fn() .mockReturnValue({ data: undefined }), useParams: vi.fn().mockReturnValue({}), + userPermissions: vi.fn(() => ({ + data: { + update_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -188,4 +197,31 @@ describe('SummaryPanel', () => { ); }); }); + + it('should disable "Add a tag" if user does not have permission', () => { + const { getByText } = renderWithTheme(, { + flags: { nodebalancerVpc: true }, + }); + + // Tags panel + expect(getByText('Tags')).toBeVisible(); + expect(getByText('Add a tag')).toBeVisible(); + expect(getByText('Add a tag')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable "Add a tag" if user has permission', () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_nodebalancer: true, + }, + }); + const { getByText } = renderWithTheme(, { + flags: { nodebalancerVpc: true }, + }); + + // Tags panel + expect(getByText('Tags')).toBeVisible(); + expect(getByText('Add a tag')).toBeVisible(); + expect(getByText('Add a tag')).not.toHaveAttribute('aria-disabled', 'true'); + }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index af29a8d91f9..e2003220d02 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -15,9 +15,9 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { TagCell } from 'src/components/TagCell/TagCell'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useKubernetesClusterQuery } from 'src/queries/kubernetes'; import { useIsNodebalancerVPCEnabled } from '../../utils'; @@ -42,11 +42,11 @@ export const SummaryPanel = () => { ); const displayFirewallLink = !!attachedFirewallData?.data?.length; - const isNodeBalancerReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'nodebalancer', - id: nodebalancer?.id, - }); + const { data: permissions } = usePermissions( + 'nodebalancer', + ['update_nodebalancer'], + nodebalancer?.id + ); const flags = useIsNodebalancerVPCEnabled(); @@ -265,7 +265,8 @@ export const SummaryPanel = () => { Tags updateNodeBalancer({ tags })} view="panel" diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx index 0de7d906e43..8de0fbc6cf6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx @@ -9,6 +9,15 @@ const navigate = vi.fn(); const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => navigate), useRouter: vi.fn(() => vi.fn()), + userPermissions: vi.fn(() => ({ + data: { + delete_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -41,7 +50,41 @@ describe('NodeBalancerActionMenu', () => { expect(getByText('Delete')).toBeVisible(); }); + it('should disable "Delete" if the user does not have permissions', async () => { + const { getAllByRole, getByText } = renderWithTheme( + + ); + const actionBtn = getAllByRole('button')[0]; + expect(actionBtn).toBeInTheDocument(); + await userEvent.click(actionBtn); + + const deleteBtn = getByText('Delete'); + expect(deleteBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable "Delete" if the user has permissions', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_nodebalancer: true, + }, + }); + const { getAllByRole, getByText } = renderWithTheme( + + ); + const actionBtn = getAllByRole('button')[0]; + expect(actionBtn).toBeInTheDocument(); + await userEvent.click(actionBtn); + + const deleteBtn = getByText('Delete'); + expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true'); + }); + it('triggers the action to delete the NodeBalancer', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_nodebalancer: true, + }, + }); const { getByText } = renderWithTheme( ); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx index b473e8497ed..120b38918da 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useIsNodebalancerVPCEnabled } from '../utils'; @@ -24,11 +24,11 @@ export const NodeBalancerActionMenu = (props: Props) => { const { nodeBalancerId } = props; - const isNodeBalancerReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'nodebalancer', - id: nodeBalancerId, - }); + const { data: permissions } = usePermissions( + 'nodebalancer', + ['delete_nodebalancer'], + nodeBalancerId + ); const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); @@ -56,7 +56,7 @@ export const NodeBalancerActionMenu = (props: Props) => { title: 'Settings', }, { - disabled: isNodeBalancerReadOnly, + disabled: !permissions.delete_nodebalancer, onClick: () => { navigate({ params: { @@ -66,7 +66,7 @@ export const NodeBalancerActionMenu = (props: Props) => { }); }, title: 'Delete', - tooltip: isNodeBalancerReadOnly + tooltip: !permissions.delete_nodebalancer ? getRestrictedResourceText({ action: 'delete', resourceType: 'NodeBalancers', diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx index 35f1b35eba9..110368fcc8b 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx @@ -11,6 +11,15 @@ import { NodeBalancerTableRow } from './NodeBalancerTableRow'; const navigate = vi.fn(); const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => navigate), + userPermissions: vi.fn(() => ({ + data: { + delete_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -56,6 +65,11 @@ describe('NodeBalancerTableRow', () => { }); it('deletes the NodeBalancer', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_nodebalancer: true, + }, + }); const { getByText } = renderWithTheme(); const deleteButton = getByText('Delete'); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx index 16187a749a8..72934f64d7a 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx @@ -1,5 +1,5 @@ import { nodeBalancerFactory } from '@linode/utilities'; -import { waitForElementToBeRemoved } from '@testing-library/react'; +import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import * as React from 'react'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -12,6 +12,15 @@ const queryMocks = vi.hoisted(() => ({ useMatch: vi.fn().mockReturnValue({}), useNavigate: vi.fn(() => vi.fn()), useParams: vi.fn().mockReturnValue({}), + userPermissions: vi.fn(() => ({ + data: { + create_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -83,4 +92,39 @@ describe('NodeBalancersLanding', () => { expect(getByText('IP Address')).toBeVisible(); expect(getByText('Region')).toBeVisible(); }); + + it('should disable the "Create NodeBalancer" button if the user does not have permission', async () => { + const { getByRole } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); + + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('should enable the "Create NodeBalancer" button if the user has permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_nodebalancer: true, + }, + }); + + const { getByRole } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); + + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx index 63e62b7d023..5b88ac4b80c 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx @@ -15,9 +15,9 @@ import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { NodeBalancerDeleteDialog } from '../NodeBalancerDeleteDialog'; import { useIsNodebalancerVPCEnabled } from '../utils'; @@ -35,9 +35,10 @@ export const NodeBalancersLanding = () => { initialPage: 1, preferenceKey, }); - const isRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_nodebalancers', - }); + + const { data: permissions } = usePermissions('account', [ + 'create_nodebalancer', + ]); const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { @@ -101,7 +102,7 @@ export const NodeBalancersLanding = () => { resourceType: 'NodeBalancers', }), }} - disabledCreateButton={isRestricted} + disabledCreateButton={!permissions.create_nodebalancer} docsLink="https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-nodebalancers" entity="NodeBalancer" onButtonClick={() => navigate({ to: '/nodebalancers/create' })} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx index b04b3f64eaf..2da3b76b5cc 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx @@ -1,13 +1,21 @@ import { waitFor } from '@testing-library/react'; import * as React from 'react'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { NodeBalancerLandingEmptyState } from './NodeBalancersLandingEmptyState'; const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => vi.fn()), + userPermissions: vi.fn(() => ({ + data: { + create_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -18,29 +26,39 @@ vi.mock('@tanstack/react-router', async () => { }; }); -vi.mock('src/hooks/useRestrictedGlobalGrantCheck'); - // Note: An integration test confirming the helper text and enabled Create NodeBalancer button already exists, so we're just checking for a disabled create button here describe('NodeBalancersLandingEmptyState', () => { - afterEach(() => { - vi.resetAllMocks(); + it('should disable the "Create NodeBalancer" button if the user does not have permission', async () => { + const { getByRole } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); + + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).toHaveAttribute('aria-disabled', 'true'); + }); }); - it('disables the Create NodeBalancer button if user does not have permissions to create a NodeBalancer', async () => { - // disables the create button - vi.mocked(useRestrictedGlobalGrantCheck).mockReturnValue(true); + it('should enable the "Create NodeBalancer" button if the user has permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_nodebalancer: true, + }, + }); - const { getByText } = renderWithTheme(); + const { getByRole } = renderWithTheme(); await waitFor(() => { - const createNodeBalancerButton = getByText('Create NodeBalancer').closest( - 'button' - ); + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); - expect(createNodeBalancerButton).toBeDisabled(); - expect(createNodeBalancerButton).toHaveAttribute( - 'data-qa-tooltip', - "You don't have permissions to create NodeBalancers. Please contact your account administrator to request the necessary permissions." + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).not.toHaveAttribute( + 'aria-disabled', + 'true' ); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx index 1f22b671b48..c826cbfa688 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx @@ -5,7 +5,7 @@ import NetworkIcon from 'src/assets/icons/entityIcons/networking.svg'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { sendEvent } from 'src/utilities/analytics/utils'; import { @@ -17,9 +17,10 @@ import { export const NodeBalancerLandingEmptyState = () => { const navigate = useNavigate(); - const isRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_nodebalancers', - }); + + const { data: permissions } = usePermissions('account', [ + 'create_nodebalancer', + ]); return ( @@ -28,7 +29,7 @@ export const NodeBalancerLandingEmptyState = () => { buttonProps={[ { children: 'Create NodeBalancer', - disabled: isRestricted, + disabled: !permissions.create_nodebalancer, onClick: () => { sendEvent({ action: 'Click:button', diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index 44bbd5898f5..e624b21443f 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -5,6 +5,7 @@ import { } from '@linode/api-v4/lib/object-storage'; import { useAccountSettings } from '@linode/queries'; import { useErrors, useOpenClose } from '@linode/utilities'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -53,6 +54,7 @@ export const AccessKeyLanding = (props: Props) => { openAccessDrawer, } = props; + const navigate = useNavigate(); const pagination = usePaginationV2({ currentRoute: '/object-storage/access-keys', initialPage: 1, @@ -88,6 +90,25 @@ export const AccessKeyLanding = (props: Props) => { const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); + // Redirect to base access keys route if current page has no data + // TODO: Remove this implementation and replace `usePagination` with `usePaginate` hook. See [M3-10442] + React.useEffect(() => { + const currentPage = Number(pagination.page); + + // Only redirect if we have data, no results, and we're not on page 1 + if ( + !isLoading && + data && + (data.results === 0 || data.data.length === 0) && + currentPage > 1 + ) { + navigate({ + to: '/object-storage/access-keys', + search: { page: undefined, pageSize: undefined }, + }); + } + }, [data, isLoading, pagination.page, navigate]); + const handleCreateKey = ( values: CreateObjectStorageKeyPayload, { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx index 8c4481074a5..8f5efa402cd 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx @@ -50,7 +50,7 @@ export const BucketRegions = (props: Props) => { placeholder="Select a Region" regions={availableStorageRegions ?? []} required={required} - value={selectedRegion} + value={selectedRegion ?? null} /> ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx index 81c8d3bfa60..bcd6d9e77ba 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx @@ -59,7 +59,7 @@ export const ClusterSelect: React.FC = (props) => { placeholder="Select a Region" regions={regionOptions ?? []} required={required} - value={selectedCluster ?? undefined} + value={selectedCluster ?? null} /> ); }; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx index 7df7e2b3781..4017cc573b9 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx @@ -1,8 +1,6 @@ import { fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { placementGroupFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsCreateDrawer } from './PlacementGroupsCreateDrawer'; @@ -110,37 +108,4 @@ describe('PlacementGroupsCreateDrawer', () => { }); }); }); - - it('should display an error message if the region has reached capacity', async () => { - /** - * Note: this unit test assumes regions are mocked from the MSW's serverHandles.ts - * and that us-west has special limits - */ - queryMocks.useAllPlacementGroupsQuery.mockReturnValue({ - data: [placementGroupFactory.build({ region: 'us-west' })], - }); - const regionWithoutCapacity = 'US, Fremont, CA (us-west)'; - - const { findByText, getByPlaceholderText, getByRole } = renderWithTheme( - - ); - - const regionSelect = getByPlaceholderText('Select a Region'); - - await userEvent.click(regionSelect); - - const regionWithNoCapacityOption = await findByText(regionWithoutCapacity); - - await userEvent.click(regionWithNoCapacityOption); - - const tooltip = getByRole('tooltip'); - - await waitFor(() => { - expect(tooltip.textContent).toContain( - 'Youโ€™ve reached the limit of placement groups you can create in this region.' - ); - }); - - expect(tooltip).toBeVisible(); - }); }); diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx index c201a319858..c3b5dee843a 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx @@ -9,12 +9,17 @@ import type { PermissionType } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; +type PermissionsSubset = T; +type OAuthClientPermissions = PermissionsSubset< + 'delete_oauth_client' | 'reset_oauth_client_secret' | 'update_oauth_client' +>; + interface Props { label: string; onOpenDeleteDialog: () => void; onOpenEditDrawer: () => void; onOpenResetDialog: () => void; - permissions: Partial>; + permissions: Record; } export const OAuthClientActionMenu = (props: Props) => { diff --git a/packages/manager/src/features/Profile/Profile.tsx b/packages/manager/src/features/Profile/Profile.tsx index cab8bb3b3ea..1825a7b8929 100644 --- a/packages/manager/src/features/Profile/Profile.tsx +++ b/packages/manager/src/features/Profile/Profile.tsx @@ -8,11 +8,13 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; export const Profile = () => { const location = useLocation(); const navigate = useNavigate(); + const { iamRbacPrimaryNavChanges } = useFlags(); const { tabs, handleTabChange, tabIndex } = useTabs([ { @@ -40,12 +42,14 @@ export const Profile = () => { title: 'OAuth Apps', }, { - to: `/profile/referrals`, - title: 'Referrals', + to: iamRbacPrimaryNavChanges + ? `/profile/preferences` + : `/profile/referrals`, + title: iamRbacPrimaryNavChanges ? 'Preferences' : 'Referrals', }, { - to: `/profile/settings`, - title: 'My Settings', + to: iamRbacPrimaryNavChanges ? `/profile/referrals` : `/profile/settings`, + title: iamRbacPrimaryNavChanges ? 'Referrals' : 'My Settings', }, ]); diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index fe8b9575367..6174d15904e 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { useFlags } from 'src/hooks/useFlags'; import { MaskSensitiveData } from './MaskSensitiveData'; import { Notifications } from './Notifications'; @@ -13,20 +14,29 @@ import { TypeToConfirm } from './TypeToConfirm'; export const ProfileSettings = () => { const navigate = useNavigate(); - const { preferenceEditor } = useSearch({ from: '/profile/settings' }); + const { iamRbacPrimaryNavChanges } = useFlags(); + const { preferenceEditor } = useSearch({ + from: iamRbacPrimaryNavChanges + ? '/profile/preferences' + : '/profile/settings', + }); const isPreferenceEditorOpen = !!preferenceEditor; const handleClosePreferenceEditor = () => { navigate({ - to: '/profile/settings', + to: iamRbacPrimaryNavChanges + ? '/profile/preferences' + : '/profile/settings', search: { preferenceEditor: undefined }, }); }; return ( <> - + diff --git a/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts b/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts index ccb73884b46..33e561dae36 100644 --- a/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts +++ b/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts @@ -5,3 +5,11 @@ import { ProfileSettings } from './Settings'; export const settingsLazyRoute = createLazyRoute('/profile/settings')({ component: ProfileSettings, }); + +/** + * @todo As part of the IAM Primary Nav flag (iamRbacPrimaryNavChanges) cleanup, /profile/settings will be removed. + * Adding the lazy route in this file will also require the necessary cleanup work, such as renaming the file and removing settingsLazyRoute(/profile/settings), as part of the flag cleanup. + */ +export const preferencesLazyRoute = createLazyRoute('/profile/preferences')({ + component: ProfileSettings, +}); diff --git a/packages/manager/src/features/Quotas/QuotasLanding.tsx b/packages/manager/src/features/Quotas/QuotasLanding.tsx index f0aa7d4f89b..eafc6f2237f 100644 --- a/packages/manager/src/features/Quotas/QuotasLanding.tsx +++ b/packages/manager/src/features/Quotas/QuotasLanding.tsx @@ -1,7 +1,6 @@ import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; @@ -36,7 +35,6 @@ export const QuotasLanding = () => { <> - diff --git a/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts b/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts deleted file mode 100644 index e73ddbfed13..00000000000 --- a/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createLazyRoute } from '@tanstack/react-router'; - -import { SettingsLanding } from './SettingsLanding'; - -export const settingsLandingLazyRoute = createLazyRoute('/settings')({ - component: SettingsLanding, -}); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index 3b5a841a146..aed316a400e 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -32,28 +32,10 @@ interface MenuLink { to: string; } -const profileLinks: MenuLink[] = [ - { - display: 'Display', - to: '/profile/display', - }, - { display: 'Login & Authentication', to: '/profile/auth' }, - { display: 'SSH Keys', to: '/profile/keys' }, - { display: 'LISH Console Settings', to: '/profile/lish' }, - { - display: 'API Tokens', - to: '/profile/tokens', - }, - { display: 'OAuth Apps', to: '/profile/clients' }, - { display: 'Referrals', to: '/profile/referrals' }, - { display: 'My Settings', to: '/profile/settings' }, - { display: 'Log Out', to: '/logout' }, -]; - export const UserMenuPopover = (props: UserMenuPopoverProps) => { const { anchorEl, isDrawerOpen, onClose, onDrawerOpen } = props; const sessionContext = React.useContext(switchAccountSessionContext); - const flags = useFlags(); + const { iamRbacPrimaryNavChanges, limitsEvolution } = useFlags(); const theme = useTheme(); const { data: account } = useAccount(); @@ -73,6 +55,32 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { const open = Boolean(anchorEl); const id = open ? 'user-menu-popover' : undefined; + const profileLinks: MenuLink[] = [ + { + display: 'Display', + to: '/profile/display', + }, + { display: 'Login & Authentication', to: '/profile/auth' }, + { display: 'SSH Keys', to: '/profile/keys' }, + { display: 'LISH Console Settings', to: '/profile/lish' }, + { + display: 'API Tokens', + to: '/profile/tokens', + }, + { display: 'OAuth Apps', to: '/profile/clients' }, + { + display: iamRbacPrimaryNavChanges ? 'Preferences' : 'Referrals', + to: iamRbacPrimaryNavChanges + ? '/profile/preferences' + : '/profile/referrals', + }, + { + display: iamRbacPrimaryNavChanges ? 'Referrals' : 'My Settings', + to: iamRbacPrimaryNavChanges ? '/profile/referrals' : '/profile/settings', + }, + { display: 'Log Out', to: '/logout' }, + ]; + // Used for fetching parent profile and account data by making a request with the parent's token. const proxyHeaders = isProxyUser ? { @@ -93,50 +101,50 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { () => [ { display: 'Billing', - to: flags?.iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', }, { display: - flags?.iamRbacPrimaryNavChanges && isIAMEnabled + iamRbacPrimaryNavChanges && isIAMEnabled ? 'Identity & Access' : 'Users & Grants', to: - flags?.iamRbacPrimaryNavChanges && isIAMEnabled + iamRbacPrimaryNavChanges && isIAMEnabled ? '/iam' - : flags?.iamRbacPrimaryNavChanges && !isIAMEnabled + : iamRbacPrimaryNavChanges && !isIAMEnabled ? '/users' : '/account/users', - isBeta: flags?.iamRbacPrimaryNavChanges && isIAMEnabled, + isBeta: iamRbacPrimaryNavChanges && isIAMEnabled, }, { display: 'Quotas', - hide: !flags.limitsEvolution?.enabled, - to: flags?.iamRbacPrimaryNavChanges ? '/quotas' : '/account/quotas', + hide: !limitsEvolution?.enabled, + to: iamRbacPrimaryNavChanges ? '/quotas' : '/account/quotas', }, { display: 'Login History', - to: flags?.iamRbacPrimaryNavChanges + to: iamRbacPrimaryNavChanges ? '/login-history' : '/account/login-history', }, { display: 'Service Transfers', - to: flags?.iamRbacPrimaryNavChanges + to: iamRbacPrimaryNavChanges ? '/service-transfers' : '/account/service-transfers', }, { display: 'Maintenance', - to: flags?.iamRbacPrimaryNavChanges - ? '/maintenance' - : '/account/maintenance', + to: iamRbacPrimaryNavChanges ? '/maintenance' : '/account/maintenance', }, { - display: 'Settings', - to: flags?.iamRbacPrimaryNavChanges ? '/settings' : '/account/settings', + display: iamRbacPrimaryNavChanges ? 'Account Settings' : 'Settings', + to: iamRbacPrimaryNavChanges + ? '/account-settings' + : '/account/settings', }, ], - [isIAMEnabled, flags] + [isIAMEnabled, iamRbacPrimaryNavChanges, limitsEvolution] ); const renderLink = (link: MenuLink) => { @@ -246,7 +254,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { - {flags?.iamRbacPrimaryNavChanges ? 'Administration' : 'Account'} + {iamRbacPrimaryNavChanges ? 'Administration' : 'Account'} { <> - diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx index 662c6484091..345739ffd01 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx @@ -37,10 +37,10 @@ describe('VPC Top Section form content', () => { expect(screen.getByText('VPC Label')).toBeVisible(); expect(screen.getByText('Description')).toBeVisible(); // @TODO VPC IPv6: Remove this check once VPC IPv6 is in GA - expect(screen.queryByText('Networking IP Stack')).not.toBeInTheDocument(); + expect(screen.queryByText('IP Stack')).not.toBeInTheDocument(); }); - it('renders a Networking IP Stack section with IPv4 pre-checked if the vpcIpv6 feature flag is enabled', async () => { + it('renders an IP Stack section with IPv4 pre-checked if the vpcIpv6 feature flag is enabled', async () => { const account = accountFactory.build({ capabilities: ['VPC Dual Stack'], }); @@ -66,7 +66,7 @@ describe('VPC Top Section form content', () => { }); await waitFor(() => { - expect(screen.getByText('Networking IP Stack')).toBeVisible(); + expect(screen.getByText('IP Stack')).toBeVisible(); }); const NetworkingIPStackRadios = screen.getAllByRole('radio'); @@ -100,7 +100,7 @@ describe('VPC Top Section form content', () => { }); await waitFor(() => { - expect(screen.getByText('Networking IP Stack')).toBeVisible(); + expect(screen.getByText('IP Stack')).toBeVisible(); }); const NetworkingIPStackRadios = screen.getAllByRole('radio'); @@ -140,7 +140,7 @@ describe('VPC Top Section form content', () => { }); await waitFor(() => { - expect(screen.getByText('Networking IP Stack')).toBeVisible(); + expect(screen.getByText('IP Stack')).toBeVisible(); }); const NetworkingIPStackRadios = screen.getAllByRole('radio'); diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx index 7ab330ae87b..bd0d864a009 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx @@ -138,7 +138,7 @@ export const VPCTopSectionContent = (props: Props) => { /> {isDualStackEnabled && ( - Networking IP Stack + IP Stack { sm: 12, xs: 12, }} - heading="IPv4 + IPv6 (Dual Stack)" + heading="IPv4 + IPv6 (dual-stack)" onClick={() => { field.onChange([ { diff --git a/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx index 7779d00aee0..f441f69f92a 100644 --- a/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx @@ -1,4 +1,4 @@ -import { useAttachVolumeMutation, useGrants } from '@linode/queries'; +import { useAttachVolumeMutation } from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, @@ -16,6 +16,7 @@ import { number, object } from 'yup'; import { BLOCK_STORAGE_ENCRYPTION_SETTING_IMMUTABLE_COPY } from 'src/components/Encryption/constants'; import { useIsBlockStorageEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useEventsPollingActions } from 'src/queries/events/events'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -46,7 +47,13 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { const { checkForNewEvents } = useEventsPollingActions(); - const { data: grants } = useGrants(); + const { data: permissions } = usePermissions( + 'volume', + ['attach_volume'], + volume?.id + ); + + const canAttachVolume = permissions?.attach_volume; const { error, mutateAsync: attachVolume } = useAttachVolumeMutation(); @@ -86,11 +93,6 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { overwrite: 'Overwrite', }; - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const hasErrorFor = getAPIErrorFor( errorResources, error === null ? undefined : error @@ -107,7 +109,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { title={`Attach Volume ${volume?.label}`} >
- {isReadOnly && ( + {!canAttachVolume && ( { {generalError && } { )} { { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; + const { data: accountPermissions } = usePermissions('account', [ + 'create_volume', + ]); + const { data: volumePermissions } = usePermissions( + 'volume', + ['clone_volume'], + volume?.id + ); + const canCloneVolume = + volumePermissions?.clone_volume && accountPermissions?.create_volume; + const { mutateAsync: cloneVolume } = useCloneVolumeMutation(); const { checkForNewEvents } = useEventsPollingActions(); - const { data: grants } = useGrants(); const { data: types, isError, isLoading } = useVolumeTypesQuery(); const { isBlockStorageEncryptionFeatureEnabled } = useIsBlockStorageEncryptionFeatureEnabled(); - // Even if a restricted user has the ability to create Volumes, they - // can't clone a Volume they only have read only permission on. - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const isInvalidPrice = !types || isError; const { @@ -104,7 +104,7 @@ export const CloneVolumeDrawer = (props: Props) => { title="Clone Volume" > - {isReadOnly && ( + {!canCloneVolume && ( { be available in {volume?.region}.
{ /> { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; - const { data: grants } = useGrants(); + const { data: permissions } = usePermissions( + 'volume', + ['update_volume'], + volume?.id + ); + const canUpdateVolume = permissions?.update_volume; const { mutateAsync: updateVolume } = useUpdateVolumeMutation(); const { isBlockStorageEncryptionFeatureEnabled } = useIsBlockStorageEncryptionFeatureEnabled(); - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const { dirty, errors, @@ -91,7 +92,7 @@ export const EditVolumeDrawer = (props: Props) => { title="Edit Volume" > - {isReadOnly && ( + {!canUpdateVolume && ( { {error && } { { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; - const { data: grants } = useGrants(); + const { data: permissions } = usePermissions( + 'volume', + ['update_volume'], + volume?.id + ); + const canUpdateVolume = permissions?.update_volume; const { mutateAsync: updateVolume } = useUpdateVolumeMutation(); - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const { control, formState: { errors, isDirty, isSubmitting }, @@ -76,7 +77,7 @@ export const ManageTagsDrawer = (props: Props) => { title="Manage Volume Tags" > - {isReadOnly && ( + {!canUpdateVolume && ( { name="tags" render={({ field, fieldState }) => ( @@ -104,7 +105,7 @@ export const ManageTagsDrawer = (props: Props) => { { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; + const { data: permissions } = usePermissions( + 'volume', + ['resize_volume'], + volume?.id + ); + const canResizeVolume = permissions?.resize_volume; + const { mutateAsync: resizeVolume } = useResizeVolumeMutation(); const { checkForNewEvents } = useEventsPollingActions(); @@ -40,14 +44,8 @@ export const ResizeVolumeDrawer = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); - const { data: grants } = useGrants(); const { data: types, isError, isLoading } = useVolumeTypesQuery(); - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const isInvalidPrice = !types || isError; const { @@ -102,7 +100,7 @@ export const ResizeVolumeDrawer = (props: Props) => { title="Resize Volume" > - {isReadOnly && ( + {!canResizeVolume && ( { )} {error && } { /> { const { linode, onClose, setClientLibraryCopyVisible } = props; - const { data: grants } = useGrants(); - const { enqueueSnackbar } = useSnackbar(); const { checkForNewEvents } = useEventsPollingActions(); - const linodeGrant = grants?.linode.find( - (grant: Grant) => grant.id === linode.id - ); - - const isReadOnly = linodeGrant?.permissions === 'read_only'; - const { mutateAsync: attachVolume } = useAttachVolumeMutation(); const { @@ -102,6 +90,13 @@ export const LinodeVolumeAttachForm = (props: Props) => { values.volume_id !== -1 ); + const { data: permissions } = usePermissions( + 'volume', + ['attach_volume'], + volume?.id + ); + const canAttachVolume = permissions?.attach_volume; + const linodeRequiresClientLibraryUpdate = volume?.encryption === 'enabled' && Boolean(!linode.capabilities?.includes('Block Storage Encryption')); @@ -113,7 +108,7 @@ export const LinodeVolumeAttachForm = (props: Props) => { return ( - {isReadOnly && ( + {!canAttachVolume && ( { )} {error && } { value={values.volume_id} /> { /> { + const { data: permissions } = usePermissions('account', ['create_volume']); + return ( { } data-qa-radio="Create and Attach Volume" + disabled={!permissions.create_volume} label="Create and Attach Volume" value="create" /> diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index ae1641de646..6d4bbf5d0e5 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -1,7 +1,6 @@ import { useAccountAgreements, useCreateVolumeMutation, - useGrants, useLinodeQuery, useMutateAccountAgreements, useProfile, @@ -58,6 +57,7 @@ import { import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { reportAgreementSigningError } from 'src/utilities/reportAgreementSigningError'; +import { usePermissions } from '../IAM/hooks/usePermissions'; import { SIZE_FIELD_WIDTH } from './constants'; import { ConfigSelect } from './Drawers/VolumeDrawer/ConfigSelect'; import { SizeField } from './Drawers/VolumeDrawer/SizeField'; @@ -134,7 +134,8 @@ export const VolumeCreate = () => { const { data: types, isError, isLoading } = useVolumeTypesQuery(); const { data: profile } = useProfile(); - const { data: grants } = useGrants(); + + const { data: permissions } = usePermissions('account', ['create_volume']); const { data: regions } = useRegionsQuery(); const { isGeckoLAEnabled } = useIsGeckoEnabled( @@ -169,9 +170,6 @@ export const VolumeCreate = () => { ) .map((thisRegion) => thisRegion.id) ?? []; - const doesNotHavePermission = - profile?.restricted && !grants?.global.add_volumes; - const renderSelectTooltip = (tooltipText: string) => { return ( { const isInvalidPrice = !types || isError; + const canCreateVolume = permissions?.create_volume; + const disabled = Boolean( - doesNotHavePermission || + !canCreateVolume || (showGDPRCheckbox && !hasSignedAgreement) || isInvalidPrice ); @@ -348,7 +348,7 @@ export const VolumeCreate = () => { }} title="Create" /> - {doesNotHavePermission && ( + {!canCreateVolume && ( { { /> @@ -419,7 +419,7 @@ export const VolumeCreate = () => { { > { )}
{ { + const navigate = useNavigate(); + const { volumeSummaryPage } = useFlags(); + const { volumeId } = useParams({ from: '/volumes/$volumeId' }); + const { data: volume, isLoading, error } = useVolumeQuery(volumeId); + const { tabs, handleTabChange, tabIndex, getTabIndex } = useTabs([ + { + to: '/volumes/$volumeId/summary', + title: 'Summary', + }, + ]); + + if (!volumeSummaryPage || error) { + return ; + } + + if (isLoading || !volume) { + return ; + } + + if (location.pathname === `/volumes/${volumeId}`) { + navigate({ to: `/volumes/${volumeId}/summary` }); + } + + return ( + <> + + + + + }> + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx new file mode 100644 index 00000000000..ccbabeda79d --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; + +import { VolumeEntityDetailBody } from './VolumeEntityDetailBody'; +import { VolumeEntityDetailFooter } from './VolumeEntityDetailFooter'; +import { VolumeEntityDetailHeader } from './VolumeEntityDetailHeader'; + +import type { Volume } from '@linode/api-v4'; + +interface Props { + volume: Volume; +} + +export const VolumeEntityDetail = ({ volume }: Props) => { + return ( + } + footer={} + header={} + /> + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx new file mode 100644 index 00000000000..d4d9c9e24c2 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx @@ -0,0 +1,147 @@ +import { useProfile, useRegionsQuery } from '@linode/queries'; +import { Box, Typography } from '@linode/ui'; +import { getFormattedStatus } from '@linode/utilities'; +import Grid from '@mui/material/Grid'; +import { useTheme } from '@mui/material/styles'; +import React from 'react'; + +import Lock from 'src/assets/icons/lock.svg'; +import Unlock from 'src/assets/icons/unlock.svg'; +import { Link } from 'src/components/Link'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { formatDate } from 'src/utilities/formatDate'; + +import { volumeStatusIconMap } from '../../utils'; + +import type { Volume } from '@linode/api-v4'; + +interface Props { + volume: Volume; +} + +export const VolumeEntityDetailBody = ({ volume }: Props) => { + const theme = useTheme(); + const { data: profile } = useProfile(); + const { data: regions } = useRegionsQuery(); + + const regionLabel = + regions?.find((region) => region.id === volume.region)?.label ?? + volume.region; + + return ( + + + + Status + + + ({ font: theme.font.bold })}> + {getFormattedStatus(volume.status)} + + + + + Size + ({ font: theme.font.bold })}> + {volume.size} GB + + + + Created + ({ font: theme.font.bold })}> + {formatDate(volume.created, { + timezone: profile?.timezone, + })} + + + + + + + Volume ID + ({ font: theme.font.bold })}> + {volume.id} + + + + Region + ({ font: theme.font.bold })}> + {regionLabel} + + + + Volume Label + ({ font: theme.font.bold })}> + {volume.label} + + + + + + + Attached To + ({ font: theme.font.bold })}> + {volume.linode_id !== null ? ( + + {volume.linode_label} + + ) : ( + 'Unattached' + )} + + + + + Encryption + + {volume.encryption === 'enabled' ? ( + <> + + ({ font: theme.font.bold })}> + Encrypted + + + ) : ( + <> + + ({ font: theme.font.bold })}> + Not Encrypted + + + )} + + + + + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx new file mode 100644 index 00000000000..eb974fe1c4b --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { TagCell } from 'src/components/TagCell/TagCell'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; + +interface Props { + tags: string[]; +} + +export const VolumeEntityDetailFooter = ({ tags }: Props) => { + const isReadOnlyAccountAccess = useRestrictedGlobalGrantCheck({ + globalGrantType: 'account_access', + permittedGrantLevel: 'read_write', + }); + + return ( + Promise.resolve()} + view="inline" + /> + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx new file mode 100644 index 00000000000..1810b7f7c38 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx @@ -0,0 +1,29 @@ +import { Box } from '@linode/ui'; +import { Typography } from '@linode/ui'; +import React from 'react'; + +import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; + +import type { Volume } from '@linode/api-v4'; + +interface Props { + volume: Volume; +} + +export const VolumeEntityDetailHeader = ({ volume }: Props) => { + return ( + + ({ + display: 'flex', + alignItems: 'center', + padding: `${theme.spacingFunction(6)} 0 ${theme.spacingFunction(6)} ${theme.spacingFunction(16)}`, + })} + > + ({ font: theme.font.bold })}> + Summary + + + + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts b/packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts new file mode 100644 index 00000000000..9b2a5bf5ed1 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { VolumeDetails } from './VolumeDetails'; + +export const volumeDetailsLazyRoute = createLazyRoute('/volumes/$volumeId')({ + component: VolumeDetails, +}); diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx index 9042d4d5b95..0cf318243ab 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx @@ -34,7 +34,30 @@ const handlers: ActionHandlers = { handleUpgrade: vi.fn(), }; +const queryMocks = vi.hoisted(() => ({ + usePermissions: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('Volume table row', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + update_volume: true, + attach_volume: true, + create_volume: true, + delete_volume: true, + resize_volume: true, + clone_volume: true, + }); + }); + it("should show the attached Linode's label if present", async () => { const { getByLabelText, getByTestId, getByText } = renderWithTheme( wrapWithTableBody( diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 069aac9d68c..6266476908b 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -10,6 +10,7 @@ import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { useFlags } from 'src/hooks/useFlags'; import { useInProgressEvents } from 'src/queries/events/events'; import { HighPerformanceVolumeIcon } from '../Linodes/HighPerformanceVolumeIcon'; @@ -53,6 +54,7 @@ export const VolumeTableRow = React.memo((props: Props) => { const { data: regions } = useRegionsQuery(); const { data: notifications } = useNotificationsQuery(); const { data: inProgressEvents } = useInProgressEvents(); + const { volumeSummaryPage } = useFlags(); const isVolumesLanding = !isDetailsPageRow; @@ -124,20 +126,39 @@ export const VolumeTableRow = React.memo((props: Props) => { wrap: 'nowrap', }} > - ({ - alignItems: 'center', - display: 'flex', - gap: theme.spacing(), - })} - > - {volume.label} - {linodeCapabilities && ( - - )} - + {volumeSummaryPage ? ( + + ({ + alignItems: 'center', + display: 'flex', + gap: theme.spacingFunction(8), + })} + > + {volume.label} + {linodeCapabilities && ( + + )} + + + ) : ( + ({ + alignItems: 'center', + display: 'flex', + gap: theme.spacingFunction(8), + })} + > + {volume.label} + {linodeCapabilities && ( + + )} + + )} {isEligibleForUpgradeToNVMe && ( ({ + usePermissions: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('Volume action menu', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + update_volume: true, + attach_volume: true, + create_volume: true, + delete_volume: true, + resize_volume: true, + clone_volume: true, + }); + }); + it('should include basic Volume actions', async () => { const { getByLabelText, getByText } = renderWithTheme( diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx index fc797e971fd..bf2defa3ba6 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import type { Volume } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -30,11 +30,22 @@ export const VolumesActionMenu = (props: Props) => { const attached = volume.linode_id !== null; - const isVolumeReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'volume', - id: volume.id, - }); + const { data: accountPermissions } = usePermissions('account', [ + 'create_volume', + ]); + const { data: volumePermissions } = usePermissions( + 'volume', + [ + 'delete_volume', + 'view_volume', + 'resize_volume', + 'clone_volume', + 'attach_volume', + 'detach_volume', + 'update_volume', + ], + volume.id + ); const actions: Action[] = [ { @@ -42,10 +53,10 @@ export const VolumesActionMenu = (props: Props) => { title: 'Show Config', }, { - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.update_volume, onClick: handlers.handleEdit, title: 'Edit', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.update_volume ? getRestrictedResourceText({ action: 'edit', isSingular: true, @@ -54,15 +65,15 @@ export const VolumesActionMenu = (props: Props) => { : undefined, }, { - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.update_volume, onClick: handlers.handleManageTags, title: 'Manage Tags', }, { - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.resize_volume, onClick: handlers.handleResize, title: 'Resize', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.resize_volume ? getRestrictedResourceText({ action: 'resize', isSingular: true, @@ -71,10 +82,11 @@ export const VolumesActionMenu = (props: Props) => { : undefined, }, { - disabled: isVolumeReadOnly, + disabled: + !volumePermissions?.clone_volume || !accountPermissions?.create_volume, onClick: handlers.handleClone, title: 'Clone', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.clone_volume ? getRestrictedResourceText({ action: 'clone', isSingular: true, @@ -86,10 +98,10 @@ export const VolumesActionMenu = (props: Props) => { if (!attached && isVolumesLanding) { actions.push({ - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.attach_volume, onClick: handlers.handleAttach, title: 'Attach', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.attach_volume ? getRestrictedResourceText({ action: 'attach', isSingular: true, @@ -99,10 +111,10 @@ export const VolumesActionMenu = (props: Props) => { }); } else { actions.push({ - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.detach_volume, onClick: handlers.handleDetach, title: 'Detach', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.detach_volume ? getRestrictedResourceText({ action: 'detach', isSingular: true, @@ -113,10 +125,10 @@ export const VolumesActionMenu = (props: Props) => { } actions.push({ - disabled: isVolumeReadOnly || attached, + disabled: !volumePermissions?.delete_volume || attached, onClick: handlers.handleDelete, title: 'Delete', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.delete_volume ? getRestrictedResourceText({ action: 'delete', isSingular: true, diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index ccd400a75c6..45805258fd8 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -18,9 +18,9 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableSortCell } from 'src/components/TableSortCell'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { VOLUME_TABLE_DEFAULT_ORDER, VOLUME_TABLE_DEFAULT_ORDER_BY, @@ -50,6 +50,8 @@ export const VolumesLanding = () => { from: '/volumes/', shouldThrow: false, }); + const { data: permissions } = usePermissions('account', ['create_volume']); + const pagination = usePaginationV2({ currentRoute: '/volumes', preferenceKey: VOLUME_TABLE_PREFERENCE_KEY, @@ -58,9 +60,8 @@ export const VolumesLanding = () => { query: search?.query, }), }); - const isVolumeCreationRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_volumes', - }); + + const canCreateVolume = permissions?.create_volume; const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { @@ -169,7 +170,7 @@ export const VolumesLanding = () => { resourceType: 'Volumes', }), }} - disabledCreateButton={isVolumeCreationRestricted} + disabledCreateButton={!canCreateVolume} docsLink="https://techdocs.akamai.com/cloud-computing/docs/block-storage" entity="Volume" onButtonClick={() => navigate({ to: '/volumes/create' })} diff --git a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx index 85d3ac47d11..13f35803b5b 100644 --- a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx +++ b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx @@ -4,8 +4,8 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { StyledBucketIcon } from 'src/features/ObjectStorage/BucketLanding/StylesBucketIcon'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendEvent } from 'src/utilities/analytics/utils'; import { @@ -17,10 +17,7 @@ import { export const VolumesLandingEmptyState = () => { const navigate = useNavigate(); - - const isRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_volumes', - }); + const { data: permissions } = usePermissions('account', ['create_volume']); return ( <> @@ -29,7 +26,7 @@ export const VolumesLandingEmptyState = () => { buttonProps={[ { children: 'Create Volume', - disabled: isRestricted, + disabled: !permissions?.create_volume, onClick: () => { sendEvent({ action: 'Click:button', diff --git a/packages/manager/src/mocks/presets/crud/datastream.ts b/packages/manager/src/mocks/presets/crud/datastream.ts index 8820ded8dd7..313d1721c1c 100644 --- a/packages/manager/src/mocks/presets/crud/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/datastream.ts @@ -1,15 +1,28 @@ import { createDestinations, createStreams, + deleteDestination, + deleteStream, getDestinations, getStreams, + updateDestination, + updateStream, } from 'src/mocks/presets/crud/handlers/datastream'; import type { MockPresetCrud } from 'src/mocks/types'; export const datastreamCrudPreset: MockPresetCrud = { group: { id: 'DataStream' }, - handlers: [getStreams, createStreams, getDestinations, createDestinations], + handlers: [ + getStreams, + createStreams, + deleteStream, + updateStream, + getDestinations, + createDestinations, + deleteDestination, + updateDestination, + ], id: 'datastream:crud', label: 'Data Stream CRUD', }; diff --git a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts index cf49a542407..74bde7244ca 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts @@ -5,6 +5,7 @@ import { destinationFactory, streamFactory } from 'src/factories/datastream'; import { mswDB } from 'src/mocks/indexedDB'; import { queueEvents } from 'src/mocks/utilities/events'; import { + makeErrorResponse, makeNotFoundResponse, makePaginatedResponse, makeResponse, @@ -92,6 +93,84 @@ export const createStreams = (mockState: MockState) => [ ), ]; +export const updateStream = (mockState: MockState) => [ + http.put( + '*/v4beta/monitor/streams/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const stream = await mswDB.get('streams', id); + + if (!stream) { + return makeNotFoundResponse(); + } + + const destinations = await mswDB.getAll('destinations'); + const payload = await request.clone().json(); + const updatedStream = { + ...stream, + ...payload, + destinations: payload['destinations'].map((destinationId: number) => + destinations?.find(({ id }) => id === destinationId) + ), + updated: DateTime.now().toISO(), + }; + + await mswDB.update('streams', id, updatedStream, mockState); + + queueEvents({ + event: { + action: 'stream_update', + entity: { + id: stream.id, + label: stream.label, + type: 'stream', + url: `/v4beta/monitor/streams/${stream.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(updatedStream); + } + ), +]; + +export const deleteStream = (mockState: MockState) => [ + http.delete( + '*/v4beta/monitor/streams/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const stream = await mswDB.get('streams', id); + + if (!stream) { + return makeNotFoundResponse(); + } + + await mswDB.delete('streams', id, mockState); + + queueEvents({ + event: { + action: 'stream_delete', + entity: { + id: stream.id, + label: stream.label, + type: 'domain', + url: `/v4beta/monitor/streams/${stream.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse({}); + } + ), +]; + export const getDestinations = () => [ http.get( '*/v4beta/monitor/streams/destinations', @@ -164,3 +243,92 @@ export const createDestinations = (mockState: MockState) => [ } ), ]; + +export const updateDestination = (mockState: MockState) => [ + http.put( + '*/v4beta/monitor/streams/destinations/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const destination = await mswDB.get('destinations', id); + + if (!destination) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + const [majorVersion, minorVersion] = destination.version.split('.'); + const updatedDestination = { + ...destination, + ...payload, + version: `${majorVersion}.${+minorVersion + 1}`, + updated: DateTime.now().toISO(), + }; + + await mswDB.update('destinations', id, updatedDestination, mockState); + + queueEvents({ + event: { + action: 'destination_update', + entity: { + id: destination.id, + label: destination.label, + type: 'stream', + url: `/v4beta/monitor/streams/${destination.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(updatedDestination); + } + ), +]; + +export const deleteDestination = (mockState: MockState) => [ + http.delete( + '*/v4beta/monitor/streams/destinations/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const destination = await mswDB.get('destinations', id); + const streams = await mswDB.getAll('streams'); + const currentlyAttachedDestinations = new Set( + streams?.flatMap(({ destinations }) => + destinations?.map(({ id }) => id) + ) + ); + + if (!destination) { + return makeNotFoundResponse(); + } + + if (currentlyAttachedDestinations.has(id)) { + return makeErrorResponse( + `Destination with id ${id} is attached to a stream and cannot be deleted`, + 409 + ); + } + + await mswDB.delete('destinations', id, mockState); + + queueEvents({ + event: { + action: 'destination_delete', + entity: { + id: destination.id, + label: destination.label, + type: 'domain', + url: `/v4beta/monitor/streams/${destination.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse({}); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/extra/account/customProfile.ts b/packages/manager/src/mocks/presets/extra/account/customProfile.ts deleted file mode 100644 index 10651dc1b8a..00000000000 --- a/packages/manager/src/mocks/presets/extra/account/customProfile.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { profileFactory } from '@linode/utilities'; -import { http } from 'msw'; - -import { makeResponse } from 'src/mocks/utilities/response'; - -import type { Profile } from '@linode/api-v4'; -import type { MockPresetExtra } from 'src/mocks/types'; - -let customProfileData: null | Profile = null; - -export const setCustomProfileData = (data: null | Profile) => { - customProfileData = data; -}; - -const mockCustomProfile = () => { - return [ - http.get('*/v4*/profile', async () => { - return makeResponse( - customProfileData - ? { ...profileFactory.build(), ...customProfileData } - : profileFactory.build() - ); - }), - ]; -}; - -export const customProfilePreset: MockPresetExtra = { - desc: 'Custom Profile', - group: { id: 'Profile', type: 'profile' }, - handlers: [mockCustomProfile], - id: 'profile:custom', - label: 'Custom Profile', -}; diff --git a/packages/manager/src/mocks/presets/extra/account/customProfileAndGrants.ts b/packages/manager/src/mocks/presets/extra/account/customProfileAndGrants.ts new file mode 100644 index 00000000000..d4dab180794 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/customProfileAndGrants.ts @@ -0,0 +1,50 @@ +import { grantsFactory, profileFactory } from '@linode/utilities'; +import { http } from 'msw'; + +import { makeResponse } from 'src/mocks/utilities/response'; + +import type { Grants, Profile } from '@linode/api-v4'; +import type { MockPresetExtra } from 'src/mocks/types'; + +let customProfileData: null | Profile = null; +let customGrantsData: Grants | null = null; + +export const setCustomProfileData = (data: null | Profile) => { + customProfileData = data; +}; + +export const setCustomGrantsData = (data: Grants | null) => { + customGrantsData = data; +}; + +const mockCustomProfile = () => { + return [ + http.get('*/v4*/profile', async () => { + return makeResponse( + customProfileData + ? { ...profileFactory.build(), ...customProfileData } + : profileFactory.build() + ); + }), + ]; +}; + +const mockCustomGrants = () => { + return [ + http.get('*/v4*/grants', async () => { + return makeResponse( + customGrantsData + ? { ...grantsFactory.build(), ...customGrantsData } + : grantsFactory.build() + ); + }), + ]; +}; + +export const customProfileAndGrantsPreset: MockPresetExtra = { + desc: 'Custom Profile and Grants', + group: { id: 'Profile & Grants', type: 'profile & grants' }, + handlers: [mockCustomProfile, mockCustomGrants], + id: 'profile-grants:custom', + label: 'Custom Profile and Grants', +}; diff --git a/packages/manager/src/mocks/presets/index.ts b/packages/manager/src/mocks/presets/index.ts index 8a1d91d40b1..20dd84566c8 100644 --- a/packages/manager/src/mocks/presets/index.ts +++ b/packages/manager/src/mocks/presets/index.ts @@ -9,7 +9,7 @@ import { customAccountPreset } from './extra/account/customAccount'; import { customEventsPreset } from './extra/account/customEvents'; import { customMaintenancePreset } from './extra/account/customMaintenance'; import { customNotificationsPreset } from './extra/account/customNotifications'; -import { customProfilePreset } from './extra/account/customProfile'; +import { customProfileAndGrantsPreset } from './extra/account/customProfileAndGrants'; import { managedDisabledPreset } from './extra/account/managedDisabled'; import { managedEnabledPreset } from './extra/account/managedEnabled'; import { apiResponseTimePreset } from './extra/api/api'; @@ -45,7 +45,7 @@ export const baselineMockPresets: MockPresetBaseline[] = [ export const extraMockPresets: MockPresetExtra[] = [ apiResponseTimePreset, customAccountPreset, - customProfilePreset, + customProfileAndGrantsPreset, customEventsPreset, customUserAccountPermissionsPreset, customUserEntityPermissionsPreset, diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6d7ce7a4f91..233c360405e 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -58,6 +58,8 @@ import { firewallDeviceFactory, firewallEntityfactory, firewallFactory, + firewallMetricDefinitionsResponse, + firewallMetricRulesFactory, imageFactory, incidentResponseFactory, invoiceFactory, @@ -739,6 +741,9 @@ export const handlers = [ }), http.get('*/linode/instances', async ({ request }) => { linodeFactory.resetSequenceNumber(); + const linodesWithFirewalls = linodeFactory.buildList(10, { + region: 'ap-west', + }); const metadataLinodeWithCompatibleImage = linodeFactory.build({ image: 'metadata-test-image', label: 'metadata-test-image', @@ -813,8 +818,19 @@ export const handlers = [ region: 'us-east', id: 1005, }), + linodeFactory.build({ + label: 'aclp-supported-region-linode-3', + region: 'us-iad', + id: 1006, + }), ]; + const linodeFirewall = linodeFactory.build({ + region: 'ap-west', + label: 'Linode-firewall-test', + id: 90909, + }); const linodes = [ + ...linodesWithFirewalls, ...mtcLinodes, ...aclpSupportedRegionLinodes, nonMTCPlanInMTCSupportedRegionsLinode, @@ -867,6 +883,7 @@ export const handlers = [ }), eventLinode, multipleIPLinode, + linodeFirewall, ]; if (request.headers.get('x-filter')) { @@ -877,20 +894,63 @@ export const handlers = [ let filteredLinodes = linodes; // Default to the original linodes in case no filters are applied - // filter the linodes based on id or region if (andFilters?.length) { - filteredLinodes = filteredLinodes.filter((linode) => { - const filteredById = andFilters.every( - (filter: { id: number }) => filter.id === linode.id - ); - const filteredByRegion = andFilters.every( - (filter: { region: string }) => filter.region === linode.region - ); + // Check if this is a combined filter structure (multiple filter groups with +or arrays) + const hasCombinedFilter = andFilters.some( + (filterGroup: any) => + filterGroup['+or'] && Array.isArray(filterGroup['+or']) + ); - return filteredById || filteredByRegion; - }); + if (hasCombinedFilter) { + // Handle combined filter structure for CloudPulse alerts + filteredLinodes = filteredLinodes.filter((linode) => { + return andFilters.every((filterGroup: any) => { + // Handle id filter group + if (filterGroup['+or'] && Array.isArray(filterGroup['+or'])) { + const idFilters = filterGroup['+or'].filter( + (f) => f.id !== undefined + ); + const regionFilters = filterGroup['+or'].filter( + (f) => f.region !== undefined + ); + + // Check if linode matches any id in the id filter group + const matchesId = + idFilters.length === 0 || + idFilters.some((f) => Number(f.id) === linode.id); + + // Check if linode matches any region in the region filter group + const matchesRegion = + regionFilters.length === 0 || + regionFilters.some((f) => f.region === linode.region); + + return matchesId && matchesRegion; + } + + return false; + }); + }); + } else { + // Handle legacy andFilters for other use cases + filteredLinodes = filteredLinodes.filter((linode) => { + const filteredById = andFilters.every( + (filter: { id: number }) => filter.id === linode.id + ); + const filteredByRegion = andFilters.every( + (filter: { region: string }) => filter.region === linode.region + ); + + return filteredById || filteredByRegion; + }); + } } + // The legacy id/region filtering logic has been removed here because it + // duplicated the work done above and incorrectly trimmed results when a + // newer "combined" filter structure (an array of "+or" groups inside + // "+and") was supplied. For legacy consumers the filtering is handled + // in the `else` branch above (lines ~922โ€“934). + // after the linodes are filtered based on region, filter the region-filtered linodes based on selected tags if any if (orFilters?.length) { filteredLinodes = filteredLinodes.filter((linode) => { @@ -939,19 +999,58 @@ export const handlers = [ }), ]; const linodeAclpSupportedRegionDetails = [ + /** Whether a Linode is ACLP-subscribed can be determined using the useIsLinodeAclpSubscribed hook. */ + + // 1. Example: ACLP-subscribed Linode in an ACLP-supported region (mock Linode ID: 1004) linodeFactory.build({ id, backups: { enabled: false }, label: 'aclp-supported-region-linode-1', region: 'us-iad', - alerts: { user: [100, 101], system: [200] }, + alerts: { + user: [21, 22, 23, 24, 25], + system: [19, 20], + cpu: 0, + io: 0, + network_in: 0, + network_out: 0, + transfer_quota: 0, + }, }), + // 2. Example: Linode not subscribed to ACLP in an ACLP-supported region (mock Linode ID: 1005) linodeFactory.build({ id, backups: { enabled: false }, label: 'aclp-supported-region-linode-2', region: 'us-east', - alerts: { user: [], system: [] }, + alerts: { + user: [], + system: [], + cpu: 10, + io: 10000, + network_in: 0, + network_out: 0, + transfer_quota: 80, + }, + }), + // 3. Example: Linode in an ACLP-supported region with NO enabled alerts (mock Linode ID: 1006) + // - Whether this Linode is ACLP-subscribed depends on the ACLP release stage: + // a. Beta stage: NOT subscribed to ACLP + // b. GA stage: Subscribed to ACLP + linodeFactory.build({ + id, + backups: { enabled: false }, + label: 'aclp-supported-region-linode-3', + region: 'us-iad', + alerts: { + user: [], + system: [], + cpu: 0, + io: 0, + network_in: 0, + network_out: 0, + transfer_quota: 0, + }, }), ]; const linodeNonMTCPlanInMTCSupportedRegionsDetail = linodeFactory.build({ @@ -986,6 +1085,8 @@ export const handlers = [ return linodeAclpSupportedRegionDetails[0]; case 1005: return linodeAclpSupportedRegionDetails[1]; + case 1006: + return linodeAclpSupportedRegionDetails[2]; default: return linodeDetail; } @@ -1113,6 +1214,12 @@ export const handlers = [ label: 'Linode-123', }), }), + firewallEntityfactory.build({ + type: 'linode', + label: 'Linode-firewall-test', + parent_entity: null, + id: 90909, + }), ], }), ]; @@ -2733,11 +2840,19 @@ export const handlers = [ alertFactory.resetSequenceNumber(); return HttpResponse.json({ data: [ - ...alertFactory.buildList(20, { + ...alertFactory.buildList(18, { + rule_criteria: { + rules: alertRulesFactory.buildList(2), + }, + service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', + }), + // Mocked 2 alert definitions associated with mock Linode ID '1004' (aclp-supported-region-linode-1) + ...alertFactory.buildList(2, { rule_criteria: { rules: alertRulesFactory.buildList(2), }, service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', + entity_ids: ['1004'], }), ...alertFactory.buildList(6, { service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', @@ -2808,12 +2923,35 @@ export const handlers = [ type: 'user', updated_by: 'user1', }), + alertFactory.build({ + id: 999, + label: 'Firewall - testing', + service_type: 'firewall', + type: 'user', + created_by: 'user1', + rule_criteria: { + rules: [firewallMetricRulesFactory.build()], + }, + }), ]; return HttpResponse.json(makeResourcePage(alerts)); }), http.get( '*/monitor/services/:serviceType/alert-definitions/:id', ({ params }) => { + if (params.id === '999' && params.serviceType === 'firewall') { + return HttpResponse.json( + alertFactory.build({ + id: 999, + label: 'Firewall - testing', + service_type: 'firewall', + type: 'user', + rule_criteria: { + rules: [firewallMetricRulesFactory.build()], + }, + }) + ); + } if (params.id !== undefined) { return HttpResponse.json( alertFactory.build({ @@ -2828,6 +2966,7 @@ export const handlers = [ }, service_type: params.serviceType === 'linode' ? 'linode' : 'dbaas', type: 'user', + scope: pickRandom(['account', 'region', 'entity']), }) ); } @@ -2837,6 +2976,19 @@ export const handlers = [ http.put( '*/monitor/services/:serviceType/alert-definitions/:id', ({ params, request }) => { + if (params.id === '999' && params.serviceType === 'firewall') { + return HttpResponse.json( + alertFactory.build({ + id: 999, + label: 'Firewall - testing', + service_type: 'firewall', + type: 'user', + rule_criteria: { + rules: [firewallMetricRulesFactory.build()], + }, + }) + ); + } const body: any = request.json(); return HttpResponse.json( alertFactory.build({ @@ -3228,6 +3380,9 @@ export const handlers = [ }, ], }; + if (params.serviceType === 'firewall') { + return HttpResponse.json({ data: firewallMetricDefinitionsResponse }); + } if (params.serviceType === 'nodebalancer') { return HttpResponse.json(nodebalancerMetricsResponse); } @@ -3255,7 +3410,7 @@ export const handlers = [ dashboardLabel = 'NodeBalancer Service I/O Statistics'; } else if (id === '4') { serviceType = 'firewall'; - dashboardLabel = 'Linode Service I/O Statistics'; + dashboardLabel = 'Firewall Service I/O Statistics'; } else { serviceType = 'linode'; dashboardLabel = 'Linode Service I/O Statistics'; @@ -3263,7 +3418,7 @@ export const handlers = [ const response = { created: '2024-04-29T17:09:29', - id: params.id, + id: Number(params.id), label: dashboardLabel, service_type: serviceType, type: 'standard', diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index d365c38aa20..6c57084afb5 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -78,7 +78,7 @@ export type MockPresetExtraGroupId = | 'Maintenance' | 'Managed' | 'Notifications' - | 'Profile' + | 'Profile & Grants' | 'Regions' | 'User Permissions'; @@ -88,7 +88,7 @@ export type MockPresetExtraGroupType = | 'events' | 'maintenance' | 'notifications' - | 'profile' + | 'profile & grants' | 'select' | 'userPermissions'; @@ -102,7 +102,7 @@ export type MockPresetExtraId = | 'limits:lke-limits' | 'maintenance:custom' | 'notifications:custom' - | 'profile:custom' + | 'profile-grants:custom' | 'regions:core-and-distributed' | 'regions:core-only' | 'regions:legacy' diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 1a1bdac1fc2..8b78ef91fa8 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -252,10 +252,44 @@ export const useServiceAlertsMutation = ( mutationFn: (payload: CloudPulseAlertsPayload) => { return updateServiceAlerts(serviceType, entityId, payload); }, - onSuccess() { + onSuccess(_, payload) { + const allAlerts = queryClient.getQueryData( + queryFactory.alerts._ctx.all().queryKey + ); + + // Get alerts previously enabled for this entity + const oldEnabledAlertIds = + allAlerts + ?.filter((alert) => alert.entity_ids.includes(entityId)) + .map((alert) => alert.id) || []; + + // Combine enabled user and system alert IDs from payload + const newEnabledAlertIds = [ + ...(payload.user ?? []), + ...(payload.system ?? []), + ]; + + // Get unique list of all enabled alert IDs for cache invalidation + const alertIdsToInvalidate = Array.from( + new Set([...oldEnabledAlertIds, ...newEnabledAlertIds]) + ); + queryClient.invalidateQueries({ queryKey: queryFactory.resources(serviceType).queryKey, }); + + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.all().queryKey, + }); + + alertIdsToInvalidate.forEach((alertId) => { + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId( + serviceType, + String(alertId) + ).queryKey, + }); + }); }, }); }; diff --git a/packages/manager/src/routes/account/index.ts b/packages/manager/src/routes/account/index.ts index aec45d398cb..8a4b97e3bae 100644 --- a/packages/manager/src/routes/account/index.ts +++ b/packages/manager/src/routes/account/index.ts @@ -142,7 +142,7 @@ const accountSettingsRoute = createRoute({ beforeLoad: ({ context }) => { if (context?.flags?.iamRbacPrimaryNavChanges) { throw redirect({ - to: `/settings`, + to: `/account-settings`, replace: true, }); } diff --git a/packages/manager/src/routes/settings/SettingsRoute.tsx b/packages/manager/src/routes/accountSettings/AccountSettingsRoute.tsx similarity index 90% rename from packages/manager/src/routes/settings/SettingsRoute.tsx rename to packages/manager/src/routes/accountSettings/AccountSettingsRoute.tsx index 14e0369843e..58ff4bf6410 100644 --- a/packages/manager/src/routes/settings/SettingsRoute.tsx +++ b/packages/manager/src/routes/accountSettings/AccountSettingsRoute.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -export const SettingsRoute = () => { +export const AccountSettingsRoute = () => { return ( }> diff --git a/packages/manager/src/routes/accountSettings/index.ts b/packages/manager/src/routes/accountSettings/index.ts new file mode 100644 index 00000000000..8b398567130 --- /dev/null +++ b/packages/manager/src/routes/accountSettings/index.ts @@ -0,0 +1,42 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { AccountSettingsRoute } from './AccountSettingsRoute'; + +const accountSettingsRoute = createRoute({ + component: AccountSettingsRoute, + getParentRoute: () => rootRoute, + path: 'account-settings', +}); + +// Catch all route for account-settings page +const accountSettingsCatchAllRoute = createRoute({ + getParentRoute: () => accountSettingsRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/account-settings' }); + }, +}); + +// Index route: /account-settings (main settings content) +const accountSettingsIndexRoute = createRoute({ + getParentRoute: () => accountSettingsRoute, + path: '/', + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/account/settings`, + replace: true, + }); + } + }, +}).lazy(() => + import('src/features/AccountSettings/accountSettingsLandingLazyRoute').then( + (m) => m.accountSettingsLandingLazyRoute + ) +); + +export const accountSettingsRouteTree = accountSettingsRoute.addChildren([ + accountSettingsIndexRoute, + accountSettingsCatchAllRoute, +]); diff --git a/packages/manager/src/routes/datastream/index.ts b/packages/manager/src/routes/datastream/index.ts index 7f421ed2fdc..992795c7767 100644 --- a/packages/manager/src/routes/datastream/index.ts +++ b/packages/manager/src/routes/datastream/index.ts @@ -43,10 +43,27 @@ const streamsCreateRoute = createRoute({ path: 'streams/create', }).lazy(() => import( - 'src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute' + 'src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute' ).then((m) => m.streamCreateLazyRoute) ); +const streamsEditRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + params: { + parse: ({ streamId }: { streamId: string }) => ({ + streamId: Number(streamId), + }), + stringify: ({ streamId }: { streamId: number }) => ({ + streamId: String(streamId), + }), + }, + path: 'streams/$streamId/edit', +}).lazy(() => + import('src/features/DataStream/Streams/StreamForm/streamEditLazyRoute').then( + (m) => m.streamEditLazyRoute + ) +); + export interface DestinationSearchParams extends TableSearchParams { label?: string; } @@ -66,12 +83,32 @@ const destinationsCreateRoute = createRoute({ path: 'destinations/create', }).lazy(() => import( - 'src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute' + 'src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute' ).then((m) => m.destinationCreateLazyRoute) ); +const destinationsEditRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + params: { + parse: ({ destinationId }: { destinationId: string }) => ({ + destinationId: Number(destinationId), + }), + stringify: ({ destinationId }: { destinationId: number }) => ({ + destinationId: String(destinationId), + }), + }, + path: 'destinations/$destinationId/edit', +}).lazy(() => + import( + 'src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute' + ).then((m) => m.destinationEditLazyRoute) +); + export const dataStreamRouteTree = dataStreamRoute.addChildren([ dataStreamLandingRoute, - streamsRoute.addChildren([streamsCreateRoute]), - destinationsRoute.addChildren([destinationsCreateRoute]), + streamsRoute.addChildren([streamsCreateRoute, streamsEditRoute]), + destinationsRoute.addChildren([ + destinationsCreateRoute, + destinationsEditRoute, + ]), ]); diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 6fe0a84eba3..ef2b52210b0 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { ErrorComponent } from 'src/features/ErrorBoundary/ErrorComponent'; import { accountRouteTree } from './account'; +import { accountSettingsRouteTree } from './accountSettings'; import { cloudPulseAlertsRouteTree } from './alerts'; import { cancelLandingRoute, @@ -37,7 +38,6 @@ import { quotasRouteTree } from './quotas'; import { rootRoute } from './root'; import { searchRouteTree } from './search'; import { serviceTransfersRouteTree } from './serviceTransfers'; -import { settingsRouteTree } from './settings'; import { stackScriptsRouteTree } from './stackscripts'; import { supportRouteTree } from './support'; import { usersAndGrantsRouteTree } from './usersAndGrants'; @@ -56,6 +56,7 @@ const indexRoute = createRoute({ export const routeTree = rootRoute.addChildren([ indexRoute, + accountSettingsRouteTree, cancelLandingRoute, loginAsCustomerCallbackRoute, logoutRoute, @@ -85,7 +86,6 @@ export const routeTree = rootRoute.addChildren([ quotasRouteTree, searchRouteTree, serviceTransfersRouteTree, - settingsRouteTree, stackScriptsRouteTree, supportRouteTree, usersAndGrantsRouteTree, diff --git a/packages/manager/src/routes/profile/index.ts b/packages/manager/src/routes/profile/index.ts index 681c2a52a0d..879b6950833 100644 --- a/packages/manager/src/routes/profile/index.ts +++ b/packages/manager/src/routes/profile/index.ts @@ -1,4 +1,4 @@ -import { createRoute } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { ProfileRoute } from './ProfileRoute'; @@ -92,7 +92,38 @@ const profileReferralsRoute = createRoute({ ) ); +/** + * The new route /profile/preferences aligns with the Profile tab, which has been renamed to Preferences (My Settings). + * After the transition, and as part of the cleanup, we will be removing /profile/settings (profileSettingsRoute). + */ + +const profilePreferencesRoute = createRoute({ + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/profile/settings`, + replace: true, + }); + } + }, + getParentRoute: () => profileRoute, + path: 'preferences', + validateSearch: (search: ProfileSettingsSearchParams) => search, +}).lazy(() => + import('src/features/Profile/Settings/settingsLazyRoute').then( + (m) => m.preferencesLazyRoute + ) +); + const profileSettingsRoute = createRoute({ + beforeLoad: ({ context }) => { + if (context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/profile/preferences`, + replace: true, + }); + } + }, getParentRoute: () => profileRoute, path: 'settings', validateSearch: (search: ProfileSettingsSearchParams) => search, @@ -109,6 +140,7 @@ export const profileRouteTree = profileRoute.addChildren([ profileLishSettingsRoute, profileAPITokensRoute, profileOAuthClientsRoute, + profilePreferencesRoute, profileReferralsRoute, profileSettingsRoute, ]); diff --git a/packages/manager/src/routes/settings/index.ts b/packages/manager/src/routes/settings/index.ts deleted file mode 100644 index 47cf6c5aca6..00000000000 --- a/packages/manager/src/routes/settings/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createRoute, redirect } from '@tanstack/react-router'; - -import { rootRoute } from '../root'; -import { SettingsRoute } from './SettingsRoute'; - -const settingsRoute = createRoute({ - component: SettingsRoute, - getParentRoute: () => rootRoute, - path: 'settings', -}); - -// Catch all route for settings page -const settingsCatchAllRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: '/$invalidPath', - beforeLoad: () => { - throw redirect({ to: '/settings' }); - }, -}); - -// Index route: /settings (main settings content) -const settingsIndexRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: '/', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/settings`, - replace: true, - }); - } - }, -}).lazy(() => - import('src/features/Settings/settingsLandingLazyRoute').then( - (m) => m.settingsLandingLazyRoute - ) -); - -export const settingsRouteTree = settingsRoute.addChildren([ - settingsIndexRoute, - settingsCatchAllRoute, -]); diff --git a/packages/manager/src/routes/volumes/index.ts b/packages/manager/src/routes/volumes/index.ts index 14cee80fd5a..1209a9622e2 100644 --- a/packages/manager/src/routes/volumes/index.ts +++ b/packages/manager/src/routes/volumes/index.ts @@ -29,6 +29,24 @@ const volumesRoute = createRoute({ path: 'volumes', }); +const volumeDetailsRoute = createRoute({ + getParentRoute: () => volumesRoute, + parseParams: (params) => ({ + volumeId: Number(params.volumeId), + }), + // validateSearch: (search: VolumesSearchParams) => search, + path: '$volumeId', +}).lazy(() => + import('src/features/Volumes/VolumeDetails/volumeLandingLazyRoute').then( + (m) => m.volumeDetailsLazyRoute + ) +); + +const volumeDetailsSummaryRoute = createRoute({ + getParentRoute: () => volumeDetailsRoute, + path: 'summary', +}); + const volumesIndexRoute = createRoute({ getParentRoute: () => volumesRoute, path: '/', @@ -96,4 +114,5 @@ export const volumesRouteTree = volumesRoute.addChildren([ volumesIndexRoute.addChildren([volumeActionRoute]), volumesCreateRoute, volumesCatchAllRoute, + volumeDetailsRoute.addChildren([volumeDetailsSummaryRoute]), ]); diff --git a/packages/manager/src/testSetup.ts b/packages/manager/src/testSetup.ts index a34d2363bd6..7b221df7d12 100644 --- a/packages/manager/src/testSetup.ts +++ b/packages/manager/src/testSetup.ts @@ -61,6 +61,13 @@ global.ResizeObserver = class ResizeObserver { unobserve() {} }; +// @ts-expect-error Mock IntersectionObserver for tests +global.IntersectionObserver = class IntersectionObserver { + disconnect() {} + observe() {} + unobserve() {} +}; + /** *************************************** * Custom matchers & matchers overrides diff --git a/packages/manager/src/utilities/pricing/kubernetes.ts b/packages/manager/src/utilities/pricing/kubernetes.ts index 99c3cec1c4a..d1f7d7cab14 100644 --- a/packages/manager/src/utilities/pricing/kubernetes.ts +++ b/packages/manager/src/utilities/pricing/kubernetes.ts @@ -3,15 +3,15 @@ import { getLinodeRegionPrice } from './linodes'; import type { CreateNodePoolData, KubeNodePoolResponse, + LinodeType, Region, } from '@linode/api-v4/lib'; -import type { ExtendedType } from 'src/utilities/extendType'; interface MonthlyPriceOptions { count: number; region: Region['id'] | undefined; - type: ExtendedType | string; - types: ExtendedType[]; + type: LinodeType | string; + types: LinodeType[]; } interface TotalClusterPriceOptions { @@ -19,7 +19,7 @@ interface TotalClusterPriceOptions { highAvailabilityPrice?: number; pools: (CreateNodePoolData | KubeNodePoolResponse)[]; region: Region['id'] | undefined; - types: ExtendedType[]; + types: LinodeType[]; } /** @@ -35,7 +35,7 @@ export const getKubernetesMonthlyPrice = ({ if (!types || !type || !region) { return undefined; } - const thisType = types.find((t: ExtendedType) => t.id === type); + const thisType = types.find((t) => t.id === type); const monthlyPrice = getLinodeRegionPrice(thisType, region)?.monthly; diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index e2246edc104..bcaed67d166 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2025-09-09] - v0.13.0 + +### Upcoming Features: + +- Add queries for Streams DELETE, PUT API endpoints ([#12645](https://github.com/linode/manager/pull/12645)) +- Add queries for Destinations DELETE, PUT API endpoints ([#12749](https://github.com/linode/manager/pull/12749)) + ## [2025-08-26] - v0.12.0 ### Added: diff --git a/packages/queries/package.json b/packages/queries/package.json index df342c9da8b..ffcf3041600 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -1,6 +1,6 @@ { "name": "@linode/queries", - "version": "0.12.0", + "version": "0.13.0", "description": "Linode Utility functions library", "main": "src/index.js", "module": "src/index.ts", @@ -43,4 +43,4 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6" } -} +} \ No newline at end of file diff --git a/packages/queries/src/datastreams/datastream.ts b/packages/queries/src/datastreams/datastream.ts index 902c2be377d..ea5d928b7c7 100644 --- a/packages/queries/src/datastreams/datastream.ts +++ b/packages/queries/src/datastreams/datastream.ts @@ -1,10 +1,14 @@ import { createDestination, createStream, + deleteDestination, + deleteStream, getDestination, getDestinations, getStream, getStreams, + updateDestination, + updateStream, } from '@linode/api-v4'; import { profileQueries } from '@linode/queries'; import { getAll } from '@linode/utilities'; @@ -20,6 +24,8 @@ import type { Params, ResourcePage, Stream, + UpdateDestinationPayloadWithId, + UpdateStreamPayloadWithId, } from '@linode/api-v4'; export const getAllDataStreams = ( @@ -83,15 +89,21 @@ export const useStreamsQuery = (params: Params = {}, filter: Filter = {}) => ...datastreamQueries.streams._ctx.paginated(params, filter), }); +export const useStreamQuery = (id: number) => + useQuery({ ...datastreamQueries.stream(id) }); + export const useCreateStreamMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createStream, onSuccess(stream) { - // Invalidate paginated lists + // Invalidate streams queryClient.invalidateQueries({ queryKey: datastreamQueries.streams._ctx.paginated._def, }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.all._def, + }); // Set Stream in cache queryClient.setQueryData( @@ -107,6 +119,49 @@ export const useCreateStreamMutation = () => { }); }; +export const useUpdateStreamMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }) => updateStream(id, data), + onSuccess(stream) { + // Invalidate streams + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.all._def, + }); + + // Update stream in cache + queryClient.setQueryData( + datastreamQueries.stream(stream.id).queryKey, + stream, + ); + }, + }); +}; + +export const useDeleteStreamMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { id: number }>({ + mutationFn: ({ id }) => deleteStream(id), + onSuccess(_, { id }) { + // Invalidate streams + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.all._def, + }); + + // Remove stream from the cache + queryClient.removeQueries({ + queryKey: datastreamQueries.stream(id).queryKey, + }); + }, + }); +}; + export const useAllDestinationsQuery = ( params: Params = {}, filter: Filter = {}, @@ -123,15 +178,21 @@ export const useDestinationsQuery = ( ...datastreamQueries.destinations._ctx.paginated(params, filter), }); +export const useDestinationQuery = (id: number) => + useQuery({ ...datastreamQueries.destination(id) }); + export const useCreateDestinationMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createDestination, onSuccess(destination) { - // Invalidate paginated lists + // Invalidate destinations queryClient.invalidateQueries({ queryKey: datastreamQueries.destinations._ctx.paginated._def, }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.all._def, + }); // Set Destination in cache queryClient.setQueryData( @@ -146,3 +207,46 @@ export const useCreateDestinationMutation = () => { }, }); }; + +export const useUpdateDestinationMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }) => updateDestination(id, data), + onSuccess(destination) { + // Invalidate destinations + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.all._def, + }); + + // Update destination in cache + queryClient.setQueryData( + datastreamQueries.destination(destination.id).queryKey, + destination, + ); + }, + }); +}; + +export const useDeleteDestinationMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { id: number }>({ + mutationFn: ({ id }) => deleteDestination(id), + onSuccess(_, { id }) { + // Invalidate destinations + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.all._def, + }); + + // Remove stream from the cache + queryClient.removeQueries({ + queryKey: datastreamQueries.destination(id).queryKey, + }); + }, + }); +}; diff --git a/packages/queries/src/images/images.ts b/packages/queries/src/images/images.ts index 0664ebc5d3c..9001b26e5d1 100644 --- a/packages/queries/src/images/images.ts +++ b/packages/queries/src/images/images.ts @@ -30,7 +30,10 @@ import type { UploadImageResponse, } from '@linode/api-v4'; import type { EventHandlerData } from '@linode/queries'; -import type { UseQueryOptions } from '@tanstack/react-query'; +import type { + UseMutationOptions, + UseQueryOptions, +} from '@tanstack/react-query'; export const getAllImages = ( passedParams: Params = {}, @@ -133,11 +136,15 @@ export const useUpdateImageMutation = () => { }); }; -export const useDeleteImageMutation = () => { +export const useDeleteImageMutation = ( + options: UseMutationOptions<{}, APIError[], { imageId: string }>, +) => { const queryClient = useQueryClient(); return useMutation<{}, APIError[], { imageId: string }>({ mutationFn: ({ imageId }) => deleteImage(imageId), - onSuccess(_, variables) { + ...options, + onSuccess(response, variables, context) { + options.onSuccess?.(response, variables, context); queryClient.invalidateQueries({ queryKey: imageQueries.paginated._def, }); diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index bb733a4dfc4..dc146b6d71c 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,5 +1,10 @@ -## [2025-08-12] - v0.7.0 +## [2025-09-09] - v0.8.0 + +### Tests: + +- Add Mock IntersectionObserver in testSetup.ts ([#12777](https://github.com/linode/manager/pull/12777)) +## [2025-08-12] - v0.7.0 ### Changed: @@ -7,14 +12,12 @@ ## [2025-07-29] - v0.6.0 - ### Fixed: - `LinodeSelect` not filtering by the `optionsFilter` when `options` was passed as props ([#12529](https://github.com/linode/manager/pull/12529)) ## [2025-07-15] - v0.5.0 - ### Upcoming Features: - Add `useIsLinodeAclpSubscribed` hook and unit tests ([#12479](https://github.com/linode/manager/pull/12479)) diff --git a/packages/shared/package.json b/packages/shared/package.json index efabcbf6772..781bde21a8d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@linode/shared", - "version": "0.7.0", + "version": "0.8.0", "description": "Linode shared feature component library", "main": "src/index.ts", "module": "src/index.ts", @@ -49,4 +49,4 @@ "@types/react-dom": "^19.1.6", "vite-plugin-svgr": "^3.2.0" } -} +} \ No newline at end of file diff --git a/packages/shared/testSetup.ts b/packages/shared/testSetup.ts index 141cd45f9a4..f28e2d3a90e 100644 --- a/packages/shared/testSetup.ts +++ b/packages/shared/testSetup.ts @@ -7,3 +7,10 @@ expect.extend(matchers); afterEach(() => { cleanup(); }); + +// @ts-expect-error Mock IntersectionObserver for tests +global.IntersectionObserver = class IntersectionObserver { + disconnect() {} + observe() {} + unobserve() {} +}; diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 4bbe8ecdf7f..2c46fbd15ca 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,10 @@ -## [2025-08-26] - v0.19.0 +## [2025-09-09] - v0.20.0 + +### Fixed: +- Disabled list option tooltip behavior in selects ([#12777](https://github.com/linode/manager/pull/12777)) + +## [2025-08-26] - v0.19.0 ### Changed: @@ -7,7 +12,6 @@ ## [2025-08-12] - v0.18.0 - ### Changed: - Use gap for TableSortLabel spacing of text and icons ([#12512](https://github.com/linode/manager/pull/12512)) @@ -20,21 +24,18 @@ ## [2025-07-29] - v0.17.0 - ### Changed: - Textfield styles and color to match ADS ([#12496](https://github.com/linode/manager/pull/12496)) -- Add qa-ids to `DateTimeRangePicker.tsx` and `TimeZoneSelect.tsx` files, update `Presets.tsx` to calculate date according to selected timezone ([#12497](https://github.com/linode/manager/pull/12497)) +- Add qa-ids to `DateTimeRangePicker.tsx` and `TimeZoneSelect.tsx` files, update `Presets.tsx` to calculate date according to selected timezone ([#12497](https://github.com/linode/manager/pull/12497)) - Use gap for TableSortLabel spacing of text and icons ([#12512](https://github.com/linode/manager/pull/12512)) ### Fixed: - `TextField` not respecting `inputProps.id` and `InputProps.id` ([#12502](https://github.com/linode/manager/pull/12502)) - ## [2025-07-15] - v0.16.0 - ### Added: - Add `null` as type option for `headingChip` ([#12460](https://github.com/linode/manager/pull/12460)) @@ -48,7 +49,6 @@ ## [2025-07-01] - v0.15.0 - ### Changed: - Add `Toggle` design tokens and update styles to match Akamai Design System ([#12303](https://github.com/linode/manager/pull/12303)) @@ -71,7 +71,6 @@ ## [2025-06-17] - v0.14.0 - ### Changed: - Add `Select` design tokens and update styles to match Akamai Design System ([#12124](https://github.com/linode/manager/pull/12124)) diff --git a/packages/ui/package.json b/packages/ui/package.json index 0824f8dfc31..1cd2bdc0433 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@linode/ui", "author": "Linode", "description": "Linode UI component library", - "version": "0.19.0", + "version": "0.20.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", @@ -56,4 +56,4 @@ "@types/react-dom": "^19.1.6", "vite-plugin-svgr": "^3.2.0" } -} +} \ No newline at end of file diff --git a/packages/ui/src/components/ListItemOption/ListItemOption.tsx b/packages/ui/src/components/ListItemOption/ListItemOption.tsx index 7e6dad29638..c755d463271 100644 --- a/packages/ui/src/components/ListItemOption/ListItemOption.tsx +++ b/packages/ui/src/components/ListItemOption/ListItemOption.tsx @@ -44,7 +44,8 @@ export const ListItemOption = ({ const disabledReason = disabledOptions?.reason; // Used to control the Tooltip - const [isFocused, setIsFocused] = useState(false); + const [isDisabledItemFocused, setIsDisabledItemFocused] = useState(false); + const [isDisabledItemInView, setIsDisabledItemInView] = useState(false); const listItemRef = useRef(null); useEffect(() => { @@ -53,24 +54,36 @@ export const ListItemOption = ({ return; } if (!isOptionDisabled) { - // We don't need to setup the mutation observer for options that are enabled. They won't have a tooltip + // We don't need to setup the observers for options that are enabled. They won't have a tooltip return; } - const observer = new MutationObserver(() => { + const intersectionObserver = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setIsDisabledItemInView(true); + } else { + setIsDisabledItemInView(false); + } + }); + intersectionObserver.observe(listItemRef.current); + + const mutationObserver = new MutationObserver(() => { const className = listItemRef.current?.className; const hasFocusedClass = className?.includes('Mui-focused') ?? false; if (hasFocusedClass) { - setIsFocused(true); + setIsDisabledItemFocused(true); } else if (!hasFocusedClass) { - setIsFocused(false); + setIsDisabledItemFocused(false); } }); - observer.observe(listItemRef.current, { attributeFilter: ['class'] }); + mutationObserver.observe(listItemRef.current, { + attributeFilter: ['class'], + }); return () => { - observer.disconnect(); + mutationObserver.disconnect(); + intersectionObserver.disconnect(); }; }, [isOptionDisabled]); @@ -109,7 +122,7 @@ export const ListItemOption = ({ if (isOptionDisabled) { return ( { describe('createRescueDevicesPostObject', () => { it('Returns the minimum requirement.', () => { const result = createDevicesFromStrings({}); - const expected = { - sda: null, - sdb: null, - sdc: null, - sdd: null, - sde: null, - sdf: null, - sdg: null, - sdh: null, - }; + const expected = {}; expect(result).toEqual(expected); }); @@ -26,13 +17,7 @@ describe('LinodeRescue', () => { }); const expected = { sda: { disk_id: 123 }, - sdb: null, - sdc: null, sdd: { disk_id: 456 }, - sde: null, - sdf: null, - sdg: null, - sdh: null, }; expect(result).toEqual(expected); @@ -44,14 +29,8 @@ describe('LinodeRescue', () => { sde: 'volume-456', }); const expected = { - sda: null, sdb: { volume_id: 123 }, - sdc: null, - sdd: null, sde: { volume_id: 456 }, - sdf: null, - sdg: null, - sdh: null, }; expect(result).toEqual(expected); @@ -63,14 +42,7 @@ describe('LinodeRescue', () => { sdd: 'disk-456', }); const expected = { - sda: null, - sdb: null, - sdc: null, sdd: { disk_id: 456 }, - sde: null, - sdf: null, - sdg: null, - sdh: null, }; expect(result).toEqual(expected); }); diff --git a/packages/utilities/src/helpers/createDevicesFromStrings.ts b/packages/utilities/src/helpers/createDevicesFromStrings.ts index 66e214b08d5..c7dcb252a32 100644 --- a/packages/utilities/src/helpers/createDevicesFromStrings.ts +++ b/packages/utilities/src/helpers/createDevicesFromStrings.ts @@ -4,23 +4,28 @@ type DiskRecord = Record<'disk_id', number>; type VolumeRecord = Record<'volume_id', number>; -export interface DevicesAsStrings { - sda?: string; - sdb?: string; - sdc?: string; - sdd?: string; - sde?: string; - sdf?: string; - sdg?: string; - sdh?: string; -} +/** + * Maps the Devices type to have optional string values instead of device objects. + * This allows us to work with string representations like "volume-123" or "disk-456" + * before converting them to the proper API format. + */ +type StringTypeMap = { + [key in keyof T]?: string; +}; + +export type DevicesAsStrings = StringTypeMap; /** - * The `value` should be formatted as volume-123, disk-123, etc., + * Creates a device record from a string representation. + * + * Device slots are optional and may not exist + * in all contexts, so empty slots can be represented as `undefined`. */ -const createTypeRecord = (value?: string): DiskRecord | null | VolumeRecord => { - if (value === null || value === undefined || value === 'none') { - return null; +const createTypeRecord = ( + value?: string, +): DiskRecord | null | undefined | VolumeRecord => { + if (value === undefined || value === null || value === 'none') { + return undefined; } // Given: volume-123 @@ -47,4 +52,60 @@ export const createDevicesFromStrings = ( sdf: createTypeRecord(devices.sdf), sdg: createTypeRecord(devices.sdg), sdh: createTypeRecord(devices.sdh), + sdi: createTypeRecord(devices.sdi), + sdj: createTypeRecord(devices.sdj), + sdk: createTypeRecord(devices.sdk), + sdl: createTypeRecord(devices.sdl), + sdm: createTypeRecord(devices.sdm), + sdn: createTypeRecord(devices.sdn), + sdo: createTypeRecord(devices.sdo), + sdp: createTypeRecord(devices.sdp), + sdq: createTypeRecord(devices.sdq), + sdr: createTypeRecord(devices.sdr), + sds: createTypeRecord(devices.sds), + sdt: createTypeRecord(devices.sdt), + sdu: createTypeRecord(devices.sdu), + sdv: createTypeRecord(devices.sdv), + sdw: createTypeRecord(devices.sdw), + sdx: createTypeRecord(devices.sdx), + sdy: createTypeRecord(devices.sdy), + sdz: createTypeRecord(devices.sdz), + sdaa: createTypeRecord(devices.sdaa), + sdab: createTypeRecord(devices.sdab), + sdac: createTypeRecord(devices.sdac), + sdad: createTypeRecord(devices.sdad), + sdae: createTypeRecord(devices.sdae), + sdaf: createTypeRecord(devices.sdaf), + sdag: createTypeRecord(devices.sdag), + sdah: createTypeRecord(devices.sdah), + sdai: createTypeRecord(devices.sdai), + sdaj: createTypeRecord(devices.sdaj), + sdak: createTypeRecord(devices.sdak), + sdal: createTypeRecord(devices.sdal), + sdam: createTypeRecord(devices.sdam), + sdan: createTypeRecord(devices.sdan), + sdao: createTypeRecord(devices.sdao), + sdap: createTypeRecord(devices.sdap), + sdaq: createTypeRecord(devices.sdaq), + sdar: createTypeRecord(devices.sdar), + sdas: createTypeRecord(devices.sdas), + sdat: createTypeRecord(devices.sdat), + sdau: createTypeRecord(devices.sdau), + sdav: createTypeRecord(devices.sdav), + sdaw: createTypeRecord(devices.sdaw), + sdax: createTypeRecord(devices.sdax), + sday: createTypeRecord(devices.sday), + sdaz: createTypeRecord(devices.sdaz), + sdba: createTypeRecord(devices.sdba), + sdbb: createTypeRecord(devices.sdbb), + sdbc: createTypeRecord(devices.sdbc), + sdbd: createTypeRecord(devices.sdbd), + sdbe: createTypeRecord(devices.sdbe), + sdbf: createTypeRecord(devices.sdbf), + sdbg: createTypeRecord(devices.sdbg), + sdbh: createTypeRecord(devices.sdbh), + sdbi: createTypeRecord(devices.sdbi), + sdbj: createTypeRecord(devices.sdbj), + sdbk: createTypeRecord(devices.sdbk), + sdbl: createTypeRecord(devices.sdbl), }); diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index b0d3e30ad83..c768baa5664 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2025-09-09] - v0.74.0 + +### Added: + +- Additional device slots to `devices` schema ([#12791](https://github.com/linode/manager/pull/12791)) +- Node Pool schemas `CreateNodePoolSchema` and `EditNodePoolSchema` ([#12793](https://github.com/linode/manager/pull/12793)) + +### Removed: + +- General Node Pool schema `nodePoolSchema` ([#12793](https://github.com/linode/manager/pull/12793)) + ## [2025-08-26] - v0.73.0 ### Changed: diff --git a/packages/validation/package.json b/packages/validation/package.json index 9db74739062..fd68f42351b 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.73.0", + "version": "0.74.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", @@ -61,4 +61,4 @@ }, "author": "Linode LLC", "license": "Apache-2.0" -} +} \ No newline at end of file diff --git a/packages/validation/src/datastream.schema.ts b/packages/validation/src/datastream.schema.ts index 14d21b97088..5fafc0a1b02 100644 --- a/packages/validation/src/datastream.schema.ts +++ b/packages/validation/src/datastream.schema.ts @@ -74,7 +74,7 @@ const linodeObjectStorageDetailsSchema = object({ .required('Access Key Secret is required.'), }); -export const createDestinationSchema = object().shape({ +export const destinationSchema = object().shape({ label: string() .max(maxLength, maxLengthMessage) .required('Destination name is required.'), @@ -125,36 +125,39 @@ const streamSchemaBase = object({ .max(maxLength, maxLengthMessage) .required('Stream name is required.'), status: mixed<'active' | 'inactive'>().oneOf(['active', 'inactive']), + type: string() + .oneOf(['audit_logs', 'lke_audit_logs']) + .required('Stream type is required.'), destinations: array().of(number().defined()).ensure().min(1).required(), - details: mixed | object>().when( - 'type', - { + details: mixed | object>() + .when('type', { is: 'lke_audit_logs', then: () => streamDetailsSchema.required(), otherwise: detailsShouldBeEmpty, - }, - ), + }) + .required(), }); -export const createStreamSchema = streamSchemaBase.shape({ - type: string() - .oneOf(['audit_logs', 'lke_audit_logs']) - .required('Stream type is required.'), +export const createStreamSchema = streamSchemaBase; + +export const updateStreamSchema = streamSchemaBase.shape({ + status: mixed<'active' | 'inactive'>() + .oneOf(['active', 'inactive']) + .required(), }); -export const createStreamAndDestinationFormSchema = object({ - stream: createStreamSchema.shape({ +export const streamAndDestinationFormSchema = object({ + stream: streamSchemaBase.shape({ destinations: array().of(number()).ensure().min(1).required(), - details: mixed | object>().when( - 'type', - { + details: mixed | object>() + .when('type', { is: 'lke_audit_logs', then: () => streamDetailsBase.required(), otherwise: detailsShouldBeEmpty, - }, - ), + }) + .required(), }), - destination: createDestinationSchema.defined().when('stream.destinations', { + destination: destinationSchema.defined().when('stream.destinations', { is: (value: never[]) => value?.length === 1 && value[0] === undefined, then: (schema) => schema, otherwise: (schema) => diff --git a/packages/validation/src/kubernetes.schema.ts b/packages/validation/src/kubernetes.schema.ts index 2eabbcb528e..218c9a5f63c 100644 --- a/packages/validation/src/kubernetes.schema.ts +++ b/packages/validation/src/kubernetes.schema.ts @@ -2,10 +2,70 @@ import { array, boolean, number, object, string } from 'yup'; import { validateIP } from './firewalls.schema'; -export const nodePoolSchema = object({ +// Starts and ends with a letter or number and contains letters, numbers, hyphens, dots, and underscores +const alphaNumericValidCharactersRegex = + /^[a-zA-Z0-9]([a-zA-Z0-9-._]*[a-zA-Z0-9])?$/; + +export const kubernetesTaintSchema = object({ + key: string() + .required('Key is required.') + .test( + 'valid-key', + 'Key must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 253 characters.', + (value) => { + return ( + alphaNumericValidCharactersRegex.test(value) || + dnsKeyRegex.test(value) + ); + }, + ) + .max(253, 'Key must be between 1 and 253 characters.') + .min(1, 'Key must be between 1 and 253 characters.'), + value: string() + .matches( + alphaNumericValidCharactersRegex, + 'Value must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 63 characters.', + ) + .max(63, 'Value must be between 0 and 63 characters.') + .notOneOf( + ['kubernetes.io', 'linode.com'], + 'Value cannot be "kubernetes.io" or "linode.com".', + ) + .notRequired(), +}); + +const NodePoolDiskSchema = object({ + size: number().required(), + type: string() + .oneOf(['raw', 'ext4'] as const) + .required(), +}); + +const AutoscaleSettingsSchema = object({ + enabled: boolean().required(), + max: number().required(), + min: number().required(), +}); + +export const CreateNodePoolSchema = object({ + autoscaler: AutoscaleSettingsSchema.notRequired().default(undefined), + type: string().required('Type is required.'), + count: number().required(), + tags: array(string().defined()).notRequired(), + disks: array(NodePoolDiskSchema).notRequired(), + update_strategy: string() + .oneOf(['rolling_update', 'on_recycle'] as const) + .notRequired(), + k8_version: string().notRequired(), + firewall_id: number().notRequired(), + labels: object().notRequired(), + taints: array(kubernetesTaintSchema).notRequired(), +}); + +export const EditNodePoolSchema = object({ type: string(), count: number(), - upgrade_strategy: string(), + update_strategy: string(), k8_version: string(), firewall_id: number(), }); @@ -58,7 +118,7 @@ export const createKubeClusterSchema = object({ region: string().required('Region is required.'), k8s_version: string().required('Kubernetes version is required.'), node_pools: array() - .of(nodePoolSchema) + .of(CreateNodePoolSchema) .min(1, 'Please add at least one node pool.'), }); @@ -105,10 +165,6 @@ export const kubernetesEnterpriseControlPlaneACLPayloadSchema = object({ ), }); -// Starts and ends with a letter or number and contains letters, numbers, hyphens, dots, and underscores -const alphaNumericValidCharactersRegex = - /^[a-zA-Z0-9]([a-zA-Z0-9-._]*[a-zA-Z0-9])?$/; - // DNS subdomain key (example.com/my-app) const dnsKeyRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-._/]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; @@ -170,31 +226,3 @@ export const kubernetesLabelSchema = object().test({ message: 'Labels must be valid key-value pairs.', test: validateKubernetesLabel, }); - -export const kubernetesTaintSchema = object({ - key: string() - .required('Key is required.') - .test( - 'valid-key', - 'Key must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 253 characters.', - (value) => { - return ( - alphaNumericValidCharactersRegex.test(value) || - dnsKeyRegex.test(value) - ); - }, - ) - .max(253, 'Key must be between 1 and 253 characters.') - .min(1, 'Key must be between 1 and 253 characters.'), - value: string() - .matches( - alphaNumericValidCharactersRegex, - 'Value must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 63 characters.', - ) - .max(63, 'Value must be between 0 and 63 characters.') - .notOneOf( - ['kubernetes.io', 'linode.com'], - 'Value cannot be "kubernetes.io" or "linode.com".', - ) - .notRequired(), -}); diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 70c9d9fb1b7..97583ba9a6b 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -457,17 +457,75 @@ export const CreateSnapshotSchema = object({ const device = object({ disk_id: number().nullable(), volume_id: number().nullable(), -}).nullable(); +}) + .nullable() + .notRequired(); const devices = object({ sda: device, + sdaa: device, + sdab: device, + sdac: device, + sdad: device, + sdae: device, + sdaf: device, + sdag: device, + sdah: device, + sdai: device, + sdaj: device, + sdak: device, + sdal: device, + sdam: device, + sdan: device, + sdao: device, + sdap: device, + sdaq: device, + sdar: device, + sdas: device, + sdat: device, + sdau: device, + sdav: device, + sdaw: device, + sdax: device, + sday: device, + sdaz: device, sdb: device, + sdba: device, + sdbb: device, + sdbc: device, + sdbd: device, + sdbe: device, + sdbf: device, + sdbg: device, + sdbh: device, + sdbi: device, + sdbj: device, + sdbk: device, + sdbl: device, sdc: device, sdd: device, sde: device, sdf: device, sdg: device, sdh: device, + sdi: device, + sdj: device, + sdk: device, + sdl: device, + sdm: device, + sdn: device, + sdo: device, + sdp: device, + sdq: device, + sdr: device, + sds: device, + sdt: device, + sdu: device, + sdv: device, + sdw: device, + sdx: device, + sdy: device, + sdz: device, }); const helpers = object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8efe6c60cf..018a069cd0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,11 +252,11 @@ importers: specifier: ^1.9.1 version: 1.9.1 jspdf: - specifier: ^3.0.1 - version: 3.0.1 + specifier: ^3.0.2 + version: 3.0.2 jspdf-autotable: specifier: ^5.0.2 - version: 5.0.2(jspdf@3.0.1) + version: 5.0.2(jspdf@3.0.2) launchdarkly-react-client-sdk: specifier: 3.0.10 version: 3.0.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -443,7 +443,7 @@ importers: version: 3.7.2(vite@6.3.4(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^3.1.2 - version: 3.1.2(vitest@3.1.2) + version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.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) @@ -2266,6 +2266,9 @@ packages: '@types/novnc__novnc@1.5.0': resolution: {integrity: sha512-9DrDJK1hUT6Cbp4t03IsU/DsR6ndnIrDgZVrzITvspldHQ7n81F3wUDfq89zmPM3wg4GErH11IQa0QuTgLMf+w==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -2664,11 +2667,6 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} - atob@2.1.2: - resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} - engines: {node: '>= 4.5.0'} - hasBin: true - attr-accept@2.2.5: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} @@ -2754,11 +2752,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - btoa@1.2.1: - resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==} - engines: {node: '>= 0.4.0'} - hasBin: true - buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -3593,6 +3586,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -3983,6 +3979,9 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4261,8 +4260,8 @@ packages: peerDependencies: jspdf: ^2 || ^3 - jspdf@3.0.1: - resolution: {integrity: sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==} + jspdf@3.0.2: + resolution: {integrity: sha512-G0fQDJ5fAm6UW78HG6lNXyq09l0PrA1rpNY5i+ly17Zb1fMMFSmS+3lw4cnrAPGyouv2Y0ylujbY2Ieq3DSlKA==} jsprim@2.0.2: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} @@ -4742,6 +4741,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7582,6 +7584,8 @@ snapshots: '@types/novnc__novnc@1.5.0': {} + '@types/pako@2.0.4': {} + '@types/parse-json@4.0.2': {} '@types/paypal-checkout-components@4.0.8': {} @@ -7826,7 +7830,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@3.1.2(vitest@3.1.2)': + '@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.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 @@ -8104,8 +8108,6 @@ snapshots: at-least-node@1.0.0: {} - atob@2.1.2: {} - attr-accept@2.2.5: {} available-typed-arrays@1.0.7: @@ -8205,8 +8207,6 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) - btoa@1.2.1: {} - buffer-crc32@0.2.13: {} buffer-from@1.1.2: {} @@ -9210,6 +9210,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -9618,6 +9624,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + iobuffer@5.4.0: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.2.0: {} @@ -9880,15 +9888,14 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jspdf-autotable@5.0.2(jspdf@3.0.1): + jspdf-autotable@5.0.2(jspdf@3.0.2): dependencies: - jspdf: 3.0.1 + jspdf: 3.0.2 - jspdf@3.0.1: + jspdf@3.0.2: dependencies: '@babel/runtime': 7.27.1 - atob: 2.1.2 - btoa: 1.2.1 + fast-png: 6.4.0 fflate: 0.8.2 optionalDependencies: canvg: 3.0.11 @@ -10464,6 +10471,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/scripts/junit-summary/util/index.ts b/scripts/junit-summary/util/index.ts index 945891272e6..5ce008ad66b 100644 --- a/scripts/junit-summary/util/index.ts +++ b/scripts/junit-summary/util/index.ts @@ -9,10 +9,32 @@ import type { TestResult } from '../results/test-result'; * @returns Length of time for all suites to run, in seconds. */ export const getTestLength = (suites: TestSuites[]): number => { - const unroundedLength = suites.reduce((acc: number, cur: TestSuites) => { - return acc + (cur.time ?? 0); - }, 0); - return Math.round(unroundedLength * 1000) / 1000; + const testDurations: {[key: number]: number} = suites.reduce((acc: {[key: number]: number}, cur: TestSuites) => { + const suite = cur.testsuite?.[0]; + if (!suite) { + return acc; + } + + const runnerIndex = (() => { + if (!suite.properties) { + return 1; + } + const indexProperty = suite.properties.find((property) => { + return property.name === 'runner_index'; + }); + + if (!indexProperty) { + return 1; + } + return Number(indexProperty.value); + })(); + + acc[runnerIndex] = (acc[runnerIndex] || 0) + (cur.time ?? 0); + return acc; + }, {}); + + const highestDuration = Math.max(...Object.values(testDurations)); + return Math.round(highestDuration * 1000) / 1000; }; /** From 17583039e348ed538b80acecfcd4bdbcd8131f18 Mon Sep 17 00:00:00 2001 From: bill-akamai Date: Fri, 5 Sep 2025 14:29:18 -0500 Subject: [PATCH 02/12] change: [M3-10593] - Update Self-Hosted Pendo Agent to Support data-pendo-id Attribute (#12828) * Update Pendo agent script for staging and prod * Update changelog --- packages/manager/CHANGELOG.md | 1 + .../manager/public/pendo/pendo-staging.js | 235 ++++++++--------- packages/manager/public/pendo/pendo.js | 247 +++++++++--------- 3 files changed, 243 insertions(+), 240 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 1b0205fdb8f..4c02215ab43 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Support IAM/RBAC permission segmentation for BETA/LA features ([#12764](https://github.com/linode/manager/pull/12764)) - Aggregation Function labels from Average,Minimum,Maximum to Avg,Min,Max in ACLP-Alerting service ([#12787](https://github.com/linode/manager/pull/12787)) - Add data-pendo-id attribute to TabbedPanel for Linode Plan tab tracking ([#12806](https://github.com/linode/manager/pull/12806)) +- Update self-hosted Pendo agent script to support data-pendo-id attribute ([#12828](https://github.com/linode/manager/pull/12828)) ### Fixed: diff --git a/packages/manager/public/pendo/pendo-staging.js b/packages/manager/public/pendo/pendo-staging.js index 6703205b634..0b327092f3e 100644 --- a/packages/manager/public/pendo/pendo-staging.js +++ b/packages/manager/public/pendo/pendo-staging.js @@ -1,121 +1,122 @@ // Pendo Agent Wrapper // Copyright 2025 Pendo.io, Inc. // Environment: staging -// Agent Version: 2.285.2 -// Installed: 2025-07-18T19:07:45Z +// Agent Version: 2.291.3 +// Installed: 2025-09-05T18:20:46Z (function (PendoConfig) { -/* -@license https://agent.pendo.io/licenses -*/ -!function(D,G,E){{var H="undefined"!=typeof PendoConfig?PendoConfig:{};z="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("");var z,j,o,W,J,q,K,V,$={uint8ToBase64:function(e){var t,n,i,r=e.length%3,o="";for(t=0,i=e.length-r;t>18&63]+z[e>>12&63]+z[e>>6&63]+z[63&e]}(n);switch(r){case 1:n=e[e.length-1],o=(o+=z[n>>2])+z[n<<4&63];break;case 2:n=(e[e.length-2]<<8)+e[e.length-1],o=(o=(o+=z[n>>10])+z[n>>4&63])+z[n<<2&63]}return o}},Ut="undefined"!=typeof globalThis?globalThis:void 0!==D?D:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};function Z(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e["default"]:e}function Y(e){e?(K[0]=K[16]=K[1]=K[2]=K[3]=K[4]=K[5]=K[6]=K[7]=K[8]=K[9]=K[10]=K[11]=K[12]=K[13]=K[14]=K[15]=0,this.blocks=K):this.blocks=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],this.h0=1732584193,this.h1=4023233417,this.h2=2562383102,this.h3=271733878,this.h4=3285377520,this.block=this.start=this.bytes=this.hBytes=0,this.finalized=this.hashed=!1,this.first=!0}a=Be={exports:{}},e=!(j="object"==typeof D?D:{}).JS_SHA1_NO_COMMON_JS&&a.exports,o="0123456789abcdef".split(""),W=[-2147483648,8388608,32768,128],J=[24,16,8,0],q=["hex","array","digest","arrayBuffer"],K=[],V=function(t){return function(e){return new Y(!0).update(e)[t]()}},Y.prototype.update=function(e){if(!this.finalized){for(var t,n,i="string"!=typeof e,r=0,o=(e=i&&e.constructor===j.ArrayBuffer?new Uint8Array(e):e).length||0,a=this.blocks;r>2]|=e[r]<>2]|=t<>2]|=(192|t>>6)<>2]|=(224|t>>12)<>2]|=(240|t>>18)<>2]|=(128|t>>12&63)<>2]|=(128|t>>6&63)<>2]|=(128|63&t)<>2]|=W[3&t],this.block=e[16],56<=t&&(this.hashed||this.hash(),e[0]=this.block,e[16]=e[1]=e[2]=e[3]=e[4]=e[5]=e[6]=e[7]=e[8]=e[9]=e[10]=e[11]=e[12]=e[13]=e[14]=e[15]=0),e[14]=this.hBytes<<3|this.bytes>>>29,e[15]=this.bytes<<3,this.hash())},Y.prototype.hash=function(){for(var e,t=this.h0,n=this.h1,i=this.h2,r=this.h3,o=this.h4,a=this.blocks,s=16;s<80;++s)e=a[s-3]^a[s-8]^a[s-14]^a[s-16],a[s]=e<<1|e>>>31;for(s=0;s<20;s+=5)t=(e=(n=(e=(i=(e=(r=(e=(o=(e=t<<5|t>>>27)+(n&i|~n&r)+o+1518500249+a[s]<<0)<<5|o>>>27)+(t&(n=n<<30|n>>>2)|~t&i)+r+1518500249+a[s+1]<<0)<<5|r>>>27)+(o&(t=t<<30|t>>>2)|~o&n)+i+1518500249+a[s+2]<<0)<<5|i>>>27)+(r&(o=o<<30|o>>>2)|~r&t)+n+1518500249+a[s+3]<<0)<<5|n>>>27)+(i&(r=r<<30|r>>>2)|~i&o)+t+1518500249+a[s+4]<<0,i=i<<30|i>>>2;for(;s<40;s+=5)t=(e=(n=(e=(i=(e=(r=(e=(o=(e=t<<5|t>>>27)+(n^i^r)+o+1859775393+a[s]<<0)<<5|o>>>27)+(t^(n=n<<30|n>>>2)^i)+r+1859775393+a[s+1]<<0)<<5|r>>>27)+(o^(t=t<<30|t>>>2)^n)+i+1859775393+a[s+2]<<0)<<5|i>>>27)+(r^(o=o<<30|o>>>2)^t)+n+1859775393+a[s+3]<<0)<<5|n>>>27)+(i^(r=r<<30|r>>>2)^o)+t+1859775393+a[s+4]<<0,i=i<<30|i>>>2;for(;s<60;s+=5)t=(e=(n=(e=(i=(e=(r=(e=(o=(e=t<<5|t>>>27)+(n&i|n&r|i&r)+o-1894007588+a[s]<<0)<<5|o>>>27)+(t&(n=n<<30|n>>>2)|t&i|n&i)+r-1894007588+a[s+1]<<0)<<5|r>>>27)+(o&(t=t<<30|t>>>2)|o&n|t&n)+i-1894007588+a[s+2]<<0)<<5|i>>>27)+(r&(o=o<<30|o>>>2)|r&t|o&t)+n-1894007588+a[s+3]<<0)<<5|n>>>27)+(i&(r=r<<30|r>>>2)|i&o|r&o)+t-1894007588+a[s+4]<<0,i=i<<30|i>>>2;for(;s<80;s+=5)t=(e=(n=(e=(i=(e=(r=(e=(o=(e=t<<5|t>>>27)+(n^i^r)+o-899497514+a[s]<<0)<<5|o>>>27)+(t^(n=n<<30|n>>>2)^i)+r-899497514+a[s+1]<<0)<<5|r>>>27)+(o^(t=t<<30|t>>>2)^n)+i-899497514+a[s+2]<<0)<<5|i>>>27)+(r^(o=o<<30|o>>>2)^t)+n-899497514+a[s+3]<<0)<<5|n>>>27)+(i^(r=r<<30|r>>>2)^o)+t-899497514+a[s+4]<<0,i=i<<30|i>>>2;this.h0=this.h0+t<<0,this.h1=this.h1+n<<0,this.h2=this.h2+i<<0,this.h3=this.h3+r<<0,this.h4=this.h4+o<<0},Y.prototype.toString=Y.prototype.hex=function(){this.finalize();var e=this.h0,t=this.h1,n=this.h2,i=this.h3,r=this.h4;return o[e>>28&15]+o[e>>24&15]+o[e>>20&15]+o[e>>16&15]+o[e>>12&15]+o[e>>8&15]+o[e>>4&15]+o[15&e]+o[t>>28&15]+o[t>>24&15]+o[t>>20&15]+o[t>>16&15]+o[t>>12&15]+o[t>>8&15]+o[t>>4&15]+o[15&t]+o[n>>28&15]+o[n>>24&15]+o[n>>20&15]+o[n>>16&15]+o[n>>12&15]+o[n>>8&15]+o[n>>4&15]+o[15&n]+o[i>>28&15]+o[i>>24&15]+o[i>>20&15]+o[i>>16&15]+o[i>>12&15]+o[i>>8&15]+o[i>>4&15]+o[15&i]+o[r>>28&15]+o[r>>24&15]+o[r>>20&15]+o[r>>16&15]+o[r>>12&15]+o[r>>8&15]+o[r>>4&15]+o[15&r]},Y.prototype.array=Y.prototype.digest=function(){this.finalize();var e=this.h0,t=this.h1,n=this.h2,i=this.h3,r=this.h4;return[e>>24&255,e>>16&255,e>>8&255,255&e,t>>24&255,t>>16&255,t>>8&255,255&t,n>>24&255,n>>16&255,n>>8&255,255&n,i>>24&255,i>>16&255,i>>8&255,255&i,r>>24&255,r>>16&255,r>>8&255,255&r]},Y.prototype.arrayBuffer=function(){this.finalize();var e=new ArrayBuffer(20),t=new DataView(e);return t.setUint32(0,this.h0),t.setUint32(4,this.h1),t.setUint32(8,this.h2),t.setUint32(12,this.h3),t.setUint32(16,this.h4),e},Ue=function(){var t=V("hex");t.create=function(){return new Y},t.update=function(e){return t.create().update(e)};for(var e=0;ee,createHTML:e=>e};function Q(e){return t||(t=e.trustedTypesPolicy||(D.trustedTypes&&"function"==typeof D.trustedTypes.createPolicy?D.trustedTypes.createPolicy("pendo",q0):q0),e.trustedTypesPolicy=t),t}var I,ee="stagingServerHashes",te={};function ne(e){return e.loadAsModule}function ie(e){return"staging"===e.environmentName}function re(e){return"extension"===e.installType}function oe(t=[],n){var i=/^https:\/\/[\w\-.]*cdn[\w\-.]*\.(pendo-dev\.com|pendo\.io)\/agent\/static\/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12}|PENDO_API_KEY)\/pendo\.js$/g;for(let e=0;e":">",'"':""","'":"'","`":"`"},Ge=De(t),t=De(Ee(t)),Ue=y.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g},Be=/(.)^/,He={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},ze=/\\|'|\r|\n|\u2028|\u2029/g;function je(e){return"\\"+He[e]}var We=/^\s*(\w|\$)+\s*$/;var Je=0;function qe(e,t,n,i,r){return i instanceof t?(i=Te(e.prototype),o(t=e.apply(i,r))?t:i):e.apply(n,r)}var _=c(function(r,o){var a=_.placeholder,s=function(){for(var e=0,t=o.length,n=Array(t),i=0;iu(h,"name"),sources:{SNIPPET_SRC:d,PENDO_CONFIG_SRC:c,GLOBAL_SRC:l,DEFAULT_SRC:p},validate(t){t.groupCollapsed("Validate Config options"),r(S(),function(e){t.log(String(e.active)),0=e},isMobileUserAgent:x.memoize(function(){return/Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(Oe())}),isChromeExtension:a},je=function(){return!isNaN(_e)&&11!=_e&&"CSS1Compat"!==G.compatMode},We=function(e,t){var n,i=e.height,r=e.width;return"top"==e.arrowPosition||"bottom"==e.arrowPosition?(n=0,"top"==e.arrowPosition?(e.top=t.top+t.height,n=-1,e.arrow.top=3,_e<=9&&(e.arrow.top=6)):"bottom"==e.arrowPosition&&(e.top=t.top-(i+I.TOOLTIP_ARROW_SIZE),e.arrow.top=i-I.TOOLTIP_ARROW_SIZE,10==_e?e.arrow.top--:_e<=9&&(e.arrow.top+=4),n=1),"left"==e.arrow.hbias?(e.left=t.left+t.width/2-(10+2*I.TOOLTIP_ARROW_SIZE),e.arrow.left=10+I.TOOLTIP_ARROW_SIZE):"right"==e.arrow.hbias?(e.left=t.left-r+t.width/2+(10+2*I.TOOLTIP_ARROW_SIZE),e.arrow.left=r-3*I.TOOLTIP_ARROW_SIZE-10):(e.left=t.left+t.width/2-r/2,e.arrow.left=r/2-I.TOOLTIP_ARROW_SIZE),e.arrow.border.top=e.arrow.top+n,e.arrow.border.left=e.arrow.left):("left"==e.arrow.hbias?(e.left=t.left+t.width,e.arrow.left=1,e.arrow.left+=5,e.arrow.border.left=e.arrow.left-1):"right"==e.arrow.hbias&&(e.left=Math.max(0,t.left-r-I.TOOLTIP_ARROW_SIZE),e.arrow.left=r-I.TOOLTIP_ARROW_SIZE-1,e.arrow.left+=5,e.arrow.border.left=e.arrow.left+1),e.top=t.top+t.height/2-i/2,e.arrow.top=i/2-I.TOOLTIP_ARROW_SIZE,e.arrow.border.top=e.arrow.top),e},Je="prod",qe="https://app.pendo.io",Ke="cdn.pendo.io",Ve="agent/releases/2.285.2",Ue="https://app.pendo.io",$e="2.285.2_prod",Be="2.285.2",Ze="xhr",Ye=function(){return je()?$e+"+quirksmode":$e};function Xe(){return-1!==Je.indexOf("prod")}var Qe=/^\s+|\s+$/g;function et(e){for(var t=[],n=0;n>6,128|63&i):i<55296||57344<=i?t.push(224|i>>12,128|i>>6&63,128|63&i):(n++,i=65536+((1023&i)<<10|1023&e.charCodeAt(n)),t.push(240|i>>18,128|i>>12&63,128|i>>6&63,128|63&i))}return t}var tt=(tt=String.prototype.trim)||function(){return this.replace(Qe,"")},e={exports:{}},nt=(!function(){var X=void 0,Q=!0,o=this;function e(e,t){var n,i=e.split("."),r=o;i[0]in r||!r.execScript||r.execScript("var "+i[0]);for(;i.length&&(n=i.shift());)i.length||t===X?r=r[n]||(r[n]={}):r[n]=t}var ee="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array;function te(e,t){if(this.index="number"==typeof t?t:0,this.e=0,this.buffer=e instanceof(ee?Uint8Array:Array)?e:new(ee?Uint8Array:Array)(32768),2*this.buffer.length<=this.index)throw Error("invalid index");this.buffer.length<=this.index&&u(this)}function u(e){var t,n=e.buffer,i=n.length,r=new(ee?Uint8Array:Array)(i<<1);if(ee)r.set(n);else for(t=0;t>>8&255]<<16|d[e>>>16&255]<<8|d[e>>>24&255])>>32-t:d[e]>>8-t),t+a<8)s=s<>t-i-1&1,8==++a&&(a=0,r[o++]=d[s],s=0,o===r.length)&&(r=u(this));r[o]=s,this.buffer=r,this.e=a,this.index=o},te.prototype.finish=function(){var e=this.buffer,t=this.index;return 0>>1;a;a>>>=1)i=i<<1|1&a,--r;t[n]=(i<>>0}var d=t;function c(e){this.buffer=new(ee?Uint16Array:Array)(2*e),this.length=0}function s(e,t){this.d=ne,this.i=0,this.input=ee&&e instanceof Array?new Uint8Array(e):e,this.c=0,t&&(t.lazy&&(this.i=t.lazy),"number"==typeof t.compressionType&&(this.d=t.compressionType),t.outputBuffer&&(this.a=ee&&t.outputBuffer instanceof Array?new Uint8Array(t.outputBuffer):t.outputBuffer),"number"==typeof t.outputIndex)&&(this.c=t.outputIndex),this.a||(this.a=new(ee?Uint8Array:Array)(32768))}c.prototype.getParent=function(e){return 2*((e-2)/4|0)},c.prototype.push=function(e,t){var n,i,r=this.buffer,o=this.length;for(r[this.length++]=t,r[this.length++]=e;0r[n]);)i=r[o],r[o]=r[n],r[n]=i,i=r[o+1],r[o+1]=r[n+1],r[n+1]=i,o=n;return this.length},c.prototype.pop=function(){var e,t,n,i=this.buffer,r=i[0],o=i[1];for(this.length-=2,i[0]=i[this.length],i[1]=i[this.length+1],n=0;!((t=2*n+2)>=this.length)&&(t+2i[t]&&(t+=2),i[t]>i[n]);)e=i[n],i[n]=i[t],i[t]=e,e=i[n+1],i[n+1]=i[t+1],i[t+1]=e,n=t;return{index:o,value:r,length:this.length}};for(var ne=2,l={NONE:0,h:1,g:ne,n:3},ie=[],p=0;p<288;p++)switch(Q){case p<=143:ie.push([p+48,8]);break;case p<=255:ie.push([p-144+400,9]);break;case p<=279:ie.push([p-256,7]);break;case p<=287:ie.push([p-280+192,8]);break;default:throw"invalid literal: "+p}function y(e,t){this.length=e,this.k=t}s.prototype.f=function(){var e,t,F,n=this.input;switch(this.d){case 0:for(t=0,F=n.length;t>>8&255,a[s++]=255&D,a[s++]=D>>>8&255,ee)a.set(i,s),s+=i.length,a=a.subarray(0,s);else{for(o=0,G=i.length;o>16&255,a[s++]=u>>24,Q){case 1===o:n=[0,o-1,0];break;case 2===o:n=[1,o-2,0];break;case 3===o:n=[2,o-3,0];break;case 4===o:n=[3,o-4,0];break;case o<=6:n=[4,o-5,1];break;case o<=8:n=[5,o-7,1];break;case o<=12:n=[6,o-9,2];break;case o<=16:n=[7,o-13,2];break;case o<=24:n=[8,o-17,3];break;case o<=32:n=[9,o-25,3];break;case o<=48:n=[10,o-33,4];break;case o<=64:n=[11,o-49,4];break;case o<=96:n=[12,o-65,5];break;case o<=128:n=[13,o-97,5];break;case o<=192:n=[14,o-129,6];break;case o<=256:n=[15,o-193,6];break;case o<=384:n=[16,o-257,7];break;case o<=512:n=[17,o-385,7];break;case o<=768:n=[18,o-513,8];break;case o<=1024:n=[19,o-769,8];break;case o<=1536:n=[20,o-1025,9];break;case o<=2048:n=[21,o-1537,9];break;case o<=3072:n=[22,o-2049,10];break;case o<=4096:n=[23,o-3073,10];break;case o<=6144:n=[24,o-4097,11];break;case o<=8192:n=[25,o-6145,11];break;case o<=12288:n=[26,o-8193,12];break;case o<=16384:n=[27,o-12289,12];break;case o<=24576:n=[28,o-16385,13];break;case o<=32768:n=[29,o-24577,13];break;default:throw"invalid distance"}for(u=n,a[s++]=u[0],a[+s]=u[1],a[5]=u[2],i=0,r=a.length;i2*u[r-1]+d[r]&&(u[r]=2*u[r-1]+d[r]),l[r]=Array(u[r]),p[r]=Array(u[r]);for(i=0;ie[i]?(l[r][o]=a,p[r][o]=n,s+=2):(l[r][o]=e[i],p[r][o]=i,++i);h[r]=0,1===d[r]&&function m(e){var t=p[e][h[e]];t===n?(m(e+1),m(e+1)):--c[t],++h[e]}(r)}return c}(i,i.length,t),o=0,a=n.length;o>>=1;return i}function f(e,t){this.input=e,this.a=new(ee?Uint8Array:Array)(32768),this.d=S.g;var n,i={};for(n in(t?"number"==typeof t.compressionType:(t={},0))&&(this.d=t.compressionType),t)i[n]=t[n];i.outputBuffer=this.a,this.j=new s(this.input,i)}var g,m,v,b,S=l,E=(f.prototype.f=function(){var e,t,n=0,i=this.a,r=Math.LOG2E*Math.log(32768)-8<<4|8;switch(i[n++]=r,8,this.d){case S.NONE:t=0;break;case S.h:t=1;break;case S.g:t=2;break;default:throw Error("unsupported compression type")}i[+n]=(e=t<<6|0)|31-(256*r+e)%31;var o=this.input;if("string"==typeof o){for(var a=o.split(""),s=0,u=a.length;s>>0;o=a}for(var d,c=1,l=0,p=o.length,h=0;0>>0,this.j.c=2,n=(i=this.j.f()).length,ee&&((i=new Uint8Array(i.buffer)).length<=n+4&&(this.a=new Uint8Array(i.length+4),this.a.set(i),i=this.a),i=i.subarray(0,n+4)),i[n++]=r>>24&255,i[n++]=r>>16&255,i[n++]=r>>8&255,i[+n]=255&r,i},e("Zlib.Deflate",f),e("Zlib.Deflate.compress",function(e,t){return new f(e,t).f()}),e("Zlib.Deflate.prototype.compress",f.prototype.f),{NONE:S.NONE,FIXED:S.h,DYNAMIC:S.g});if(Object.keys)g=Object.keys(E);else for(m in g=[],v=0,E)g[v++]=m;for(v=0,b=g.length;v>>8^r[255&(t^e[n])];for(o=i>>3;o--;n+=8)t=(t=(t=(t=(t=(t=(t=(t=t>>>8^r[255&(t^e[n])])>>>8^r[255&(t^e[n+1])])>>>8^r[255&(t^e[n+2])])>>>8^r[255&(t^e[n+3])])>>>8^r[255&(t^e[n+4])])>>>8^r[255&(t^e[n+5])])>>>8^r[255&(t^e[n+6])])>>>8^r[255&(t^e[n+7])];return(4294967295^t)>>>0},d:function(e,t){return(a.a[255&(e^t)]^e>>>8)>>>0},b:[0,1996959894,3993919788,2567524794,124634137,1886057615,3915621685,2657392035,249268274,2044508324,3772115230,2547177864,162941995,2125561021,3887607047,2428444049,498536548,1789927666,4089016648,2227061214,450548861,1843258603,4107580753,2211677639,325883990,1684777152,4251122042,2321926636,335633487,1661365465,4195302755,2366115317,997073096,1281953886,3579855332,2724688242,1006888145,1258607687,3524101629,2768942443,901097722,1119000684,3686517206,2898065728,853044451,1172266101,3705015759,2882616665,651767980,1373503546,3369554304,3218104598,565507253,1454621731,3485111705,3099436303,671266974,1594198024,3322730930,2970347812,795835527,1483230225,3244367275,3060149565,1994146192,31158534,2563907772,4023717930,1907459465,112637215,2680153253,3904427059,2013776290,251722036,2517215374,3775830040,2137656763,141376813,2439277719,3865271297,1802195444,476864866,2238001368,4066508878,1812370925,453092731,2181625025,4111451223,1706088902,314042704,2344532202,4240017532,1658658271,366619977,2362670323,4224994405,1303535960,984961486,2747007092,3569037538,1256170817,1037604311,2765210733,3554079995,1131014506,879679996,2909243462,3663771856,1141124467,855842277,2852801631,3708648649,1342533948,654459306,3188396048,3373015174,1466479909,544179635,3110523913,3462522015,1591671054,702138776,2966460450,3352799412,1504918807,783551873,3082640443,3233442989,3988292384,2596254646,62317068,1957810842,3939845945,2647816111,81470997,1943803523,3814918930,2489596804,225274430,2053790376,3826175755,2466906013,167816743,2097651377,4027552580,2265490386,503444072,1762050814,4150417245,2154129355,426522225,1852507879,4275313526,2312317920,282753626,1742555852,4189708143,2394877945,397917763,1622183637,3604390888,2714866558,953729732,1340076626,3518719985,2797360999,1068828381,1219638859,3624741850,2936675148,906185462,1090812512,3747672003,2825379669,829329135,1181335161,3412177804,3160834842,628085408,1382605366,3423369109,3138078467,570562233,1426400815,3317316542,2998733608,733239954,1555261956,3268935591,3050360625,752459403,1541320221,2607071920,3965973030,1969922972,40735498,2617837225,3943577151,1913087877,83908371,2512341634,3803740692,2075208622,213261112,2463272603,3855990285,2094854071,198958881,2262029012,4057260610,1759359992,534414190,2176718541,4139329115,1873836001,414664567,2282248934,4279200368,1711684554,285281116,2405801727,4167216745,1634467795,376229701,2685067896,3608007406,1308918612,956543938,2808555105,3495958263,1231636301,1047427035,2932959818,3654703836,1088359270,936918e3,2847714899,3736837829,1202900863,817233897,3183342108,3401237130,1404277552,615818150,3134207493,3453421203,1423857449,601450431,3009837614,3294710456,1567103746,711928724,3020668471,3272380065,1510334235,755167117]};a.a="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array?new Uint32Array(a.b):a.b,e("Zlib.CRC32",a),e("Zlib.CRC32.calc",a.c),e("Zlib.CRC32.update",a.update)}.call(a.exports);var ot={CRC32:Z(a.exports).Zlib.CRC32},at=function(e,n){var i;return 200<=(n=n||0)?e:x.isArray(e)?x.map(e,function(e){return at(e,n+1)}):!x.isObject(e)||x.isDate(e)||x.isRegExp(e)||x.isElement(e)?x.isString(e)?x.escape(e):e:(i={},x.each(e,function(e,t){i[t]=at(e,n+1)}),i)},st=function(e){e=et(e);return $.uint8ToBase64(e)},ut=function(e){if(void 0!==e)return e=et(e=x.isString(e)?e:JSON.stringify(e)),ot.CRC32.calc(e,0,e.length)};function dt(e){return e[Math.floor(Math.random()*e.length)]}function ct(e){for(var t="abcdefghijklmnopqrstuvwxyz",n="",i=(t+t.toUpperCase()+"1234567890").split(""),r=0;rt+"="+e)),e}hashCode(){return this.toString()}}(a=yt=yt||{}).Debug="debug",a.Info="info",a.Warn="warn",a.Error="error",a.Critical="critical";class V0 extends class{constructor(){this.listeners={}}addEventListener(e,t){let n=this.listeners[e];n||(n=[],this.listeners[e]=n),x.findIndex(n,e=>t===e)<0&&n.push(t)}removeEventListener(e,t){var n,i=this.listeners[e];i&&0<=(n=x.findIndex(i,e=>t===e))&&(i.splice(n,1),i.length||delete this.listeners[e])}dispatchEvent(t){var e=this.listeners[t.type];e&&x.each(e,e=>{e(t)})}}{write(e,t,n){this.dispatchEvent(new K0(t,e,n))}writeError(e,t,n){let i,r;x.isString(e)?(i=e,r={message:i}):(r=e,i=r.message),n&&n.error&&(r=n.error,delete n.error);e=new K0(t,i,n);e.error=r,this.dispatchEvent(e)}debug(e,t){this.write(e,yt.Debug,t)}info(e,t){this.write(e,yt.Info,t)}warn(e,t){this.writeError(e,yt.Warn,t)}error(e,t){this.writeError(e,yt.Error,t)}critical(e,t){this.writeError(e,yt.Critical,t)}}const B=new V0;function Et(e){if(e)return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}const $0=function(){var e=f.get("storage.allowKeys")||"*";return x.isArray(e)?x.indexBy(e):e};function It(t,n,i){return function(){try{return n.apply(t,arguments)}catch(e){return i}}}function xt(n){x.forEach(x.keys(n),function(e){try{/^_?pendo_/.test(e)&&n.removeItem(e)}catch(t){}})}function Ct(e){var t=x.noop,t={getItem:()=>null,setItem:t,removeItem:t,clearPendo:t};try{var n=e();return n?{getItem:It(n,n.getItem,null),setItem:It(n,n.setItem),removeItem:It(n,n.removeItem),clearPendo:x.partial(xt,n)}:t}catch(i){return t}}var _t,Tt=Ct(function(){return D.localStorage}),At=Ct(function(){return D.sessionStorage}),Rt={},Ot=!0,kt=function(){return f.get("localStorageOnly")},Lt=function(){return!!f.get("disableCookies")};function Nt(e){Ot=e}var Mt=function(e){var t=Lt()||kt()?Rt[e]:G.cookie;return(e=new RegExp("(^|; )"+e+"=([^;]*)").exec(t))?wt(e[2]):null},Pt=function(e,t,n,i){var r,o;!Ot||f.get("preventCookieRefresh")&&Mt(e)===t||(o=X0(n),(r=new Date).setTime(r.getTime()+o),o=e+"="+St(t)+(n?";expires="+r.toUTCString():"")+"; path=/"+("https:"===G.location.protocol||i?";secure":"")+"; SameSite=Strict",_t&&(o+=";domain="+_t),Lt()||kt()?Rt[e]=o:G.cookie=o)};function Ft(e){_t?Pt(e,""):G.cookie=e+"=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}var Dt=function(e,t){return`_pendo_${e}.`+(t||I.apiKey)},Bt=function(e,t){return Mt(Dt(e,t))};const Z0=864e5,Y0=100*Z0,X0=(e=Y0)=>{var t=f.get("maxCookieTTLDays"),t=tn,getSession:()=>t}}(),qt=(D.Promise,e=function(){var t=Gt;function d(e){return Boolean(e&&"undefined"!=typeof e.length)}function i(){}function a(e){if(!(this instanceof a))throw new TypeError("Promises must be constructed via new");if("function"!=typeof e)throw new TypeError("not a function");this._state=0,this._handled=!1,this._value=E,this._deferreds=[],l(e,this)}function r(i,r){for(;3===i._state;)i=i._value;0===i._state?i._deferreds.push(r):(i._handled=!0,a._immediateFn(function(){var e,t=1===i._state?r.onFulfilled:r.onRejected;if(null===t)(1===i._state?o:s)(r.promise,i._value);else{try{e=t(i._value)}catch(n){return void s(r.promise,n)}o(r.promise,e)}}))}function o(e,t){try{if(t===e)throw new TypeError("A promise cannot be resolved with itself.");if(t&&("object"==typeof t||"function"==typeof t)){var n=t.then;if(t instanceof a)return e._state=3,e._value=t,void u(e);if("function"==typeof n)return void l((i=n,r=t,function(){i.apply(r,arguments)}),e)}e._state=1,e._value=t,u(e)}catch(o){s(e,o)}var i,r}function s(e,t){e._state=2,e._value=t,u(e)}function u(e){2===e._state&&0===e._deferreds.length&&a._immediateFn(function(){e._handled||a._unhandledRejectionFn(e._value)});for(var t=0,n=e._deferreds.length;t+~]|"+c+")"+c+"*"),Ri=new RegExp(c+"|>"),Oi=new RegExp(xi),ki=new RegExp("^"+e+"$"),Li={ID:new RegExp("^#("+e+")"),CLASS:new RegExp("^\\.("+e+")"),TAG:new RegExp("^("+e+"|[*])"),ATTR:new RegExp("^"+Yv),PSEUDO:new RegExp("^"+xi),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+c+"*(even|odd|(([+-]|)(\\d*)n|)"+c+"*(?:([+-]|)"+c+"*(\\d+)|))"+c+"*\\)|)","i"),bool:new RegExp("^(?:"+Ii+")$","i"),needsContext:new RegExp("^"+c+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+c+"*((?:-\\d)?\\d*)"+c+"*\\)|)(?=[^-]|$)","i")},Ni=/HTML$/i,Mi=/^(?:input|select|textarea|button)$/i,Pi=/^h\d$/i,Fi=/^[^{]+\{\s*\[native \w/,Di=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,Gi=/[+~]/,Ui=new RegExp("\\\\[\\da-fA-F]{1,6}"+c+"?|\\\\([^\\r\\n\\f])","g"),Bi=function(e,t){e="0x"+e.slice(1)-65536;return t||(e<0?String.fromCharCode(65536+e):String.fromCharCode(e>>10|55296,1023&e|56320))},Hi=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,zi=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},ji=function(){ei()},Wi=tr(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{wi.apply(Kv=Si.call(di.childNodes),di.childNodes),Kv[di.childNodes.length].nodeType}catch(W0){wi={apply:Kv.length?function(e,t){yi.apply(e,Si.call(t))}:function(e,t){for(var n=e.length,i=0;e[n++]=t[i++];);e.length=n-1}}}function T(e,t,n,i){var r,o,a,s,u,d,c=t&&t.ownerDocument,l=t?t.nodeType:9;if(n=n||[],"string"!=typeof e||!e||1!==l&&9!==l&&11!==l)return n;if(!i&&(ei(t),t=t||_,ni)){if(11!==l&&(s=Di.exec(e)))if(r=s[1]){if(9===l){if(!(d=t.getElementById(r)))return n;if(d.id===r)return n.push(d),n}else if(c&&(d=c.getElementById(r))&&ai(t,d)&&d.id===r)return n.push(d),n}else{if(s[2])return wi.apply(n,t.getElementsByTagName(e)),n;if((r=s[3])&&S.getElementsByClassName&&t.getElementsByClassName)return wi.apply(n,t.getElementsByClassName(r)),n}if(S.qsa&&!gi[e+" "]&&(!ii||!ii.test(e))&&(1!==l||"object"!==t.nodeName.toLowerCase())){if(d=e,c=t,1===l&&(Ri.test(e)||Ai.test(e))){for((c=Gi.test(e)&&Xi(t.parentNode)||t)===t&&S.scope||((a=t.getAttribute("id"))?a=a.replace(Hi,zi):t.setAttribute("id",a=ui)),o=(u=Vn(e)).length;o--;)u[o]=(a?"#"+a:":scope")+" "+er(u[o]);d=u.join(",")}try{return wi.apply(n,c.querySelectorAll(d)),n}catch(p){gi(e,!0)}finally{a===ui&&t.removeAttribute("id")}}}return Zn(e.replace(_i,"$1"),t,n,i)}function Ji(){var n=[];function i(e,t){return n.push(e+" ")>C.cacheLength&&delete i[n.shift()],i[e+" "]=t}return i}function qi(e){return e[ui]=!0,e}function Ki(e){var t=_.createElement("fieldset");try{return!!e(t)}catch(W0){return!1}finally{t.parentNode&&t.parentNode.removeChild(t)}}function Vi(e,t){for(var n=e.split("|"),i=n.length;i--;)C.attrHandle[n[i]]=t}function $i(e,t){var n=t&&e,i=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(i)return i;if(n)for(;n=n.nextSibling;)if(n===t)return-1;return e?1:-1}function Zi(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&Wi(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function Yi(a){return qi(function(o){return o=+o,qi(function(e,t){for(var n,i=a([],e.length,o),r=i.length;r--;)e[n=i[r]]&&(e[n]=!(t[n]=e[n]))})})}function Xi(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(Jn in S=T.support={},Kn=T.isXML=function(e){var t=e.namespaceURI,e=(e.ownerDocument||e).documentElement;return!Ni.test(t||e&&e.nodeName||"HTML")},ei=T.setDocument=function(e){var e=e?e.ownerDocument||e:di;return e!=_&&9===e.nodeType&&e.documentElement&&(ti=(_=e).documentElement,ni=!Kn(_),di!=_&&(e=_.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",ji,!1):e.attachEvent&&e.attachEvent("onunload",ji)),S.scope=Ki(function(e){return ti.appendChild(e).appendChild(_.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),S.attributes=Ki(function(e){return e.className="i",!e.getAttribute("className")}),S.getElementsByTagName=Ki(function(e){return e.appendChild(_.createComment("")),!e.getElementsByTagName("*").length}),S.getElementsByClassName=!!_.getElementsByClassName,S.getById=Ki(function(e){return ti.appendChild(e).id=ui,!_.getElementsByName||!_.getElementsByName(ui).length}),S.getById?(C.filter.ID=function(e){var t=e.replace(Ui,Bi);return function(e){return e.getAttribute("id")===t}},C.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&ni)return(e=t.getElementById(e))?[e]:[]}):(C.filter.ID=function(e){var t=e.replace(Ui,Bi);return function(e){e="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return e&&e.value===t}},C.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&ni){var n,i,r,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];for(r=t.getElementsByName(e),i=0;o=r[i++];)if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),C.find.TAG=S.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):S.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,i=[],r=0,o=t.getElementsByTagName(e);if("*"!==e)return o;for(;n=o[r++];)1===n.nodeType&&i.push(n);return i},C.find.CLASS=S.getElementsByClassName&&function(e,t){return"undefined"!=typeof t.getElementsByClassName&&ni?t.getElementsByClassName(e):S.qsa&&ni?t.querySelectorAll("."+e):void 0},ri=[],ii=[],(S.qsa=!!_.querySelectorAll)&&(Ki(function(e){var t;ti.appendChild(e).innerHTML=Q().createHTML(""),e.querySelectorAll("[msallowcapture^='']").length&&ii.push("[*^$]="+c+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||ii.push("\\["+c+"*(?:value|"+Ii+")"),e.querySelectorAll("[id~="+ui+"-]").length||ii.push("~="),(t=_.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||ii.push("\\["+c+"*name"+c+"*="+c+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||ii.push(":checked"),e.querySelectorAll("a#"+ui+"+*").length||ii.push(".#.+[+~]"),e.querySelectorAll("\\\f"),ii.push("[\\r\\n\\f]")}),Ki(function(e){e.innerHTML=Q().createHTML("");var t=_.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&ii.push("name"+c+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&ii.push(":enabled",":disabled"),ti.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&ii.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),ii.push(",.*:")})),(S.matchesSelector=Fi.test(oi=ti.matches||ti.webkitMatchesSelector||ti.mozMatchesSelector||ti.oMatchesSelector||ti.msMatchesSelector))&&Ki(function(e){S.disconnectedMatch=oi.call(e,"*"),oi.call(e,"[s!='']:x"),ri.push("!=",xi)}),ii=ii.length&&new RegExp(ii.join("|")),ri=ri.length&&new RegExp(ri.join("|")),e=!!ti.compareDocumentPosition,ai=e||ti.contains?function(e,t){var n=9===e.nodeType?e.documentElement:e,t=t&&t.parentNode;return e===t||!(!t||1!==t.nodeType||!(n.contains?n.contains(t):e.compareDocumentPosition&&16&e.compareDocumentPosition(t)))}:function(e,t){if(t)for(;t=t.parentNode;)if(t===e)return!0;return!1},mi=e?function(e,t){var n;return e===t?(Qn=!0,0):(n=!e.compareDocumentPosition-!t.compareDocumentPosition)||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!S.sortDetached&&t.compareDocumentPosition(e)===n?e==_||e.ownerDocument==di&&ai(di,e)?-1:t==_||t.ownerDocument==di&&ai(di,t)?1:Xn?Ei(Xn,e)-Ei(Xn,t):0:4&n?-1:1)}:function(e,t){if(e===t)return Qn=!0,0;var n,i=0,r=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!r||!o)return e==_?-1:t==_?1:r?-1:o?1:Xn?Ei(Xn,e)-Ei(Xn,t):0;if(r===o)return $i(e,t);for(n=e;n=n.parentNode;)a.unshift(n);for(n=t;n=n.parentNode;)s.unshift(n);for(;a[i]===s[i];)i++;return i?$i(a[i],s[i]):a[i]==di?-1:s[i]==di?1:0}),_},T.matches=function(e,t){return T(e,null,null,t)},T.matchesSelector=function(e,t){if(ei(e),S.matchesSelector&&ni&&!gi[t+" "]&&(!ri||!ri.test(t))&&(!ii||!ii.test(t)))try{var n=oi.call(e,t);if(n||S.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(W0){gi(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(Ui,Bi),e[3]=(e[3]||e[4]||e[5]||"").replace(Ui,Bi),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||T.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&T.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return Li.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&Oi.test(n)&&(t=(t=Vn(n,!0))&&n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(Ui,Bi).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=pi[e+" "];return t||(t=new RegExp("(^|"+c+")"+e+"("+c+"|$)"))&&pi(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(t,n,i){return function(e){e=T.attr(e,t);return null==e?"!="===n:!n||(e+="","="===n?e===i:"!="===n?e!==i:"^="===n?i&&0===e.indexOf(i):"*="===n?i&&-1"),"#"===e.firstChild.getAttribute("href")})||Vi("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),S.attributes&&Ki(function(e){return e.innerHTML=Q().createHTML(""),e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||Vi("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),Ki(function(e){return null==e.getAttribute("disabled")})||Vi(Ii,function(e,t,n){if(!n)return!0===e[t]?t.toLowerCase():(n=e.getAttributeNode(t))&&n.specified?n.value:null});var or=si.Sizzle;T.noConflict=function(){return si.Sizzle===T&&(si.Sizzle=or),T},d.exports?d.exports=T:si.Sizzle=T,ar=Z(a.exports),(sr=x.extend(function(){return sr.Sizzle.apply(this,arguments)},ar)).reset=function(){sr.Sizzle=ar,sr.matchesSelector=ar.matchesSelector,sr.matches=ar.matches},sr.intercept=function(e,t="Sizzle"){sr[t]=x.wrap(sr[t],e)},sr.reset();var ar,sr,ur=sr;function A(e,t){var n,i=this;if(e&&e instanceof A)return e;if(!(i instanceof A))return new A(e,t);if(e)if(e.nodeType)n=[e];else if(r=/^<(\w+)\/?>$/.exec(e))n=[G.createElement(r[1])];else if(/^<[\w\W]+>$/.test(e)){var r=G.createElement("div");r.innerHTML=e,n=x.toArray(r.childNodes)}else if(x.isString(e)){t instanceof A&&(t=0{fr(e,t,n,i)}):x.noop}function fr(e,t,n,i){e&&t&&n&&(i=i||!1,e.removeEventListener?ht("removeEventListener",e).call(e,t,n,i):e.detachEvent&&e.detachEvent("on"+t,n))}var gr=function(e){var t=zn.getComposedPath(e);return t&&0{A.event.remove(t,e.type,e.handler,e.capture)}},dispatch(r,o){var e,t,a;r&&(e=(cr.get(r,"captureEvents")||{})[o.type]||[],t=(cr.get(r,"bubbleEvents")||{})[o.type]||[],(t=e.concat(t)).length)&&!(a=cr.get(o)).ignore&&(a.handled=a.handled||{},x.each(t.slice(),function(e){var t=!!e.capture===pr(o),n=(n=o,x.isNumber(n.eventPhase)&&2===n.eventPhase),t=!t&&!n;if(!(gr(o)!==r&&t||a.handled[e.id])){a.handled[e.id]=!0;try{(!be(e.selector)||0{clearTimeout(Sr)}}var kr=function(e){var t;try{t=_r()}catch(n){}return t},Lr=[],Nr=!1,Mr=null;function Pr(){var t=xr().href;Mr!=t&&(Mr=t,x.map(Lr,function(e){e(t)}))}var Fr="queryStringWhitelist";function Dr(e){var t=f.get("sanitizeUrl");return x.isFunction(t)?t(e):e}function Gr(e){e=e||xr().href;var t=f.get("annotateUrl");if(t)if(x.isFunction(t))try{var n,i,r,o=t();return o&&(x.isObject(o)||x.isArray(o))?(n=o.exclude,i=o.include,r=o.fragment,delete o.fragment,(n&&x.isArray(n)||i&&(x.isArray(i)||x.isObject(i)))&&(n&&(e=Br(e,null,n,!0)),o=i||{}),vr.urlFor(e,o,r)):e}catch(a){B.error("customer-provided `annotateUrl` function threw an exception",{error:a})}else B.error("customer-provided `annotateUrl` must be of type: function");return e}function Ur(e){var t,n;return!e||(t=e.indexOf("?"))<0?"":(n=e.indexOf("#"))<0?e.substring(t):n{i[e]=t})}),n.push(hr(D,"popstate",Pr))),ze.supportsHashChange()&&n.push(hr(D,"hashchange",Pr)),!1!==t&&ze.supportsHashChange()||n.push(Or()),Nr=!0),Lr.push(e),()=>{x.each(n,function(e){e()}),Lr.length=0,Nr=!1}},get:kr,externalizeURL:function(e,t,n){n=n||f.get(Fr);return Dr(Br(e,t,n=x.isFunction(n)?n():n,!1))},startPoller:Or,getWindowLocation:xr,clear:function(){Lr=[]},isElectron:Er,electronUserDirectory:function(){return D.process.env.PWD||""},electronAppName:function(){return D.process.env.npm_package_name||""},electronUserHomeDirectory:function(){return D.process.env.HOME||""},electronResourcesPath:function(){return D.process.resourcesPath||""}};function Jr(){var e=f.getLocalConfig("dataHost");return e||((e=f.getHostedConfig("dataHost"))?-1===e.indexOf("://")?"https://"+e:e:qe)}function qr(){Hr=Jr()}function Kr(){var e=f.get("contentHost")||f.get("assetHost")||Ke;return e=e&&-1===e.indexOf("://")?"https://"+e:e}function Vr(e){var t=Kr(),n=0<=(n=t).indexOf("localhost")||0<=n.indexOf("local.pendo.io")?e.replace(".min.js",".js"):e;return t+"/"+(Ve?Ve+"/":"")+n}function $r(){var e=f.get("allowPartnerAnalyticsForwarding",!1)&&f.get("adoptAnalyticsForwarding",!1);return f.get("trainingPartner",!1)||e}var Zr=3,Yr=1,Xr=9,Qr=11,eo=4;function O(e){var t;if((t=e)&&t.nodeType===Yr)try{return D.getComputedStyle?getComputedStyle(e):e.currentStyle||void 0}catch(n){}}function to(e,t){var n;return!(!e||!x.isFunction(e.getPropertyValue))&&(n=[e.getPropertyValue("transform")],void 0!==t&&x.isString(t)&&n.push(e.getPropertyValue("-"+t.toLowerCase()+"-transform")),x.any(n,function(e){return e&&"none"!==e}))}function no(e){var t=(e=e||D).document.documentElement;return e.pageYOffset||t.scrollTop}function io(e){var t=(e=e||D).document.documentElement;return e.pageXOffset||t.scrollLeft}function ro(e){return x.isNumber(e)?e:0}function oo(e,t){e=e.offsetParent;return t=t||D,e=e&&e.parentElement===t.document.documentElement&&!so(e)?null:e}function ao(e){return to(O(e),ke)&&isNaN(_e)}function so(e){if(e)return(e=O(e))&&(x.contains(["relative","absolute","fixed"],e.position)||to(e,ke))}function uo(e,t,n){if(!e)return{width:0,height:0};n=n||D;var t=so(t)?t:oo(t,n),i=t?co(t):{top:0,left:0},e=co(e),i={top:e.top-i.top,left:e.left-i.left,width:e.width,height:e.height};return t?(t!==n.document.scrollingElement&&(i.top+=ro(t.scrollTop),i.left+=ro(t.scrollLeft)),i.top-=ro(t.clientTop),i.left-=ro(t.clientLeft)):(i.top+=no(n),i.left+=io(n)),i.bottom=i.top+i.height,i.right=i.left+i.width,i}function co(e){var t;return e?e.getBoundingClientRect?{top:(t=e.getBoundingClientRect()).top,left:t.left,bottom:t.bottom,right:t.right,width:t.width||Math.abs(t.right-t.left),height:t.height||Math.abs(t.bottom-t.top)}:{top:0,left:0,width:e.offsetWidth,height:e.offsetHeight,right:e.offsetWidth,bottom:e.offsetHeight}:{width:0,height:0}}var lo=void 0===(e=f.get("pendoCore"))||e,po=function(e,t,n){e=Hr+"/data/"+e+"/"+t,t=x.map(n,function(e,t){return t+"="+e});return 0=n&&e.left>=i&&e.top+e.height<=n+t.height&&e.left+e.width<=i+t.width};function No(t){return x.each(["left","top","width","height"],function(e){t[e]=Math.round(t[e])}),t}function Mo(e,t=D){var n;return function(e){var t,n=e;for(;n;){if(!(t=O(n)))return;if("fixed"===t.position)return!isNaN(_e)||!jo(n);n=n.parentNode}return}(e)?((n=co(e)).fixed=!0,No(n)):No(uo(e,Go(t.document),t))}var Po=function(e){e&&e.parentNode&&e.parentNode.removeChild(e)},Fo=x.compose(function(e){return Array.prototype.slice.call(e)},function(e,t){try{return ur(e,t)}catch(n){return fo("error using sizzle: "+n),t.getElementsByTagName(e)}}),Do=function(e,t){try{return t.children.length+t.offsetHeight+t.offsetWidth-(e.children.length+e.offsetHeight+e.offsetWidth)}catch(n){return B.info("error interrogating body elements: "+n),fo("error picking best body:"+n),0}},Go=function(e){e=e||G;try{var t=Fo("body",e);return t&&1=t.bottom||e.bottom<=t.top||e.left>=t.right||e.right<=t.left)};function jo(e){for(var t=e&&e.parentNode;t;){if(to(O(t),ke))return 1;t=t.parentNode}}var Wo=function(e,t,n){t=t||/(auto|scroll|hidden)/;var i,r=(n=n||D).document.documentElement;if(Bo(e))for(i=e;i;)if(zn.isElementShadowRoot(i))i=i.host;else{if(i===r)return null;if(!(o=O(i)))return null;var o,a=o.position;if(i!==e&&t.test(o.overflow+o.overflowY+o.overflowX))return i.parentNode!==r||(o=O(r))&&!x.contains([o.overflow,o.overflowY,o.overflowX],"visible")?i:null;if("absolute"===a||"fixed"===a&&jo(i))i=oo(i);else{if("fixed"===a)return null;i=i.assignedSlot||i.parentNode}}return null};function Jo(e,t){e=O(e);return t=t||/(auto|scroll|hidden)/,!e||"inline"===e.display?qo.NONE:t.test(e.overflowY)&&t.test(e.overflowX)?qo.BOTH:t.test(e.overflowY)?qo.Y:t.test(e.overflowX)?qo.X:t.test(e.overflow)?qo.BOTH:qo.NONE}var qo={X:"x",Y:"y",BOTH:"both",NONE:"none"};function Ko(e){return e&&e.nodeName&&"body"===e.nodeName.toLowerCase()&&Bo(e)}function Vo(e){var t=G.createElement("script"),n=G.head||G.getElementsByTagName("head")[0]||G.body;t.type="text/javascript",e.src?t.src=e.src:t.text=e.text||e.textContent||e.innerHTML||"",n.appendChild(t),n.removeChild(t)}function $o(e){if(e){if(x.isFunction(e.getRootNode))return e.getRootNode();if(null!=e.ownerDocument)return e.ownerDocument}return G}function Zo(e,t,n){const i=[];var r=$o(G.documentElement);let o=$o(Wo(t)),a=0;for(;o!==r&&a<20;)i.push(e(o,"scroll",n,!0)),o=$o(Wo(o)),a++;return()=>{x.each(x.compact(i),function(e){e()}),i.length=0}}const aw=['a[href]:not([disabled]):not([tabindex="-1"])','button:not([disabled]):not([tabindex="-1"])','textarea:not([disabled]):not([tabindex="-1"])','input:not([disabled]):not([tabindex="-1"])','select:not([disabled]):not([tabindex="-1"])','[tabindex]:not([tabindex="-1"])',"iframe"].join(", ");function Yo(e,t,n){var i=Ho(t),t=Jo(t,n);if(t!==qo.BOTH||zo(e,i)){if(t===qo.Y){if(e.top>=i.bottom)return;if(e.bottom<=i.top)return}if(t===qo.X){if(e.left>=i.right)return;if(e.right<=i.left)return}return 1}}function Xo(e){if(e){if(Ko(e))return 1;var t=Ho(e);if(0!==t.width&&0!==t.height){var n=O(e);if(!n||"hidden"!==n.visibility){for(var i=e;i&&n;){if("none"===n.display)return;if(parseFloat(n.opacity)<=0)return;n=O(i=i.parentNode)}return 1}}}}function Qo(e,t){if(!Xo(e))return!1;if(!Ko(e)){for(var n=Ho(e),i=Wo(e,t=t||/hidden/),r=null;i&&i!==G&&i!==r;){if(!Yo(n,i,t))return!1;i=Wo(r=i,t)}if(e.getBoundingClientRect){var e=e.getBoundingClientRect(),o=e.right,e=e.bottom;if(n.fixed||(o+=io(),e+=no()),o<=0||e<=0)return!1}}return!0}function ea(e){var t,n,i,r,o,a=/(auto|scroll)/,s=/(auto|scroll|hidden)/,u=Ho(e),d=Wo(e,s);if(!Xo(e))return!1;for(;d;){if(t=Ho(d),(o=Jo(d,a))!==qo.NONE&&(i=n=0,o!==qo.Y&&o!==qo.BOTH||(u.bottom>t.bottom&&(n+=u.bottom-t.bottom,u.top-=n,u.bottom-=n),u.topt.right&&(i+=u.right-t.right,u.left-=i,u.right-=i),u.leftn.bottom&&(i+=t.bottom-n.bottom,t.top-=i,t.bottom-=i),t.topn.right&&(r+=t.right-n.right,t.left-=r,t.right-=r),t.left{},ze.MutationObserver){const n=new(ht("MutationObserver"))((e,t)=>{this.signal()});n.observe(e,t),this._teardown=()=>n.disconnect}else{const i=Gt(()=>{this.signal()},500);this._teardown=()=>{clearTimeout(i)}}}signal(){x.each(this.listeners,e=>{e.get()})}addObservers(...e){this.listeners=[].concat(this.listeners,e)}teardown(){this._teardown()}}Kv=function(){function e(e){this._object=e}return e.prototype.deref=function(){return this._object},e};var na="function"==typeof(d=D.WeakRef)&&/native/.test(d)?d:Kv();function ia(e,t){var n,i;return e.tagName&&-1<["textarea","input"].indexOf(e.tagName.toLowerCase())?(n=e.value,i=t,n.length<=i?n:sa(n.substring(0,i))):ra(e,t)}function ra(e,t=128){var n,i="",r=e.nodeType;if(r===Zr||r===eo)return e.nodeValue;if((n=e).tagName&&"textarea"!=n.tagName.toLowerCase()&&(r===Yr||r===Xr||r===Qr)){if(!e.childNodes)return i;for(var o,a=0;a{t.addEventListener(e,e=>this.onEvent(e))}),this.elRef=new na(t)}return t}getText(e=1024){return ia(this.get(),e)}addEventListener(e,t){var n=this.get();this.events.indexOf(e)<0&&(this.events.push(e),n)&&n.addEventListener(e,e=>this.onEvent(e)),this.listeners[e]=this.listeners[e]||[],this.listeners[e].push(t)}onEvent(t){var e=t.type;x.each(this.listeners[e],e=>e(t))}teardown(t=this.get()){t&&x.each(this.events,e=>t.removeEventListener(e,this.onEvent))}}function ua(t){if(!t)return!1;if(t===D.location.origin)return!0;if(t===Jr())return!0;if(t===Kr())return!0;var e=[/^https:\/\/(app|via|adopt)(\.eu|\.us|\.gov|\.jpn|\.hsbc|\.au)?\.pendo\.io$/,/^https:\/\/((adopt\.)?us1\.)?(app|via|adopt)\.pendo\.io$/,/^https:\/\/([0-9]{8}t[0-9]{4}-dot-)pendo-(io|eu|us1|govramp|jp-prod|hsbc|au)\.appspot\.com$/,/^https:\/\/hotfix-(ops|app)-([0-9]+-dot-)pendo-(io|eu|us1|govramp|jp-prod|hsbc|au)\.appspot\.com$/,/^https:\/\/pendo-(io|eu|us1|govramp|jp-prod|hsbc|au)-static\.storage\.googleapis\.com$/,/^https:\/\/(us1\.)?cdn(\.eu|\.jpn|\.gov|\.hsbc|\.au)?\.pendo\.io$/],n=(Xe()||(e=e.concat([/^https:\/\/([a-zA-Z0-9-]+\.)*pendo-dev\.com$/,/^https:\/\/([a-zA-Z0-9-]+-dot-)?pendo-(dev|test|io|us1|govramp|jp-prod|hsbc|au|batman|magic|atlas|wildlings|ionchef|mobile-guides|mobile-hummus|mobile-fbi|mobile-plat|eu|eu-dev|apollo|security|perfserf|freeze|armada|voc|mcfly|calypso|dap|scrum-ops|ml|helix|uat)\.appspot\.com$/,/^https:\/\/via\.pendo\.local:\d{4}$/,/^https:\/\/adopt\.pendo\.local:\d{4}$/,/^https:\/\/local\.pendo\.io:\d{4}$/,new RegExp("^https://pendo-"+Je+"-static\\.storage\\.googleapis\\.com$")])),f.get("adoptHost"));if(n&&t==="https://"+n)return!0;return!!x.contains(f.get("allowedOriginServers",[]),t)||x.any(e,function(e){return e.test(t)})}function da(e){var t;if(x.isString(e))return t=(t=Ur(e).substring(1))&&t.length?jr(t):{},e=x.last(x.first(e.split("?")).split("/")).split("."),{filename:x.first(e),extension:e.slice(1).join("."),query:t}}function ca(e,t){var n;f.get("guideValidation")&&ze.sri&&(n=da(t),t=x.find(["sha512","sha384","sha256"],function(e){return!!n.query[e]}))&&(e.integrity=t+"-"+(t=n.query[t],x.isString?t.replace(/-/g,"+").replace(/_/g,"/"):t),e.setAttribute("crossorigin","anonymous"))}x.extend(A,{data:cr,event:mr,removeNode:Po,getClass:_o,hasClass:Eo,addClass:function(e,t){var n;"string"==typeof e?(n=A(e),x.map(n,function(e){Io(e,t)})):Io(e,t)},removeClass:function(e,t){var n;"string"==typeof e?(n=A(e),x.map(n,function(e){xo(e,t)})):xo(e,t)},getBody:Go,getComputedStyle:O,getClientRect:Ho,intersectRect:zo,getScrollParent:Wo,isElementVisible:Qo,Observer:sw,Element:uw,scrollIntoView:ta,getRootNode:$o}),x.extend(A.prototype,mr.$,Yv.$);var la,pa=function(e){var t=0===f.get("allowedOriginServers",[]).length,n=$r();return!(!t&&!n&&(t=Je,n=te,!/prod/.test(t)||se(n)))||ua(e)},ha=function(e,t,n=!1){try{var i,r="text/css",o="text/javascript";if(x.isString(e)&&(e={url:e}),!pa((d=e.url,new Dn(d).origin)))throw new Error;e.type=e.type||/\.css/.test(e.url)?r:o;var a=null,s=G.getElementsByTagName("head")[0]||G.getElementsByTagName("body")[0];if(e.type===r){var u=G.createElement("link");u.type=r,u.rel="stylesheet",u.href=e.url,ca(u,e.url),a=u}else{if(pt())return(i=G.createElement("script")).addEventListener("load",function(){t(),Po(i)}),i.type=o,i.src=Q(I).createScriptURL(e.url),ca(i,e.url),G.body.appendChild(i),{};(i=G.createElement("script")).type=o,i["async"]=!0,i.src=Q(I).createScriptURL(e.url),ca(i,e.url),a=i,t=x.wrap(t,function(e,t){A.removeNode(i),t?n&&e(t):e.apply(this,x.toArray(arguments).slice(1))})}return s.appendChild(a),fa(a,e.url,t),a}catch(c){return{}}var d},fa=function(e,t,n){var i=!1;be(n)&&(e.onload=function(){!0!==i&&(i=!0,n(null,t))},e.onerror=function(){!0!==i&&(i=!0,n(new Error("Failed to load script"),t))},e.onreadystatechange=function(){i||e.readyState&&"loaded"!=e.readyState&&"complete"!=e.readyState||(i=!0,n(null,t))},"link"===e.tagName.toLowerCase())&&(Gt(function(){var e;i||((e=new Image).onload=e.onerror=function(){!0!==i&&(i=!0,n(null,t))},e.src=t)},500),Gt(function(){i||fo("Failed to load "+t+" within 10 seconds")},1e4))},ga=function(e){var t=JSON.parse(e.data),n=e.origin;B.debug(I.app_name+": Message: "+JSON.stringify(t)+" from "+n),Oa(e.source,{status:"success",msg:"ack",originator:"messageLogger"},n)},ma=function(e){ba(Ea)(va(e))},va=function(e){if(e.data)try{var t="string"==typeof e.data?JSON.parse(e.data):e.data,n=e.origin,i=e.source;if(!t.action&&!t.mutation){if(t.type&&"string"==typeof t.type)return{data:t,origin:n,source:i};B.debug("Invalid Message: Missing 'type' in data format")}}catch(r){}};function ba(t){return function(e){if(e&&ua(e.origin))return t.apply(this,arguments)}}var ya={disconnect(e){},module:function(e){Ra(e.moduleURL)},debug:function(e){Na(ga)}},wa=function(e,t){ya[e]=t},Sa=function(e){delete ya[e]},Ea=function(e){var t;e&&(t=e.data)&&be(ya[t.type])&&ya[t.type](t,e)},Ia={},xa=function(e){if(Ia[e]={},"undefined"!=typeof CKEDITOR)try{CKEDITOR.config.customConfig=""}catch(t){}},Ca=function(e){return be(Ia[e])},_a=function(e){if(Ia)for(var t in Ia)if(0<=t.indexOf(e))return t;return null},Ta=[],Aa=function(){var e;Ta.length<1||(e=Ta.shift(),Ca(e))||ha(e,function(){xa(e),Aa()})},Ra=function(e){!function(e){var t={"/js/lib/ckeditor/ckeditor.js":1};x.each(["depres.js","tether.js","sortable.js","selection.js","selection.css","html2canvas.js","ckeditor/ckeditor.js"],function(e){t["/modules/pendo.designer/plugins/"+e]=1,t["/engage-app-ui/assets/classic-designer/plugins/"+e]=1});try{var n=new Dn(e);return ua(n.origin)&&t[n.pathname]}catch(i){B.debug("Invalid module URL: "+e)}}(e)||(Ta.push(e),1e.codePointAt(0))))):JSON.parse(atob(e.split(".")[1]))}catch(n){return null}var t}function Qa(e,t){return t=t?t+": ":"",e.jwt||e.signingKeyName?e.jwt&&!e.signingKeyName?(B.debug(t+"The jwt is supplied but missing signingKeyName."),!1):e.signingKeyName&&!e.jwt?(B.debug(t+"The signingKeyName is supplied but missing jwt."),!1):(e=e.jwt,!(!x.isString(e)||!/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/.test(e))||(B.debug(t+"The jwt is invalid."),!1)):(B.debug(t+"Missing jwt and signingKeyName."),!1)}es=null;var es,ts={set:function(e){es=JSON.parse(JSON.stringify(e||{}))},get:function(){return null!==es?es:{}},getJwtOptions:function(e,t){var n;return t=t||"",!!f.get("enableSignedMetadata")&&(n=Qa(e,t),f.get("requireSignedMetadata")&&!n?(B.debug("Pendo will not "+t+"."),!1):n?Xa(e.jwt):void B.debug("JWT is enabled but not being used, falling back to unsigned metadata."))}};class dw{constructor(e,t=100){this.queue=[],this.unloads=new Set,this.pending=new Set,this.failures=new Map,this.sendFn=e,this.maxFailures=t}isEmpty(){return this.queue.length<=0}stop(){this.queue.length=0,this.unloads.clear(),this.pending.clear(),this.failures.clear(),this.stopped=!0,clearTimeout(this.timer),delete this.timer}start(){this.stopped=!1}push(...e){this.queue.push(...e),this.next()}next(){var e;if(this.queue.length&&this.pending.size<1)return e=this.queue[0],this.send(e,!1,!0)}incrementFailure(e){var t=(this.failures.get(e)||0)+1;return this.failures.set(e,t),t}pass(e,t=!0){this.unloads["delete"](e),this.pending["delete"](e),this.failures.clear();e=this.queue.indexOf(e);0<=e&&this.queue.splice(e,1),!this.stopped&&t&&this.next()}fail(e,t=!0){this.unloads["delete"](e),this.pending["delete"](e);var n=this.incrementFailure(e);!this.stopped&&t&&(n>=this.maxFailures&&(this.onTimeout&&this.onTimeout(),this.pass(e,!1)),this.retryLater(1e3*Math.pow(2,Math.min(n-1,6))))}retryLater(e){this.timer=Gt(()=>{delete this.timer,this.next()},e)}failed(){return 0this.pass(e,n),()=>this.fail(e,n))}drain(e,t=!0){if(this.queue.push(...e),this.failed())return qt.reject();var n=[];for(const i of this.queue)this.pending.has(i)?this.retryPending&&t&&!this.unloads.has(i)&&(this.incrementFailure(i),n.push(this.send(i,t,!1))):n.push(this.send(i,t,!1));return qt.all(n)}}const cw="unsentEvents";class lw{constructor(){this.events={}}send(t,n){var i=this.events[t];if(i){let e=i.shift();for(;e;)n.push(e),e=i.shift();delete this.events[t]}}push(e,t){var n=this.events[e]||[];n.push(t),this.events[e]=n}read(e){e.registry.addLocal(cw),this.events=JSON.parse(e.read(cw)||"{}"),e.clear(cw)}write(e){0t.upper)return o;i+=n}if(!(0t.lower)return o;i+=n}return-1}function ys(){var e=ts.get();return x.isEmpty(e)?0:e.jwt.length+e.signingKeyName.length}function ws(e,t){var n;if(0!==e.length)return e.JZB||(e.JZB=I.squeezeAndCompress(e.slice()),e.JZB.length<=Ba)||1===e.length?t(e):(n=e.length/2,ws(e.slice(0,n),t),void ws(e.slice(n),t))}function Ss(e,t){So()&&t(e)}function Es(){return function(e,t){1===e.length&&e.JZB.length>Ba?(B.debug("Couldn't write event"),fo("Single item is: "+e.JZB.length+". Dropping."),go(e.JZB)):t(e)}}function Is(e,t){return po(t.beacon+".gif",e,x.extend({v:$e,ct:v(),jzb:t.JZB},t.params,t.auth))}function xs(e,t){return po(t.beacon+".gif",e,x.extend({v:$e,ct:v(),s:t.JZB.length},t.params))}function Cs(i){return function(e,t){e.params=x.extend({},e.params,i.params),e.beacon=i.beacon,e.eventLength=e.JZB.length;var n=ts.get();x.isEmpty(n)||(e.auth=n,e.eventLength+=n.jwt.length,e.eventLength+=n.signingKeyName.length),t(e)}}function _s(e,t){var n=$r(),i=x.first(e),i=x.get(i,"account_id");n&&i&&(e.params=x.extend({},e.params,{acc:st(i)})),t(e)}function Ts(e,t){var n=x.first(e),n=x.get(n,"props.source");n&&(e.params=x.extend({},e.params,{source:n})),t(e)}function As(e){return JSON.stringify(x.extend({events:e.JZB},e.auth))}function Rs(e){return e.status<200||300<=e.status?b.reject(new Error(`received status code ${e.status}: `+e.statusText)):b.resolve()}function Os(e,t){return vo(Is(e,t)).then(Rs)}function ks(e,t){return fetch(xs(e,t),{method:"POST",keepalive:!0,body:As(t),headers:{"Content-Type":"application/json"}}).then(Rs)}function Ls(e,t){var n=As(t);return bo(xs(e,t),n)?b.resolve():b.reject()}function Ns(n){return function(e,t){return t.JZB?t.eventLength<=Ua&&!f.get("sendEventsWithPostOnly")?n.preferFetch&&!t.auth&&vo.supported()?Os(e,t):t.auth?vr({method:"GET",url:Is(e,t)}):mo(Is(e,t)):n.allowPost&&t.eventLength<=Ba?vo.supported()?ks(e,t):bo.supported()?Ls(e,t):vr({method:"POST",url:xs(e,e=t),data:As(e),headers:{"Content-Type":"application/json"}}):b.resolve():b.resolve()}}function Ms(n){return function(e,t){if(t.JZB){if(t.eventLength<=Ua&&!f.get("sendEventsWithPostOnly",!1)){if(!t.auth&&vo.supported())return Os(e,t);if(ze.msie<=11)return vr({method:"GET",url:Is(e,t),sync:!0})}if(t.eventLength<=Ba&&n.allowPost){if(vo.supported())return ks(e,t);if(bo.supported())return Ls(e,t);if(ze.msie<=11)return vr({method:"POST",url:xs(e,e=t),data:As(e),sync:!0,headers:{"Content-Type":"application/json"}})}}return b.resolve()}}function Ps(e,t){e.length=0;var n,i={};for(n in e)e.hasOwnProperty(n)&&(i[n]=e[n]);t(i)}function Fs(e){return ls(Ss,fs,ws,(t=e.shorten,t=x.defaults(t||{},{fields:[],siloMaxLength:Ba}),function(n,e){var i;1===n.length&&n.JZB.length>t.siloMaxLength&&(i=n[0],B.debug("Max length exceeded for an event"),x.each(t.fields,function(e){var t=i[e];t&&2e3e.isEmpty())}stop(){x.each(this.queues,e=>e.stop())}push(){const t=x.toArray(arguments);x.each(this.queues,e=>e.push.apply(e,t))}drain(){const t=x.toArray(arguments);return b.all(x.map(this.queues,e=>e.drain.apply(e,t)))}}function Ds(o,a,s){a=a||Ns(o),s=s||Ms(o);e=o;var e=x.isFunction(e.apiKey)?[].concat(e.apiKey()):[].concat(e.apiKey),e=x.map(e,(i,r)=>{var e=new dw(function(e,t,n){return n&&(e.params=x.extend({},e.params,{rt:n})),o.localStorageUnload&&t?(0===r&&ns.push(o.beacon,e),b.resolve()):(t?s:a)(i,e)});return e.onTimeout=function(){y.commit("monitoring/incrementCounter",o.beacon+"GifFailures")},e.retryPending=!0,e}),e=new pw(e);return ns.send(o.beacon,e),e}function Gs(e,t){var n=f.get("analytics.excludeEvents");0<=x.indexOf(n,e.type)||t(e)}class hw{constructor(e){this.locks={},this.cache=e.cache||[],this.silos=e.silos||[],this.packageSilos=e.packageSilos,this.processSilos=e.processSilos,this.sendQueue=Ds(e)}pause(e=1e4){var t=x.uniqueId();const n=this["locks"];n[t]=1;var i=()=>{n[t]&&(clearTimeout(r),delete n[t],this.flush())},r=Gt(i,e);return i}push(e){this.packageSilos(e,e=>{this.silos.push(e)})}clear(){this.cache.length=0,this.silos.length=0,this.sendQueue.stop()}flush({unload:e=!1,hidden:t=!1}={}){var{cache:n,silos:i}=this;if((0!==n.length||0!==i.length||!this.sendQueue.isEmpty())&&x.isEmpty(this.locks)){i.push(n.slice()),n.length=0;n=i.slice();i.length=0;const r=[];x.each(n,function(e){this.processSilos(e,function(e){r.push(e)})},this),e||t?this.sendQueue.drain(r,e):this.sendQueue.push(...r)}}}function Us(e){var i,r,t=Fs(e),n=ls((r=e.beacon,function(e,t){var n=f.get("excludeNonGuideAnalytics");"ptm"===r&&n||t(e)}),Gs,ps,hs(e.cache),(i={overhead:ys,lower:f.get("sendEventsWithPostOnly")?Ba:Ua,upper:Ba,compressionRatio:[.5*rs,.75*rs,rs]},function(e,t){for(var n=bs(e,i);0<=n;)t(e.splice(0,Math.max(n,1))),n=bs(e,i)}));return new hw(x.extend({processSilos:t,packageSilos:n},e))}var Bs=Fn(function(e){var t,n,i;if((e=e||R.get())&&e!==Bs.lastUrl)return Bs.lastUrl=e,t=-1,Ma()||ka()&&(i=La(),Oa(i,{type:"load",url:location.toString()},"*")),B.debug("sending load event for url "+e),t={load_time:t="undefined"!=typeof performance&&x.isFunction(performance.getEntriesByType)&&!x.isEmpty(performance.getEntriesByType("navigation"))?(i=performance.getEntriesByType("navigation")[0]).loadEventStart-i.fetchStart:t},ka()&&(t.is_frame=!0),"*"!==(n=$0())&&(t.allowed_storage_keys=x.keys(n)),os("load",t,e),Va(),m.urlChanged.trigger(),!0});function Hs(e){return"hidden"===e.visibilityState}Bs.reset=function(){Bs.lastUrl=null};const fw="visibilitychange",gw="pagehide",mw="unload";function zs(){this.serializers=x.toArray(arguments)}function js(e,t){return e.tag=zn.isElementShadowRoot(t)?"#shadow-root":t.nodeName||"",e}function Ws(e){return be(e)?""+e:""}function Js(e,t){return e.id=Ws(t.id),e}function qs(e,t){return e.cls=Ws(A.getClass(t)),e}x.extend(zs.prototype,{add(e){this.serializers.push(e)},remove(e){e=x.indexOf(this.serializers,e);0<=e&&this.serializers.splice(e,1)},serialize(n,i){return n?(i=i||n,x.reduce(this.serializers,function(e,t){return t.call(this,e,n,i)},{},this)):{}}});var Ks=256,Vs=64,$s={a:{events:["click"],attr:["href"]},button:{events:["click"],attr:["value","name"]},img:{events:["click"],attr:["src","alt"]},select:{events:["mouseup"],attr:["name","type","selectedIndex"]},textarea:{events:["mouseup"],attr:["name"]},'input[type="submit"]':{events:["click"],attr:["name","type","value"]},'input[type="button"]':{events:["click"],attr:["name","type","value"]},'input[type="radio"]':{events:["click"],attr:["name","type"]},'input[type="checkbox"]':{events:["click"],attr:["name","type"]},'input[type="password"]':{events:["click"],attr:["name","type"]},'input[type="text"]':{events:["click"],attr:["name","type"]}},Zs=function(e,t,n){var i;return e&&e.nodeName?"img"==(i=e.nodeName.toLowerCase())&&"src"==t||"a"==i&&"href"==t?(i=e.getAttribute(t),Dr((i=i)&&0===i.indexOf("data:")?(B.debug("Embedded data provided in URI."),i.substring(0,i.indexOf(","))):i+"")):(i=t,e=(t=e).getAttribute?t.getAttribute(i):t[i],(!n||typeof e===n)&&e?x.isString(e)?tt.call(e).substring(0,Ks):e:null):null};function Ys(t){var e,n,i;return x.isRegExp(t)&&x.isFunction(t.test)?function(e){return t.test(e)}:x.isArray(t)?(e=x.map(x.filter(t,x.isObject),function(e){var t;return e.regexp?(t=(t=/\/([a-z]*)$/.exec(e.value))&&t[1]||"",new RegExp(e.value.replace(/^\//,"").replace(/\/[a-z]*$/,""),t)):new RegExp("^"+e.value+"$","i")}),function(t){return x.any(e,function(e){return e.test(t)})}):x.isObject(t)&&t.regexp?(n=(n=/\/([a-z]*)$/.exec(t.value))&&n[1]||"",i=new RegExp(t.value.replace(/^\//,"").replace(/\/[a-z]*$/,""),n),function(e){return i.test(e)}):x.constant(!1)}function Xs(e,t,n,i){try{var r,o=x.indexBy(t),a=x.filter(x.filter(e,function(e){return n(e.nodeName)||o[e.nodeName]}),function(e){return!i(e.nodeName)});return a.length<=Vs?x.pluck(a,"nodeName"):(r=x.groupBy(e,function(e){return o[e.nodeName]?"defaults":x.isString(e.value)&&e.value.length>Ks?"large":"small"}),x.pluck([].concat(x.sortBy(r.defaults,"nodeName")).concat(x.sortBy(r.small,"nodeName")).concat(x.sortBy(r.large,"nodeName")).slice(0,Vs),"nodeName"))}catch(s){return B.error("Error collecting DOM Node attributes: "+s),[]}}function Qs(t,n){var e=Ys(f.get("htmlAttributes")),i=Ys(f.get("htmlAttributeBlacklist")),r=(i("title")||(t.title=Zs(n,"title","string")),(t.tag||"").toLowerCase()),r=("input"===r&&(r+='[type="'+n.type+'"]'),t.attrs={},Xs(n.attributes,$s[r]&&$s[r].attr,e,i));return x.each(r,function(e){t.attrs[e.toLowerCase()]=Zs(n,e)}),t}function eu(e,t){var n;return t.parentNode&&t.parentNode.childNodes&&(n=x.chain(t.parentNode.childNodes),e.myIndex=n.indexOf(t).value(),e.childIndex=n.filter(function(e){return e.nodeType==Yr}).indexOf(t).value()),e}function tu(i,e){var r;return f.get("siblingSelectors")&&e.previousElementSibling&&(r="_pendo_sibling_",this.remove(tu),e=this.serialize(e.previousElementSibling),this.add(tu),i.attrs=i.attrs||{},x.each(e,function(e,t){var n={cls:"class",txt:"pendo_text"}[t]||t;x.isEmpty(e)||(x.isObject(e)?x.each(e,function(e,t){e&&!x.isEmpty(e)&&(i.attrs[r+n+"_"+t]=e)}):i.attrs[r+n]=e)})),i}var nu=new zs(js,Js,qs,Qs,eu,tu),iu=function(e){return"BODY"===e.nodeName&&e===Go()||null===e.parentNode&&!zn.isElementShadowRoot(e)},ru="pendo-ignore",ou="pendo-analytics-ignore",au=function(e){var t={},n=t,i=e,r=!1;if(!e)return t;do{var o=i,a=nu.serialize(o,e)}while(r||!lt(a.cls,ru)&&!lt(a.cls,ou)||(r=!0),n.parentElem=a,n=a,(i=zn.getParent(o))&&!iu(o));return r&&(t.parentElem.ignore=!0),t.parentElem},su=["","left","right","middle"],uu=[["button",function(e){return e.which||e.button},function(){return!0},function(e,t){return su[t]}],["altKey",e=function(e,t){return e[t]},a=function(e){return e},a],["ctrlKey",e,a,a],["metaKey",e,a,a],["shiftKey",e,a,a]],du={click:function(e,t){for(var n=[],i=0;i{lu.cancel()}),t.push(hr(G,"change",lu,!0)));var n,i=f.get("interceptElementRemoval")||f.get("syntheticClicks.elementRemoval"),r=f.get("syntheticClicks.targetChanged"),r=(t.push(function(t,e,n,i){var r,o,a=[],s=ze.hasEvent("pointerdown"),u=s?"pointerdown":"mousedown",s=s?"pointerup":"mouseup",d=[],c={cloneEvent:function(e){e=A.event.clone(e);return e.type="click",e.from=u,e.bubbles=!0,e},down:function(e){o=!1,e&&(r=c.cloneEvent(e),n)&&c.intercept(e)},up:function(e){o=!1,e&&r&&i&&gr(r)!==gr(e)&&(o=!0,t(r))},click:function(e){r=null,o&&A.data.set(e,"ignore",!0),o=!1,n&&c.unwrap()},intercept:function(e){e=function(e){var t=[];for(;e&&!iu(e);)t.push(e),e=e.parentNode;return t}(gr(e));x.each(e,function(e){e=hu(e,c.remove);a.push(e)})},remove:function(){r&&(t(r),r=null),c.unwrap()},unwrap:function(){0{x.each(n,function(e){e()})})),t.push(function(e,t){if(t){const n=[];return n.push(hr(G,fw,()=>{Hs(G)&&e(!0,!1)})),n.push(hr(D,gw,x.partial(e,!1,!0))),()=>x.each(n,function(e){e()})}return hr(D,mw,x.partial(e,!0,!0))}(function(e,t){t&&m.appUnloaded.trigger(),e&&m.appHidden.trigger()},f.get("preventUnloadListener"))),f.get("interceptStopPropagation",!0)),i=f.get("interceptPreventDefault",!0);return r&&t.push(fu(D.Event,e)),i&&t.push(gu(D.Event,["touchend"])),()=>{x.each(t,function(e){e()})}};function hu(n,i){var e=["remove","removeChild"];try{if(!n)return x.noop;x.each(e,function(e){var t=n[e];if(!t)return x.noop;n[e]=x.wrap(t,function(e){return i&&i(),e.apply(this,x.toArray(arguments).slice(1))}),n[e]._pendoUnwrap=function(){if(!n)return x.noop;n[e]=t,delete n[e]._pendoUnwrap}})}catch(t){B.critical("ERROR in interceptRemove",{error:t})}return function(){x.each(e,function(e){if(!n[e])return x.noop;e=n[e]._pendoUnwrap;x.isFunction(e)&&e()})}}function fu(n,e){var t=["stopPropagation","stopImmediatePropagation"];try{if(!n||!n.prototype)return x.noop;var i=x.indexBy(e);x.each(t,function(e){var t=n.prototype[e];t&&(n.prototype[e]=x.wrap(t,function(e){var t=e.apply(this,arguments);return i[this.type]&&(A.data.set(this,"stopped",!0),A.event.trigger(this)),t}),n.prototype[e]._pendoUnwrap=function(){n.prototype[e]=t,delete n.prototype[e]._pendoUnwrap})})}catch(r){B.critical("ERROR in interceptStopPropagation",{error:r})}return function(){x.each(t,function(e){e=n.prototype[e]._pendoUnwrap;x.isFunction(e)&&e()})}}function gu(t,e){try{if(!t||!t.prototype)return x.noop;var i=x.indexBy(e),n=t.prototype.preventDefault;if(!n)return x.noop;t.prototype.preventDefault=x.wrap(n,function(e){var t,n=e.apply(this,arguments);return i[this.type]&&((t=A.event.clone(this)).type="click",t.from=this.type,t.bubbles=!0,t.eventPhase=lr,A.event.trigger(t)),n}),t.prototype.preventDefault._pendoUnwrap=function(){t.prototype.preventDefault=n,delete t.prototype.preventDefault._pendoUnwrap}}catch(r){B.critical("ERROR in interceptPreventDefault",{error:r})}return function(){var e=t.prototype.preventDefault._pendoUnwrap;x.isFunction(e)&&e()}}function l(e,t,n,i){return e&&t&&n?(i&&!ze.addEventListener&&(i=!1),A.event.add(e,{type:t,handler:n,capture:i})):x.noop}function mu(e,t,n,i){e&&t&&n&&(i&&!ze.addEventListener&&(i=!1),A.event.remove(e,t,n,i))}var vu=function(e){A.data.set(e,"pendoStopped",!0),e.stopPropagation?e.stopPropagation():e.cancelBubble=!0,e.preventDefault?e.preventDefault():e.returnValue=!1},bu=function(e,t){return"complete"!==(t=t||D).document.readyState?hr(t,"load",e):(e(),x.noop)},k=[],yu=[],wu={};let h={};function Su(){return k}function Eu(){return Iu(k)}function Iu(e){return x.filter(e,function(e){return!e.isFrameProxy})}function xu(e){x.isArray(e)?(k=e,m.guideListChanged.trigger({guideIds:x.pluck(e,"id")})):B.info("bad guide array input to `setActiveGuides`")}var Cu=function(){let n=[];return{addGuide:e=>{var t;x.isEmpty(e)||(n=n.concat(e),x.each(n,e=>e.hide&&e.hide()),e=k,(t=x.difference(e,n)).length