@rancher/shell 3.0.10 → 3.0.12-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/assets/styles/base/_mixins.scss +31 -0
  2. package/assets/styles/base/_variables.scss +2 -0
  3. package/assets/styles/themes/_modern.scss +6 -5
  4. package/assets/translations/en-us.yaml +12 -9
  5. package/assets/translations/zh-hans.yaml +0 -3
  6. package/chart/__tests__/rancher-backup-index.test.ts +248 -0
  7. package/chart/rancher-backup/index.vue +41 -2
  8. package/components/BrandImage.vue +6 -5
  9. package/components/ConsumptionGauge.vue +12 -4
  10. package/components/DynamicContent/DynamicContentIcon.vue +3 -2
  11. package/components/EmptyProductPage.vue +76 -0
  12. package/components/ExplorerProjectsNamespaces.vue +1 -4
  13. package/components/LazyImage.vue +2 -1
  14. package/components/Resource/Detail/Card/Scaler.vue +4 -4
  15. package/components/Resource/Detail/CopyToClipboard.vue +1 -2
  16. package/components/Resource/Detail/Metadata/KeyValueRow.vue +9 -3
  17. package/components/Resource/Detail/TitleBar/__tests__/__snapshots__/index.test.ts.snap +31 -0
  18. package/components/Resource/Detail/TitleBar/__tests__/index.test.ts +45 -1
  19. package/components/Resource/Detail/TitleBar/index.vue +1 -1
  20. package/components/Resource/Detail/ViewOptions/__tests__/__snapshots__/index.test.ts.snap +9 -0
  21. package/components/Resource/Detail/ViewOptions/__tests__/index.test.ts +62 -0
  22. package/components/Resource/Detail/ViewOptions/index.vue +2 -1
  23. package/components/ResourceList/Masthead.vue +25 -2
  24. package/components/SideNav.vue +13 -0
  25. package/components/Tabbed/index.vue +6 -0
  26. package/components/__tests__/ConsumptionGauge.test.ts +31 -0
  27. package/components/__tests__/PromptModal.test.ts +2 -0
  28. package/components/fleet/FleetClusters.vue +1 -0
  29. package/components/fleet/__tests__/FleetClusters.test.ts +71 -0
  30. package/components/form/NodeScheduling.vue +17 -3
  31. package/components/form/PrivateRegistry.vue +69 -0
  32. package/components/form/ProjectMemberEditor.vue +0 -10
  33. package/components/form/__tests__/PrivateRegistry.test.ts +133 -0
  34. package/components/formatter/WorkloadHealthScale.vue +3 -1
  35. package/components/nav/Group.vue +26 -3
  36. package/components/nav/Header.vue +32 -7
  37. package/components/nav/TopLevelMenu.helper.ts +7 -79
  38. package/components/nav/TopLevelMenu.vue +15 -1
  39. package/components/nav/__tests__/TopLevelMenu.helper.test.ts +2 -53
  40. package/config/pagination-table-headers.js +8 -1
  41. package/config/private-label.js +2 -1
  42. package/config/product/apps.js +3 -1
  43. package/config/product/auth.js +1 -0
  44. package/config/product/backup.js +1 -0
  45. package/config/product/compliance.js +1 -1
  46. package/config/product/explorer.js +25 -6
  47. package/config/product/fleet.js +1 -0
  48. package/config/product/gatekeeper.js +1 -0
  49. package/config/product/istio.js +1 -0
  50. package/config/product/logging.js +1 -0
  51. package/config/product/longhorn.js +2 -1
  52. package/config/product/manager.js +1 -0
  53. package/config/product/monitoring.js +1 -0
  54. package/config/product/navlinks.js +1 -0
  55. package/config/product/neuvector.js +2 -1
  56. package/config/product/settings.js +1 -0
  57. package/config/product/uiplugins.js +1 -0
  58. package/core/__tests__/extension-manager-impl.test.js +187 -2
  59. package/core/__tests__/plugin-products-helpers.test.ts +454 -0
  60. package/core/__tests__/plugin-products.test.ts +3219 -0
  61. package/core/extension-manager-impl.js +34 -3
  62. package/core/plugin-helpers.ts +31 -0
  63. package/core/plugin-products-base.ts +375 -0
  64. package/core/plugin-products-extending.ts +44 -0
  65. package/core/plugin-products-helpers.ts +262 -0
  66. package/core/plugin-products-top-level.ts +66 -0
  67. package/core/plugin-products-type-guards.ts +33 -0
  68. package/core/plugin-products.ts +50 -0
  69. package/core/plugin-types.ts +222 -0
  70. package/core/plugin.ts +45 -10
  71. package/core/productDebugger.js +48 -0
  72. package/core/types.ts +95 -11
  73. package/detail/__tests__/__snapshots__/fleet.cattle.io.bundle.test.ts.snap +52 -0
  74. package/detail/__tests__/fleet.cattle.io.bundle.test.ts +171 -0
  75. package/detail/__tests__/node.test.ts +83 -0
  76. package/detail/fleet.cattle.io.bundle.vue +21 -34
  77. package/detail/management.cattle.io.oidcclient.vue +2 -1
  78. package/detail/node.vue +1 -0
  79. package/dialog/ExtensionCatalogInstallDialog.vue +1 -1
  80. package/dialog/InstallExtensionDialog.vue +6 -27
  81. package/dialog/UninstallExistingExtensionDialog.vue +141 -0
  82. package/dialog/UninstallExtensionDialog.vue +4 -26
  83. package/dialog/__tests__/UninstallExistingExtensionDialog.test.ts +114 -0
  84. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +1 -0
  85. package/edit/catalog.cattle.io.clusterrepo.vue +17 -3
  86. package/edit/cloudcredential.vue +2 -1
  87. package/edit/monitoring.coreos.com.alertmanagerconfig/receiverConfig.vue +11 -6
  88. package/edit/provisioning.cattle.io.cluster/__tests__/Ingress.test.ts +176 -0
  89. package/edit/provisioning.cattle.io.cluster/index.vue +5 -4
  90. package/edit/provisioning.cattle.io.cluster/rke2.vue +4 -1
  91. package/edit/provisioning.cattle.io.cluster/shared.ts +4 -2
  92. package/edit/provisioning.cattle.io.cluster/tabs/Basics.vue +6 -0
  93. package/edit/provisioning.cattle.io.cluster/tabs/Ingress.vue +7 -2
  94. package/edit/secret/generic.vue +1 -0
  95. package/edit/secret/index.vue +2 -1
  96. package/edit/service.vue +2 -14
  97. package/list/management.cattle.io.feature.vue +7 -1
  98. package/list/provisioning.cattle.io.cluster.vue +0 -50
  99. package/list/workload.vue +11 -4
  100. package/mixins/brand.js +2 -1
  101. package/mixins/resource-fetch.js +12 -3
  102. package/models/catalog.cattle.io.clusterrepo.js +9 -0
  103. package/models/cluster.x-k8s.io.machinedeployment.js +8 -3
  104. package/models/management.cattle.io.authconfig.js +2 -1
  105. package/models/management.cattle.io.cluster.js +4 -3
  106. package/models/monitoring.coreos.com.receiver.js +11 -6
  107. package/models/pod.js +18 -0
  108. package/models/provisioning.cattle.io.cluster.js +2 -2
  109. package/models/workload.js +20 -2
  110. package/package.json +5 -6
  111. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +0 -1
  112. package/pages/c/_cluster/apps/charts/index.vue +3 -8
  113. package/pages/c/_cluster/apps/charts/install.vue +8 -9
  114. package/pages/c/_cluster/istio/index.vue +4 -2
  115. package/pages/c/_cluster/longhorn/index.vue +2 -1
  116. package/pages/c/_cluster/monitoring/index.vue +2 -2
  117. package/pages/c/_cluster/neuvector/index.vue +2 -1
  118. package/pages/c/_cluster/settings/brand.vue +4 -4
  119. package/pages/c/_cluster/settings/performance.vue +0 -5
  120. package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +2 -1
  121. package/pages/c/_cluster/uiplugins/__tests__/index.test.ts +231 -13
  122. package/pages/c/_cluster/uiplugins/index.vue +145 -38
  123. package/plugins/dashboard-store/__tests__/resource-class.test.ts +1 -0
  124. package/plugins/dashboard-store/actions.js +3 -2
  125. package/plugins/dashboard-store/resource-class.js +62 -6
  126. package/plugins/plugin.js +16 -0
  127. package/plugins/steve/steve-pagination-utils.ts +8 -2
  128. package/plugins/steve/subscribe.js +29 -4
  129. package/rancher-components/RcButton/RcButton.vue +3 -3
  130. package/rancher-components/RcButtonSplit/RcButtonSplit.test.ts +253 -0
  131. package/rancher-components/RcButtonSplit/RcButtonSplit.vue +158 -0
  132. package/rancher-components/RcButtonSplit/index.ts +1 -0
  133. package/scripts/test-plugins-build.sh +4 -4
  134. package/scripts/typegen.sh +13 -1
  135. package/store/__tests__/type-map.test.ts +84 -24
  136. package/store/type-map.js +42 -3
  137. package/tsconfig.paths.json +1 -0
  138. package/types/resources/pod.ts +18 -0
  139. package/types/shell/index.d.ts +8506 -2908
  140. package/types/store/dashboard-store.types.ts +5 -0
  141. package/types/store/pagination.types.ts +6 -0
  142. package/utils/__tests__/require-asset.test.ts +98 -0
  143. package/utils/async.ts +1 -5
  144. package/utils/axios.js +1 -4
  145. package/utils/brand.ts +3 -1
  146. package/utils/dynamic-importer.js +3 -2
  147. package/utils/favicon.js +4 -3
  148. package/utils/pagination-utils.ts +1 -1
  149. package/utils/require-asset.ts +95 -0
  150. package/utils/uiplugins.ts +12 -16
  151. package/utils/validators/__tests__/private-registry.test.ts +76 -0
  152. package/utils/validators/private-registry.ts +28 -0
  153. package/vue.config.js +4 -3
  154. package/components/HarvesterServiceAddOnConfig.vue +0 -207
@@ -0,0 +1,76 @@
1
+ <script>
2
+ export default {
3
+ name: 'EmptyProductPage',
4
+ layout: 'plain',
5
+ data() {
6
+ const err = this.$route.meta?.pageError;
7
+
8
+ let msg;
9
+
10
+ switch (err) {
11
+ case 'no-nav':
12
+ msg = [
13
+ 'When a component is not provided for a product, the layout with side navigation is used',
14
+ 'No child items were specified, so this "Default" empty view has been added',
15
+ 'Please add child items to this product'
16
+ ];
17
+ break;
18
+ default:
19
+ msg = ['No component defined for this extension product... Define a component or child pages so it can be rendered here.'];
20
+ }
21
+
22
+ return {
23
+ img: require('~shell/assets/images/generic-plugin.svg'),
24
+ msg,
25
+ };
26
+ },
27
+ };
28
+ </script>
29
+
30
+ <template>
31
+ <div class="empty-product-page">
32
+ <img
33
+ :src="img"
34
+ alt="Extension Product Error"
35
+ >
36
+ <div class="err-messages">
37
+ <p
38
+ v-for="(m, index) in msg"
39
+ :key="index"
40
+ >
41
+ {{ m }}
42
+ </p>
43
+ </div>
44
+ </div>
45
+ </template>
46
+
47
+ <style lang="scss" scoped>
48
+ .empty-product-page {
49
+ align-items: center;
50
+ display: flex;
51
+ justify-content: center;
52
+ opacity: 0.75;
53
+
54
+ > img {
55
+ width: 128px;
56
+ margin-bottom: 20px;
57
+ }
58
+
59
+ .err-messages {
60
+ display: flex;
61
+ align-items: center;
62
+ text-align: center;
63
+ flex-direction: column;
64
+
65
+ > * {
66
+ margin-bottom: 8px;
67
+ font-size: 16px;
68
+
69
+ &:last-child {
70
+ font-weight: bold;
71
+ color: var(--error);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ </style>
@@ -593,15 +593,12 @@ export default {
593
593
 
594
594
  .project-namespaces {
595
595
  & :deep() {
596
- .project-namespaces-table table {
597
- table-layout: fixed;
598
- }
599
-
600
596
  .project-name {
601
597
  line-height: 30px;
602
598
  }
603
599
 
604
600
  .project-bar {
601
+ contain: inline-size;
605
602
  display: flex;
606
603
  flex-direction: row;
607
604
  justify-content: space-between;
@@ -1,5 +1,6 @@
1
1
  <script>
2
2
  import { BLANK_IMAGE } from '@shell/utils/style';
3
+ import genericCatalogSvg from '@shell/assets/images/generic-catalog.svg';
3
4
 
4
5
  export default {
5
6
  props: {
@@ -10,7 +11,7 @@ export default {
10
11
 
11
12
  errorSrc: {
12
13
  type: String,
13
- default: require('@shell/assets/images/generic-catalog.svg'),
14
+ default: genericCatalogSvg,
14
15
  },
15
16
 
16
17
  src: {
@@ -54,9 +54,9 @@ const i18n = useI18n(store);
54
54
  .scaler {
55
55
  display: inline-flex;
56
56
  align-items: center;
57
- background-color: hsl(from var(--primary) h s calc(l + 30));
57
+ background-color: var(--accent-btn);
58
58
  border-radius: var(--border-radius-md);
59
- border: 1px solid var(--primary);
59
+ border: solid thin var(--primary);
60
60
  overflow: hidden;
61
61
 
62
62
  button {
@@ -77,7 +77,7 @@ const i18n = useI18n(store);
77
77
  }
78
78
 
79
79
  &:hover {
80
- background-color: hsl(from var(--primary) h s calc(l + 20));
80
+ background-color: var(--accent-btn);
81
81
  }
82
82
 
83
83
  &[disabled] {
@@ -88,7 +88,7 @@ const i18n = useI18n(store);
88
88
  }
89
89
 
90
90
  .value {
91
- color: initial;
91
+ color: var(--body-text);
92
92
  cursor: default;
93
93
  padding: 4px;
94
94
  padding-top: 5px;
@@ -70,8 +70,7 @@ const onClick = (ev: MouseEvent) => {
70
70
  border-color: var(--success-border);
71
71
  color: var(--success-text);
72
72
 
73
- transition: all 0.25s;
74
- transition-timing-function: ease;
73
+ transition: background-color 0.25s ease, border-color 0.25s ease, color 0.25s ease;
75
74
  }
76
75
 
77
76
  &:focus-visible {
@@ -77,12 +77,15 @@ const previewId = randomStr();
77
77
  position: relative;
78
78
  padding: 0;
79
79
 
80
+ $topShift: -6px;
81
+ $topShiftHidden: -100vh; //100% of the viewport
82
+
80
83
  .copy-to-clipboard {
81
84
  position: fixed;
82
85
 
83
86
  right: -20px;
84
- top: -6px;
85
- z-index: 20px;
87
+ top: $topShiftHidden;
88
+ z-index: z-index('copyToClipboard');
86
89
  }
87
90
 
88
91
  &, .btn, .rc-tag {
@@ -126,13 +129,16 @@ const previewId = randomStr();
126
129
  }
127
130
 
128
131
  & + .copy-to-clipboard {
132
+ // This is how we "hide" the component but still allow it to be visible to accessibility (tab focus)
129
133
  position: absolute;
134
+ top: $topShift;
130
135
  }
131
136
  }
132
137
 
133
138
  .copy-to-clipboard:focus-visible, .copy-to-clipboard:hover {
139
+ // This is how we "hide" the component but still allow it to be visible to accessibility (tab focus)
134
140
  position: absolute;
135
-
141
+ top: $topShift;
136
142
  }
137
143
 
138
144
  .btn:has(+ .copy-to-clipboard:focus-visible), .btn:has(+ .copy-to-clipboard:hover) {
@@ -0,0 +1,31 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`component: TitleBar/index should match the full component snapshot 1`] = `
4
+ <div class="title-bar">
5
+ <div class="top">
6
+ <h1 class="title">
7
+ <!----><a class="resource-link">RESOURCE_TYPE_LABEL: </a><span class="resource-name masthead-resource-title">RESOURCE_NAME</span><span class="badge-state bg-success badge-state"><!--v-if--><span class="msg">Active</span></span>
8
+ </h1>
9
+ <div class="actions"><button role="button" class="rc-button btn variant-primary btn-medium">
10
+ <!--v-if-->Deploy
11
+ <!--v-if-->
12
+ </button><button role="button" class="rc-button btn variant-secondary btn-large">
13
+ <!--v-if-->Rollback
14
+ <!--v-if-->
15
+ </button><button role="button" class="rc-button btn variant-primary btn-large show-configuration" data-testid="show-configuration-cta" aria-label="component.resource.detail.titleBar.ariaLabel.showConfiguration-{&quot;resource&quot;:&quot;RESOURCE_NAME&quot;}">
16
+ <!--v-if--><i class="icon icon-document" aria-hidden="true"></i> component.resource.detail.titleBar.showConfiguration
17
+ <!--v-if-->
18
+ </button>
19
+ <div class="v-popper v-popper--theme-dropdown"><button role="button" class="rc-button btn variant-multi-action btn-medium" aria-haspopup="menu" aria-expanded="false" data-testid="masthead-action-menu" aria-label="component.resource.detail.titleBar.ariaLabel.actionMenu-{&quot;resource&quot;:&quot;RESOURCE_NAME&quot;}">
20
+ <!--v-if--><i class="icon icon-actions"></i>
21
+ <!--v-if-->
22
+ </button></div>
23
+ <div class="popperContainer">
24
+ <!--Empty container for mounting popper content-->
25
+ </div>
26
+ </div>
27
+ </div>
28
+ <div class="bottom description text-deemphasized">A test description</div>
29
+ <!--v-if-->
30
+ </div>
31
+ `;
@@ -1,5 +1,5 @@
1
1
  import { mount, RouterLinkStub } from '@vue/test-utils';
2
- import TitleBar from '@shell/components/Resource/Detail/TitleBar/index.vue';
2
+ import TitleBar, { AdditionalActionButton } from '@shell/components/Resource/Detail/TitleBar/index.vue';
3
3
  import ActionMenu from '@shell/components/ActionMenuShell.vue';
4
4
  import { createStore } from 'vuex';
5
5
  import { defineComponent, h } from 'vue';
@@ -240,5 +240,49 @@ describe('component: TitleBar/index', () => {
240
240
  expect(wrapper.find('.slot-button').exists()).toBeTruthy();
241
241
  expect(wrapper.find('.slot-button').text()).toBe('Slot Button');
242
242
  });
243
+
244
+ it('should render the actions container correctly when additional-actions slot contains nested buttons', async() => {
245
+ const wrapper = mount(TitleBar, {
246
+ props: { resourceTypeLabel, resourceName },
247
+ slots: { 'additional-actions': '<div class="btn-group"><button class="nested-btn">A</button><button class="nested-btn">B</button></div>' },
248
+ global: { stubs: { 'router-link': RouterLinkStub }, provide: { store } }
249
+ });
250
+
251
+ const actions = wrapper.find('.actions');
252
+
253
+ expect(actions.find('.btn-group').exists()).toBeTruthy();
254
+ expect(actions.findAll('.btn-group .nested-btn')).toHaveLength(2);
255
+ });
256
+ });
257
+
258
+ it('should match the full component snapshot', () => {
259
+ const additionalActions: AdditionalActionButton[] = [
260
+ {
261
+ label: 'Deploy', variant: 'primary', onClick: jest.fn()
262
+ },
263
+ {
264
+ label: 'Rollback', variant: 'secondary', size: 'large', onClick: jest.fn()
265
+ },
266
+ ];
267
+
268
+ const wrapper = mount(TitleBar, {
269
+ props: {
270
+ resource: {},
271
+ resourceTypeLabel,
272
+ resourceName,
273
+ resourceTo,
274
+ description: 'A test description',
275
+ badge: { color: 'bg-success', label: 'Active' },
276
+ additionalActions,
277
+ actionMenuResource: { resource: 'test-menu' },
278
+ onShowConfiguration() {},
279
+ },
280
+ global: {
281
+ stubs: { 'router-link': RouterLinkStub },
282
+ provide: { store }
283
+ }
284
+ });
285
+
286
+ expect(wrapper.html()).toMatchSnapshot();
243
287
  });
244
288
  });
@@ -178,7 +178,7 @@ const showAdditionalActionButtons = computed(() => isArray(additionalActions));
178
178
  align-items: center;
179
179
  }
180
180
 
181
- .show-configuration, &:deep() .actions button {
181
+ .show-configuration, &:deep() .actions > button {
182
182
  margin-left: 16px;
183
183
  }
184
184
 
@@ -0,0 +1,9 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`component: ViewOptions/index should match the snapshot 1`] = `
4
+ <div class="btn-group"><button data-testid="button-group-child-0" type="button" class="btn bg-primary" role="button" aria-label="%resourceDetail.masthead.detail%" aria-pressed="true">
5
+ <!--v-if--><span k="resourceDetail.masthead.detail"></span>
6
+ </button><button data-testid="button-group-child-1" type="button" class="btn bg-disabled" role="button" aria-label="%resourceDetail.masthead.graph%" aria-pressed="false">
7
+ <!--v-if--><span k="resourceDetail.masthead.graph"></span>
8
+ </button></div>
9
+ `;
@@ -0,0 +1,62 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import ViewOptions from '@shell/components/Resource/Detail/ViewOptions/index.vue';
3
+ import ButtonGroup from '@shell/components/ButtonGroup.vue';
4
+ import { _CONFIG, _GRAPH } from '@shell/config/query-params';
5
+
6
+ const mockPush = jest.fn();
7
+ const mockQuery = { view: _CONFIG };
8
+
9
+ jest.mock('vue-router', () => ({
10
+ useRouter: () => ({ push: mockPush }),
11
+ useRoute: () => ({ query: mockQuery }),
12
+ }));
13
+
14
+ describe('component: ViewOptions/index', () => {
15
+ beforeEach(() => {
16
+ jest.clearAllMocks();
17
+ mockQuery.view = _CONFIG;
18
+ });
19
+
20
+ const createWrapper = () => {
21
+ return mount(ViewOptions);
22
+ };
23
+
24
+ it('should render a ButtonGroup component', () => {
25
+ const wrapper = createWrapper();
26
+
27
+ expect(wrapper.findComponent(ButtonGroup).exists()).toBeTruthy();
28
+ });
29
+
30
+ it('should provide two view options: detail and graph', () => {
31
+ const wrapper = createWrapper();
32
+ const buttonGroup = wrapper.findComponent(ButtonGroup);
33
+ const options = buttonGroup.props('options');
34
+
35
+ expect(options).toHaveLength(2);
36
+ expect(options[0].value).toStrictEqual(_CONFIG);
37
+ expect(options[1].value).toStrictEqual(_GRAPH);
38
+ });
39
+
40
+ it('should set the initial view from the route query', () => {
41
+ const wrapper = createWrapper();
42
+ const buttonGroup = wrapper.findComponent(ButtonGroup);
43
+
44
+ expect(buttonGroup.props('value')).toStrictEqual(_CONFIG);
45
+ });
46
+
47
+ it('should push to router when view changes', async() => {
48
+ const wrapper = createWrapper();
49
+ const buttons = wrapper.findAll('.btn-group button');
50
+ const graphButton = buttons[1];
51
+
52
+ await graphButton.trigger('click');
53
+
54
+ expect(mockPush).toHaveBeenCalledWith({ query: { view: _GRAPH } });
55
+ });
56
+
57
+ it('should match the snapshot', () => {
58
+ const wrapper = createWrapper();
59
+
60
+ expect(wrapper.html()).toMatchSnapshot();
61
+ });
62
+ });
@@ -14,7 +14,8 @@ const view = ref(currentView.value);
14
14
  const viewOptions = computed(() => {
15
15
  return [
16
16
  {
17
- labelKey: 'resourceDetail.masthead.config',
17
+ labelKey: 'resourceDetail.masthead.detail',
18
+ // _CONFIG is the default when there is no query on the router
18
19
  value: _CONFIG,
19
20
  },
20
21
  {
@@ -85,7 +85,29 @@ export default {
85
85
  data() {
86
86
  const params = { ...this.$route.params };
87
87
 
88
- const formRoute = { name: `${ this.$route.name }-create`, params };
88
+ // Determine if the current product has a topLevelProduct defined, and if so,
89
+ // use that for the formRoute instead of the current route's product.
90
+ // This allows resources from extensions (new product registration) to use the correct route for creation,
91
+ // which may be different from the route of the resource list.
92
+ let currPluginName = '';
93
+ let formRoute;
94
+ let overrideCreateLocationByExtension = false;
95
+ const plugins = this.$extension.getPlugins();
96
+
97
+ Object.keys(plugins).forEach((key) => {
98
+ if (plugins[key].productNames.includes(this.$store.getters['productId'])) {
99
+ currPluginName = key;
100
+ }
101
+ });
102
+
103
+ if (currPluginName && plugins[currPluginName]?.topLevelProduct) {
104
+ // override create route for extension resource lists
105
+ formRoute = { name: `${ this.$route.name }-create`, params: { ...params, product: this.$store.getters['productId'] } };
106
+ overrideCreateLocationByExtension = true;
107
+ } else {
108
+ // this was the original logic before the topLevelProduct override was added
109
+ formRoute = { name: `${ this.$route.name }-create`, params };
110
+ }
89
111
 
90
112
  const hasEditComponent = this.$store.getters['type-map/hasCustomEdit'](this.resource);
91
113
 
@@ -96,6 +118,7 @@ export default {
96
118
  };
97
119
 
98
120
  return {
121
+ overrideCreateLocationByExtension,
99
122
  formRoute,
100
123
  yamlRoute,
101
124
  hasEditComponent,
@@ -149,7 +172,7 @@ export default {
149
172
  },
150
173
 
151
174
  _createLocation() {
152
- return this.createLocation || this.formRoute;
175
+ return this.overrideCreateLocationByExtension ? this.formRoute : this.createLocation || this.formRoute;
153
176
  },
154
177
 
155
178
  _yamlCreateLocation() {
@@ -229,6 +229,19 @@ export default {
229
229
 
230
230
  this.getExplorerGroups(out);
231
231
 
232
+ // If there's a root group, pull its children up to the top level
233
+ // so that we can order them alongside group items in the nav
234
+ const rootGroupIndex = out.findIndex((g) => g.name.toLowerCase() === 'root');
235
+ const rootGroup = out[rootGroupIndex];
236
+
237
+ if (rootGroup && rootGroup.children?.length) {
238
+ out.splice(rootGroupIndex, 1);
239
+
240
+ rootGroup.children.forEach((child) => {
241
+ addObject(out, { ...child, children: [] });
242
+ });
243
+ }
244
+
232
245
  replaceWith(this.groups, ...sortBy(out, ['weight:desc', 'label']));
233
246
 
234
247
  this.gettingGroups = false;
@@ -76,6 +76,12 @@ export default {
76
76
  type: Boolean,
77
77
  default: true,
78
78
  },
79
+
80
+ extensionParams: {
81
+ type: Object,
82
+ default: null,
83
+ },
84
+
79
85
  /**
80
86
  * Inherited global identifier prefix for tests
81
87
  * Define a term based on the parent component to avoid conflicts on multiple components
@@ -64,6 +64,37 @@ describe('component: ConsumptionGauge', () => {
64
64
  expect(slotTitle.text()).toBe('some-resource-name');
65
65
  });
66
66
 
67
+ it('should display the default "Used" label when usedLabel is not provided', () => {
68
+ const wrapper = mount(ConsumptionGauge, {
69
+ props: {
70
+ resourceName: 'some-resource-name',
71
+ capacity: 100,
72
+ used: 50,
73
+ }
74
+ });
75
+
76
+ const usedSpan = wrapper.find('.consumption-gauge .numbers span:nth-child(1)');
77
+
78
+ expect(usedSpan.exists()).toBe(true);
79
+ expect(usedSpan.text()).toBe('%node.detail.glance.consumptionGauge.used%');
80
+ });
81
+
82
+ it('usedLabel should override the default "Used" label text', () => {
83
+ const wrapper = mount(ConsumptionGauge, {
84
+ props: {
85
+ resourceName: 'some-resource-name',
86
+ capacity: 100,
87
+ used: 50,
88
+ usedLabel: 'Running'
89
+ }
90
+ });
91
+
92
+ const usedSpan = wrapper.find('.consumption-gauge .numbers span:nth-child(1)');
93
+
94
+ expect(usedSpan.exists()).toBe(true);
95
+ expect(usedSpan.text()).toBe('Running');
96
+ });
97
+
67
98
  it('passing slot TITLE should render correctly', () => {
68
99
  const colorStops = {
69
100
  0: '--success', 30: '--warning', 70: '--error'
@@ -24,6 +24,7 @@ import DeveloperLoadExtensionDialog from '@shell/dialog/DeveloperLoadExtensionDi
24
24
  import AddExtensionReposDialog from '@shell/dialog/AddExtensionReposDialog.vue';
25
25
  import InstallExtensionDialog from '@shell/dialog/InstallExtensionDialog.vue';
26
26
  import UninstallExtensionDialog from '@shell/dialog/UninstallExtensionDialog.vue';
27
+ import UninstallExistingExtensionDialog from '@shell/dialog/UninstallExistingExtensionDialog.vue';
27
28
  import KnownHostsEditDialog from '@shell/dialog/KnownHostsEditDialog.vue';
28
29
  import ImportDialog from '@shell/dialog/ImportDialog.vue';
29
30
  import SearchDialog from '@shell/dialog/SearchDialog.vue';
@@ -110,6 +111,7 @@ describe('component: PromptModal', () => {
110
111
  ['AddExtensionReposDialog', AddExtensionReposDialog],
111
112
  ['InstallExtensionDialog', InstallExtensionDialog],
112
113
  ['UninstallExtensionDialog', UninstallExtensionDialog],
114
+ ['UninstallExistingExtensionDialog', UninstallExistingExtensionDialog],
113
115
  ['KnownHostsEditDialog', KnownHostsEditDialog],
114
116
  ['ImportDialog', ImportDialog],
115
117
  ['SearchDialog', SearchDialog],
@@ -107,6 +107,7 @@ export default {
107
107
  :schema="schema"
108
108
  :headers="headers"
109
109
  :rows="rows"
110
+ :sub-rows="true"
110
111
  :loading="loading"
111
112
  :use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering"
112
113
  :ignore-filter="ignoreFilter"
@@ -573,4 +573,75 @@ describe('component: FleetClusters', () => {
573
573
  expect(additionalSubRow.exists()).toBe(false);
574
574
  });
575
575
  });
576
+
577
+ describe('labels visibility regardless of error state', () => {
578
+ it('should pass sub-rows prop as true to ResourceTable so labels always render', () => {
579
+ const wrapper = createWrapper();
580
+
581
+ // sub-rows=true ensures SortableTable.showSubRow() returns true,
582
+ // which makes the #additional-sub-row slot render regardless of stateDescription.
583
+ // Without this, labels only appear when there is an error (stateDescription).
584
+ const resourceTableStub = wrapper.findComponent('.resource-table') as any;
585
+
586
+ expect(resourceTableStub.props('subRows')).toBe(true);
587
+ });
588
+
589
+ it('should render labels when cluster has no stateDescription (no error)', () => {
590
+ const rows = [{
591
+ customLabels: ['env:prod', 'team:backend'],
592
+ displayCustomLabels: false,
593
+ stateDescription: undefined,
594
+ }];
595
+
596
+ const wrapper = createWrapper({ rows });
597
+ const tags = wrapper.findAll('.tag');
598
+
599
+ expect(tags).toHaveLength(2);
600
+ expect(tags[0].text()).toBe('env:prod');
601
+ expect(tags[1].text()).toBe('team:backend');
602
+ });
603
+
604
+ it('should render labels when cluster has a stateDescription (error)', () => {
605
+ const rows = [{
606
+ customLabels: ['env:prod', 'team:backend'],
607
+ displayCustomLabels: false,
608
+ stateDescription: 'Something went wrong',
609
+ }];
610
+
611
+ const wrapper = createWrapper({ rows });
612
+ const tags = wrapper.findAll('.tag');
613
+
614
+ expect(tags).toHaveLength(2);
615
+ expect(tags[0].text()).toBe('env:prod');
616
+ expect(tags[1].text()).toBe('team:backend');
617
+ });
618
+
619
+ it('should render labels when stateDescription is empty string', () => {
620
+ const rows = [{
621
+ customLabels: ['env:staging'],
622
+ displayCustomLabels: false,
623
+ stateDescription: '',
624
+ }];
625
+
626
+ const wrapper = createWrapper({ rows });
627
+ const tags = wrapper.findAll('.tag');
628
+
629
+ expect(tags).toHaveLength(1);
630
+ expect(tags[0].text()).toBe('env:staging');
631
+ });
632
+
633
+ it('should render labels when stateDescription is null', () => {
634
+ const rows = [{
635
+ customLabels: ['region:eu-west'],
636
+ displayCustomLabels: false,
637
+ stateDescription: null,
638
+ }];
639
+
640
+ const wrapper = createWrapper({ rows });
641
+ const tags = wrapper.findAll('.tag');
642
+
643
+ expect(tags).toHaveLength(1);
644
+ expect(tags[0].text()).toBe('region:eu-west');
645
+ });
646
+ });
576
647
  });
@@ -2,6 +2,7 @@
2
2
  import { mapGetters } from 'vuex';
3
3
  import { RadioGroup } from '@components/Form/Radio';
4
4
  import ResourceLabeledSelect from '@shell/components/form/ResourceLabeledSelect.vue';
5
+ import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
5
6
  import NodeAffinity from '@shell/components/form/NodeAffinity.vue';
6
7
  import { HARVESTER_NAME as VIRTUAL } from '@shell/config/features';
7
8
  import { _VIEW } from '@shell/config/query-params';
@@ -18,6 +19,7 @@ const parseNode = (node: string | KubeNode) => typeof node === 'string' ? node :
18
19
  export default {
19
20
  components: {
20
21
  RadioGroup,
22
+ LabeledSelect,
21
23
  ResourceLabeledSelect,
22
24
  NodeAffinity,
23
25
  },
@@ -35,7 +37,7 @@ export default {
35
37
  */
36
38
  nodes: {
37
39
  type: Array,
38
- default: () => []
40
+ default: () => null
39
41
  },
40
42
 
41
43
  mode: {
@@ -218,14 +220,14 @@ export default {
218
220
  handler(nodeSelector) {
219
221
  // Harvester specific code should not live in rancher/dashboard components
220
222
  // This was brought into harvester/dashboard via https://github.com/harvester/dashboard/pull/342
221
- // rancher/dashboard via https://github.com/rancher/dashboard/pull/6310
223
+ // and then rancher/dashboard via https://github.com/rancher/dashboard/pull/6310
222
224
  if (this.isHarvester && nodeSelector?.[HOSTNAME]) {
223
225
  this.selectNode = 'nodeSelector';
224
226
  const nodeName = nodeSelector[HOSTNAME];
225
227
 
226
228
  this.nodeName = nodeName;
227
229
 
228
- const array = this.nodes.map((n) => n.value);
230
+ const array = this.nodes?.map((n) => n.value) || [];
229
231
 
230
232
  if (nodeName && !array.includes(nodeName)) {
231
233
  this.$store.dispatch('growl/error', {
@@ -259,7 +261,19 @@ export default {
259
261
  <template v-if="selectNode === 'nodeSelector'">
260
262
  <div class="row">
261
263
  <div class="col span-6">
264
+ <LabeledSelect
265
+ v-if="nodes"
266
+ v-model:value="nodeName"
267
+ :label="t('workload.scheduling.affinity.nodeName')"
268
+ :options="nodes || []"
269
+ :mode="mode"
270
+ :multiple="false"
271
+ :loading="loading"
272
+ :data-testid="'node-scheduling-nodeSelector'"
273
+ @update:value="update"
274
+ />
262
275
  <ResourceLabeledSelect
276
+ v-else
263
277
  v-model:value="nodeName"
264
278
  :label="t('workload.scheduling.affinity.nodeName')"
265
279
  :resource-type="NODE"