@rancher/shell 3.0.11 → 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 (98) 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 +5 -4
  5. package/assets/translations/zh-hans.yaml +0 -3
  6. package/components/EmptyProductPage.vue +76 -0
  7. package/components/Resource/Detail/CopyToClipboard.vue +1 -2
  8. package/components/Resource/Detail/Metadata/KeyValueRow.vue +9 -3
  9. package/components/Resource/Detail/TitleBar/__tests__/__snapshots__/index.test.ts.snap +31 -0
  10. package/components/Resource/Detail/TitleBar/__tests__/index.test.ts +45 -1
  11. package/components/Resource/Detail/TitleBar/index.vue +1 -1
  12. package/components/Resource/Detail/ViewOptions/__tests__/__snapshots__/index.test.ts.snap +9 -0
  13. package/components/Resource/Detail/ViewOptions/__tests__/index.test.ts +62 -0
  14. package/components/Resource/Detail/ViewOptions/index.vue +2 -1
  15. package/components/ResourceList/Masthead.vue +25 -2
  16. package/components/SideNav.vue +13 -0
  17. package/components/__tests__/PromptModal.test.ts +2 -0
  18. package/components/fleet/FleetClusters.vue +1 -0
  19. package/components/fleet/__tests__/FleetClusters.test.ts +71 -0
  20. package/components/form/NodeScheduling.vue +17 -3
  21. package/components/form/PrivateRegistry.vue +69 -0
  22. package/components/form/__tests__/PrivateRegistry.test.ts +133 -0
  23. package/components/formatter/WorkloadHealthScale.vue +3 -1
  24. package/components/nav/Group.vue +26 -3
  25. package/components/nav/Header.vue +32 -7
  26. package/components/nav/TopLevelMenu.vue +15 -1
  27. package/config/pagination-table-headers.js +8 -1
  28. package/config/product/apps.js +2 -1
  29. package/config/product/auth.js +1 -0
  30. package/config/product/backup.js +1 -0
  31. package/config/product/compliance.js +1 -1
  32. package/config/product/explorer.js +25 -6
  33. package/config/product/fleet.js +1 -0
  34. package/config/product/gatekeeper.js +1 -0
  35. package/config/product/istio.js +1 -0
  36. package/config/product/logging.js +1 -0
  37. package/config/product/longhorn.js +2 -1
  38. package/config/product/manager.js +1 -0
  39. package/config/product/monitoring.js +1 -0
  40. package/config/product/navlinks.js +1 -0
  41. package/config/product/neuvector.js +2 -1
  42. package/config/product/settings.js +1 -0
  43. package/config/product/uiplugins.js +1 -0
  44. package/core/__tests__/plugin-products-helpers.test.ts +454 -0
  45. package/core/__tests__/plugin-products.test.ts +3219 -0
  46. package/core/extension-manager-impl.js +30 -1
  47. package/core/plugin-products-base.ts +375 -0
  48. package/core/plugin-products-extending.ts +44 -0
  49. package/core/plugin-products-helpers.ts +262 -0
  50. package/core/plugin-products-top-level.ts +66 -0
  51. package/core/plugin-products-type-guards.ts +33 -0
  52. package/core/plugin-products.ts +50 -0
  53. package/core/plugin-types.ts +222 -0
  54. package/core/plugin.ts +45 -10
  55. package/core/productDebugger.js +48 -0
  56. package/core/types.ts +95 -11
  57. package/detail/__tests__/__snapshots__/fleet.cattle.io.bundle.test.ts.snap +52 -0
  58. package/detail/__tests__/fleet.cattle.io.bundle.test.ts +171 -0
  59. package/detail/fleet.cattle.io.bundle.vue +21 -34
  60. package/dialog/ExtensionCatalogInstallDialog.vue +1 -1
  61. package/dialog/InstallExtensionDialog.vue +6 -27
  62. package/dialog/UninstallExistingExtensionDialog.vue +141 -0
  63. package/dialog/UninstallExtensionDialog.vue +4 -26
  64. package/dialog/__tests__/UninstallExistingExtensionDialog.test.ts +114 -0
  65. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +1 -0
  66. package/edit/provisioning.cattle.io.cluster/__tests__/Ingress.test.ts +176 -0
  67. package/edit/provisioning.cattle.io.cluster/rke2.vue +4 -1
  68. package/edit/provisioning.cattle.io.cluster/tabs/Basics.vue +6 -0
  69. package/edit/provisioning.cattle.io.cluster/tabs/Ingress.vue +7 -2
  70. package/list/provisioning.cattle.io.cluster.vue +0 -1
  71. package/list/workload.vue +11 -4
  72. package/mixins/resource-fetch.js +12 -3
  73. package/models/pod.js +18 -0
  74. package/models/workload.js +20 -2
  75. package/package.json +1 -2
  76. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +0 -1
  77. package/pages/c/_cluster/settings/brand.vue +4 -4
  78. package/pages/c/_cluster/uiplugins/__tests__/index.test.ts +231 -13
  79. package/pages/c/_cluster/uiplugins/index.vue +143 -37
  80. package/plugins/dashboard-store/__tests__/resource-class.test.ts +1 -0
  81. package/plugins/dashboard-store/actions.js +3 -2
  82. package/plugins/dashboard-store/resource-class.js +62 -6
  83. package/plugins/plugin.js +16 -0
  84. package/plugins/steve/steve-pagination-utils.ts +7 -0
  85. package/scripts/typegen.sh +13 -1
  86. package/store/__tests__/type-map.test.ts +84 -24
  87. package/store/type-map.js +42 -3
  88. package/tsconfig.paths.json +1 -0
  89. package/types/resources/pod.ts +18 -0
  90. package/types/shell/index.d.ts +8506 -2909
  91. package/types/store/dashboard-store.types.ts +5 -0
  92. package/types/store/pagination.types.ts +6 -0
  93. package/utils/axios.js +1 -4
  94. package/utils/dynamic-importer.js +3 -2
  95. package/utils/pagination-utils.ts +1 -1
  96. package/utils/uiplugins.ts +12 -16
  97. package/utils/validators/__tests__/private-registry.test.ts +76 -0
  98. package/utils/validators/private-registry.ts +28 -0
@@ -302,7 +302,7 @@ export default {
302
302
  });
303
303
 
304
304
  if (this.extensionSvc) {
305
- this.extensionUrl = `http://${ this.extensionSvc.spec.clusterIP }:${ this.extensionSvc.spec.ports[0].port }`;
305
+ this.extensionUrl = `http://${ this.extensionSvc.metadata.name }.${ this.extensionSvc.metadata.namespace }.svc:${ this.extensionSvc.spec.ports[0].port }`;
306
306
  } else {
307
307
  throw new Error('Error fetching extension service');
308
308
  }
@@ -223,7 +223,7 @@ export default {
223
223
 
224
224
  const plugin = this.plugin;
225
225
 
226
- this.updateStatus(plugin.name, this.action);
226
+ this.updateStatus(plugin.id, this.action);
227
227
 
228
228
  // Find the version that the user wants to install
229
229
  const version = plugin.versions?.find((v) => v.version === this.version);
@@ -370,31 +370,21 @@ export default {
370
370
  </template>
371
371
 
372
372
  <style lang="scss" scoped>
373
- .plugin-install-dialog {
374
- padding: 10px;
373
+ @import '@shell/assets/styles/base/_mixins.scss';
375
374
 
376
- h4 {
377
- font-weight: bold;
378
- }
375
+ .plugin-install-dialog {
376
+ @include extension-dialog;
379
377
 
380
378
  .dialog-panel {
381
- display: flex;
382
- flex-direction: column;
383
- min-height: 100px;
384
-
385
379
  p {
386
- margin-bottom: 5px;
387
- }
388
-
389
- .dialog-info {
390
- flex: 1;
380
+ margin-bottom: 4px;
391
381
  }
392
382
 
393
383
  .toggle-advanced {
394
384
  display: flex;
395
385
  align-items: center;
396
386
  cursor: pointer;
397
- margin: 10px 0;
387
+ margin: 8px 0;
398
388
 
399
389
  &:hover {
400
390
  text-decoration: none;
@@ -403,19 +393,8 @@ export default {
403
393
  }
404
394
 
405
395
  .version-selector {
406
- margin: 0 10px 10px 10px;
407
396
  width: auto;
408
397
  }
409
398
  }
410
-
411
- .dialog-buttons {
412
- display: flex;
413
- justify-content: flex-end;
414
- margin-top: 10px;
415
-
416
- > *:not(:last-child) {
417
- margin-right: 10px;
418
- }
419
- }
420
399
  }
421
400
  </style>
@@ -0,0 +1,141 @@
1
+ <script>
2
+ import AsyncButton from '@shell/components/AsyncButton';
3
+ import { CATALOG } from '@shell/config/types';
4
+ import { UI_PLUGIN_NAMESPACE } from '@shell/config/uiplugins';
5
+
6
+ /**
7
+ * Dialog shown when user tries to install an extension that is already installed from a different source.
8
+ * Prompts the user to uninstall the existing version first before installing from the new source.
9
+ */
10
+ export default {
11
+ emits: ['close'],
12
+
13
+ components: { AsyncButton },
14
+
15
+ props: {
16
+ /**
17
+ * The installed plugin that needs to be uninstalled
18
+ */
19
+ installedPlugin: {
20
+ type: Object,
21
+ default: () => {},
22
+ required: true
23
+ },
24
+ /**
25
+ * Callback to update install status on extensions main screen
26
+ */
27
+ updateStatus: {
28
+ type: Function,
29
+ default: () => {},
30
+ required: true
31
+ },
32
+ /**
33
+ * Callback when modal is closed
34
+ */
35
+ closed: {
36
+ type: Function,
37
+ default: () => {},
38
+ required: true
39
+ }
40
+ },
41
+
42
+ data() {
43
+ return { busy: false };
44
+ },
45
+
46
+ methods: {
47
+ closeDialog(result) {
48
+ this.closed(result);
49
+ this.$emit('close');
50
+ },
51
+ async uninstall() {
52
+ this.busy = true;
53
+
54
+ const plugin = this.installedPlugin;
55
+
56
+ this.updateStatus(plugin.id, 'uninstall');
57
+
58
+ // Delete the CR if this is a developer plugin (there is no Helm App, so need to remove the CRD ourselves)
59
+ if (plugin.uiplugin?.isDeveloper) {
60
+ // Delete the custom resource
61
+ await plugin.uiplugin.remove();
62
+ }
63
+
64
+ // Find the app for this plugin using direct lookup (more efficient than findAll)
65
+ let pluginApp = null;
66
+
67
+ try {
68
+ const appId = `${ UI_PLUGIN_NAMESPACE }/${ plugin.name }`;
69
+
70
+ pluginApp = await this.$store.dispatch('management/find', {
71
+ type: CATALOG.APP,
72
+ id: appId
73
+ });
74
+ } catch (e) {
75
+ // If the app cannot be found (e.g. already removed), proceed without error
76
+ pluginApp = null;
77
+ }
78
+
79
+ if (pluginApp) {
80
+ try {
81
+ await pluginApp.remove();
82
+ } catch (e) {
83
+ this.$store.dispatch('growl/error', {
84
+ title: this.t('plugins.error.generic'),
85
+ message: e.message ? e.message : e,
86
+ timeout: 10000
87
+ }, { root: true });
88
+
89
+ this.busy = false;
90
+
91
+ return;
92
+ }
93
+
94
+ await this.$store.dispatch('management/findAll', { type: CATALOG.OPERATION });
95
+ }
96
+
97
+ // Close the dialog
98
+ this.closeDialog({ uninstalled: true, plugin });
99
+ }
100
+ }
101
+ };
102
+ </script>
103
+
104
+ <template>
105
+ <div class="plugin-install-dialog">
106
+ <h4 class="mt-10">
107
+ {{ t('plugins.install.alreadyInstalledTitle') }}
108
+ </h4>
109
+ <div class="mt-10 dialog-panel">
110
+ <div class="dialog-info">
111
+ <p>
112
+ {{ t('plugins.install.alreadyInstalledPrompt') }}
113
+ </p>
114
+ </div>
115
+ <div class="dialog-buttons">
116
+ <button
117
+ :disabled="busy"
118
+ class="btn role-secondary"
119
+ data-testid="uninstall-existing-ext-modal-cancel-btn"
120
+ @click="closeDialog(false)"
121
+ >
122
+ {{ t('generic.cancel') }}
123
+ </button>
124
+ <AsyncButton
125
+ mode="uninstall"
126
+ :action-label="t('plugins.install.uninstallExisting')"
127
+ data-testid="uninstall-existing-ext-modal-uninstall-btn"
128
+ @click="uninstall()"
129
+ />
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </template>
134
+
135
+ <style lang="scss" scoped>
136
+ @import '@shell/assets/styles/base/_mixins.scss';
137
+
138
+ .plugin-install-dialog {
139
+ @include extension-dialog;
140
+ }
141
+ </style>
@@ -65,7 +65,7 @@ export default {
65
65
 
66
66
  const plugin = this.plugin;
67
67
 
68
- this.updateStatus(plugin.name, 'uninstall');
68
+ this.updateStatus(plugin.id, 'uninstall');
69
69
 
70
70
  // Delete the CR if this is a developer plugin (there is no Helm App, so need to remove the CRD ourselves)
71
71
  if (plugin.uiplugin?.isDeveloper) {
@@ -132,31 +132,9 @@ export default {
132
132
  </template>
133
133
 
134
134
  <style lang="scss" scoped>
135
- .plugin-install-dialog {
136
- padding: 10px;
137
-
138
- h4 {
139
- font-weight: bold;
140
- }
135
+ @import '@shell/assets/styles/base/_mixins.scss';
141
136
 
142
- .dialog-panel {
143
- display: flex;
144
- flex-direction: column;
145
- min-height: 100px;
146
-
147
- .dialog-info {
148
- flex: 1;
149
- }
150
- }
151
-
152
- .dialog-buttons {
153
- display: flex;
154
- justify-content: flex-end;
155
- margin-top: 10px;
156
-
157
- > *:not(:last-child) {
158
- margin-right: 10px;
159
- }
160
- }
137
+ .plugin-install-dialog {
138
+ @include extension-dialog;
161
139
  }
162
140
  </style>
@@ -0,0 +1,114 @@
1
+ import { shallowMount, VueWrapper } from '@vue/test-utils';
2
+ import UninstallExistingExtensionDialog from '@shell/dialog/UninstallExistingExtensionDialog.vue';
3
+
4
+ const t = (key: string): string => key;
5
+
6
+ describe('component: UninstallExistingExtensionDialog', () => {
7
+ let wrapper: VueWrapper<any>;
8
+
9
+ const mountComponent = (propsData = {}) => {
10
+ const store = { dispatch: jest.fn().mockResolvedValue([]) };
11
+
12
+ const defaultProps = {
13
+ installedPlugin: {
14
+ id: 'test-plugin', name: 'test-plugin', label: 'Test Plugin'
15
+ },
16
+ updateStatus: jest.fn(),
17
+ closed: jest.fn(),
18
+ };
19
+
20
+ return shallowMount(UninstallExistingExtensionDialog, {
21
+ propsData: {
22
+ ...defaultProps,
23
+ ...propsData,
24
+ },
25
+ global: {
26
+ mocks: {
27
+ $store: store,
28
+ $router: { go: jest.fn() },
29
+ t,
30
+ },
31
+ }
32
+ });
33
+ };
34
+
35
+ describe('rendering', () => {
36
+ it('should render the dialog title', () => {
37
+ wrapper = mountComponent();
38
+
39
+ const title = wrapper.find('h4');
40
+
41
+ expect(title.text()).toBe('plugins.install.alreadyInstalledTitle');
42
+ });
43
+
44
+ it('should render the dialog prompt', () => {
45
+ wrapper = mountComponent();
46
+
47
+ const prompt = wrapper.find('.dialog-info p');
48
+
49
+ expect(prompt.text()).toBe('plugins.install.alreadyInstalledPrompt');
50
+ });
51
+
52
+ it('should render cancel button', () => {
53
+ wrapper = mountComponent();
54
+
55
+ const cancelBtn = wrapper.find('[data-testid="uninstall-existing-ext-modal-cancel-btn"]');
56
+
57
+ expect(cancelBtn.exists()).toBe(true);
58
+ });
59
+
60
+ it('should render uninstall button', () => {
61
+ wrapper = mountComponent();
62
+
63
+ const uninstallBtn = wrapper.find('[data-testid="uninstall-existing-ext-modal-uninstall-btn"]');
64
+
65
+ expect(uninstallBtn.exists()).toBe(true);
66
+ });
67
+ });
68
+
69
+ describe('closeDialog', () => {
70
+ it('should call closed callback and emit close event when cancel is clicked', async() => {
71
+ const closedFn = jest.fn();
72
+
73
+ wrapper = mountComponent({ closed: closedFn });
74
+
75
+ const cancelBtn = wrapper.find('[data-testid="uninstall-existing-ext-modal-cancel-btn"]');
76
+
77
+ await cancelBtn.trigger('click');
78
+
79
+ expect(closedFn).toHaveBeenCalledWith(false);
80
+ expect(wrapper.emitted('close')).toBeTruthy();
81
+ });
82
+ });
83
+
84
+ describe('uninstall', () => {
85
+ it('should call updateStatus with uninstall action', async() => {
86
+ const updateStatusFn = jest.fn();
87
+ const installedPlugin = {
88
+ id: 'test-plugin', name: 'test-plugin', label: 'Test Plugin'
89
+ };
90
+
91
+ wrapper = mountComponent({ installedPlugin, updateStatus: updateStatusFn });
92
+
93
+ await wrapper.vm.uninstall();
94
+
95
+ expect(updateStatusFn).toHaveBeenCalledWith('test-plugin', 'uninstall');
96
+ });
97
+
98
+ it('should remove developer plugin CR if isDeveloper is true', async() => {
99
+ const removeFn = jest.fn().mockResolvedValue(undefined);
100
+ const installedPlugin = {
101
+ id: 'test-plugin',
102
+ name: 'test-plugin',
103
+ label: 'Test Plugin',
104
+ uiplugin: { isDeveloper: true, remove: removeFn }
105
+ };
106
+
107
+ wrapper = mountComponent({ installedPlugin });
108
+
109
+ await wrapper.vm.uninstall();
110
+
111
+ expect(removeFn).toHaveBeenCalledWith();
112
+ });
113
+ });
114
+ });
@@ -70,6 +70,7 @@ const initGitRepo = (props: any, value?: any) => {
70
70
  }, {
71
71
  getters: { schemaFor: () => ({ linkFor: jest.fn() }) },
72
72
  dispatch: jest.fn(),
73
+ rootState: { $extension: { getPlugins: () => ({}) } },
73
74
  rootGetters: { 'i18n/t': jest.fn() },
74
75
  });
75
76
 
@@ -0,0 +1,176 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import Ingress from '@shell/edit/provisioning.cattle.io.cluster/tabs/Ingress.vue';
3
+ import { _EDIT } from '@shell/config/query-params';
4
+ import { INGRESS_DUAL, TRAEFIK, INGRESS_NGINX, INGRESS_NONE } from '@shell/edit/provisioning.cattle.io.cluster/shared';
5
+
6
+ jest.mock('vuex', () => ({
7
+ useStore: () => ({ getters: { 'i18n/t': (key: string) => key } }),
8
+ mapGetters: () => ({ t: (key: string) => key })
9
+ }));
10
+
11
+ jest.mock('@shell/edit/provisioning.cattle.io.cluster/shared', () => ({
12
+
13
+ INGRESS_NGINX: 'ingress-nginx',
14
+ TRAEFIK: 'traefik',
15
+ INGRESS_DUAL: 'dual',
16
+ INGRESS_NONE: 'none',
17
+ INGRESS_OPTIONS: [{
18
+ id: 'traefik',
19
+ image: { src: '', alt: 'Traefik' },
20
+ header: { title: { key: 'cluster.ingress.traefik.header' } },
21
+ subHeader: { label: { key: 'cluster.ingress.recommended' } },
22
+ content: { key: 'cluster.ingress.traefik.content' },
23
+ doc: { url: 'https://docs.rke2.io/networking/networking_services?_highlight=ingress#ingress-controller' }
24
+ },
25
+ {
26
+ id: 'ingress-nginx',
27
+ image: { src: '', alt: 'NGINX' },
28
+ header: { title: { key: 'cluster.ingress.nginx.header' } },
29
+ subHeader: { label: { key: 'cluster.ingress.legacy' } },
30
+ content: { key: 'cluster.ingress.nginx.content' },
31
+ doc: { url: 'https://www.kubernetes.dev/blog/2025/11/12/ingress-nginx-retirement/' }
32
+ },
33
+ {
34
+ id: 'dual',
35
+ header: { title: { key: 'cluster.ingress.dual.header' } },
36
+ subHeader: { label: { key: 'cluster.ingress.migration' } },
37
+ content: { key: 'cluster.ingress.dual.content' }
38
+ }],
39
+ INGRESS_MIGRATION_KB_LINK: 'mock-link',
40
+ INGRESS_CONTROLLER_CLASS_MIGRATION: 'rke2.cattle.io/ingress-nginx-migration',
41
+ INGRESS_CLASS_DEFAULT: 'rke2.cattle.io/ingress-nginx-default',
42
+ INGRESS_CONTROLLER_CLASS_DEFAULT: 'rke2.cattle.io/ingress-nginx-controller-default',
43
+ INGRESS_CLASS_MIGRATION: 'rke2.cattle.io/ingress-nginx-migration'
44
+ }));
45
+
46
+ describe('ingress.vue', () => {
47
+ const defaultProps = {
48
+ mode: _EDIT,
49
+ value: INGRESS_NONE,
50
+ nginxSupported: true,
51
+ traefikSupported: true,
52
+ nginxChart: 'rancher-ingress-nginx',
53
+ traefikChart: 'traefik',
54
+ userChartValues: {},
55
+ versionInfo: {
56
+ 'rancher-ingress-nginx': { values: {} },
57
+ traefik: { values: {} }
58
+ }
59
+ };
60
+
61
+ const createWrapper = (props = {}) => mount(Ingress, {
62
+ props: { ...defaultProps, ...props },
63
+ global: {
64
+ stubs: {
65
+ Checkbox: true,
66
+ Banner: true,
67
+ IngressCards: true,
68
+ IngressConfiguration: true,
69
+ YamlEditor: true,
70
+ RichTranslation: true
71
+ }
72
+ }
73
+ });
74
+
75
+ it('renders checkbox to enable/disable ingress', () => {
76
+ const wrapper = createWrapper();
77
+ const checkbox = wrapper.findComponent({ name: 'Checkbox' });
78
+
79
+ expect(checkbox.exists()).toBe(true);
80
+ });
81
+
82
+ it('emits update:value with INGRESS_NONE when ingress is disabled', async() => {
83
+ const wrapper = createWrapper({ value: TRAEFIK });
84
+ const checkbox = wrapper.findComponent({ name: 'Checkbox' });
85
+
86
+ await checkbox.vm.$emit('update:value', false);
87
+
88
+ expect(wrapper.emitted('update:value')).toBeTruthy();
89
+ expect(wrapper.emitted('update:value')?.[0]).toStrictEqual([INGRESS_NONE]);
90
+ });
91
+
92
+ it('emits update:value with TRAEFIK when ingress is enabled and traefik is supported', async() => {
93
+ const wrapper = createWrapper({ value: INGRESS_NONE });
94
+ const checkbox = wrapper.findComponent({ name: 'Checkbox' });
95
+
96
+ await checkbox.vm.$emit('update:value', true);
97
+
98
+ expect(wrapper.emitted('update:value')).toBeTruthy();
99
+ expect(wrapper.emitted('update:value')?.[0]).toStrictEqual([TRAEFIK]);
100
+ });
101
+
102
+ it('emits update:value with INGRESS_NGINX when ingress is enabled, traefik is NOT supported, and nginx IS supported', async() => {
103
+ const wrapper = createWrapper({ value: INGRESS_NONE, traefikSupported: false });
104
+ const checkbox = wrapper.findComponent({ name: 'Checkbox' });
105
+
106
+ await checkbox.vm.$emit('update:value', true);
107
+
108
+ expect(wrapper.emitted('update:value')).toBeTruthy();
109
+ expect(wrapper.emitted('update:value')?.[0]).toStrictEqual([INGRESS_NGINX]);
110
+ });
111
+
112
+ it('selectIngress emits [INGRESS_NGINX, TRAEFIK] string value when INGRESS_DUAL is selected and previous value was ingress-nginx', () => {
113
+ const wrapper = createWrapper({ value: INGRESS_NGINX, originalIngressController: INGRESS_NGINX });
114
+ const ingressCards = wrapper.findComponent({ name: 'IngressCards' });
115
+
116
+ ingressCards.vm.$emit('select', INGRESS_DUAL);
117
+
118
+ expect(wrapper.emitted('update:value')).toBeTruthy();
119
+ expect(wrapper.emitted('update:value')?.[0]).toStrictEqual([[INGRESS_NGINX, TRAEFIK]]);
120
+ });
121
+
122
+ it('selectIngress emits [TRAEFIK, INGRESS_NGINX] string value when INGRESS_DUAL is selected and previous value was traefik', () => {
123
+ const wrapper = createWrapper({ value: TRAEFIK, originalIngressController: TRAEFIK });
124
+ const ingressCards = wrapper.findComponent({ name: 'IngressCards' });
125
+
126
+ ingressCards.vm.$emit('select', INGRESS_DUAL);
127
+
128
+ expect(wrapper.emitted('update:value')).toBeTruthy();
129
+ expect(wrapper.emitted('update:value')?.[0]).toStrictEqual([[TRAEFIK, INGRESS_NGINX]]);
130
+ });
131
+
132
+ it('selectIngress emits [TRAEFIK, INGRESS_NGINX] string value when INGRESS_DUAL is selected and value went traefik -> nginx -> dual', () => {
133
+ const wrapper = createWrapper({ value: TRAEFIK, originalIngressController: TRAEFIK });
134
+ const ingressCards = wrapper.findComponent({ name: 'IngressCards' });
135
+
136
+ ingressCards.vm.$emit('select', INGRESS_NGINX);
137
+ expect(wrapper.emitted('update:value')).toBeTruthy();
138
+ expect(wrapper.emitted('update:value')?.[0]).toStrictEqual([INGRESS_NGINX]);
139
+
140
+ ingressCards.vm.$emit('select', INGRESS_DUAL);
141
+
142
+ expect(wrapper.emitted('update:value')).toBeTruthy();
143
+ expect(wrapper.emitted('update:value')?.[1]).toStrictEqual([[TRAEFIK, INGRESS_NGINX]]);
144
+ });
145
+
146
+ it('selectIngress emits string value when a single ingress is selected', () => {
147
+ const wrapper = createWrapper({ value: TRAEFIK });
148
+ const ingressCards = wrapper.findComponent({ name: 'IngressCards' });
149
+
150
+ ingressCards.vm.$emit('select', INGRESS_NGINX);
151
+
152
+ expect(wrapper.emitted('update:value')).toBeTruthy();
153
+ expect(wrapper.emitted('update:value')?.[0]).toStrictEqual([INGRESS_NGINX]);
154
+ });
155
+
156
+ it('renders IngressConfiguration when versionInfo contains chart values', () => {
157
+ const wrapper = createWrapper({ value: TRAEFIK });
158
+ const config = wrapper.findComponent({ name: 'IngressConfiguration' });
159
+
160
+ expect(config.exists()).toBe(true);
161
+ });
162
+
163
+ it('toggles advanced configuration visibility and renders YamlEditor', async() => {
164
+ const wrapper = createWrapper({ value: TRAEFIK });
165
+
166
+ expect(wrapper.findComponent({ name: 'YamlEditor' }).exists()).toBe(false);
167
+
168
+ const advancedButton = wrapper.find('.advanced-toggle');
169
+
170
+ await advancedButton.trigger('click');
171
+
172
+ const yamlEditor = wrapper.find('[data-testid="traefik-yaml-editor"]');
173
+
174
+ expect(yamlEditor.exists()).toBe(true);
175
+ });
176
+ });
@@ -297,7 +297,8 @@ export default {
297
297
  originalKubeVersion: null,
298
298
  isEmpty,
299
299
  AGENT_CONFIGURATION_TYPES,
300
- basicsValid: true
300
+ basicsValid: true,
301
+ originalIngressController: this.value.spec.rkeConfig.machineGlobalConfig?.[INGRESS_CONTROLLER] || INGRESS_NONE,
301
302
  };
302
303
  },
303
304
 
@@ -2522,6 +2523,7 @@ export default {
2522
2523
  <Tab
2523
2524
  v-if="!obj.remove"
2524
2525
  :key="obj.id"
2526
+ :weight="-1 * idx"
2525
2527
  :name="obj.id"
2526
2528
  :label="obj.pool.name || '(Not Named)'"
2527
2529
  :show-header="false"
@@ -2592,6 +2594,7 @@ export default {
2592
2594
  :is-azure-provider-unsupported="isAzureProviderUnsupported"
2593
2595
  :can-azure-migrate-on-edit="canAzureMigrateOnEdit"
2594
2596
  :has-some-ipv6-pools="hasOnlyIpv6Pools"
2597
+ :original-ingress-controller="originalIngressController"
2595
2598
  @update:value="$emit('input', $event)"
2596
2599
  @cilium-values-changed="handleCiliumValuesChanged"
2597
2600
  @enabled-system-services-changed="handleEnabledSystemServicesChanged"
@@ -120,6 +120,11 @@ export default {
120
120
  canAzureMigrateOnEdit: {
121
121
  type: Boolean,
122
122
  required: true
123
+ },
124
+ originalIngressController: {
125
+ type: [String, Array],
126
+ required: false,
127
+ default: INGRESS_NONE
123
128
  }
124
129
  },
125
130
 
@@ -699,6 +704,7 @@ export default {
699
704
  :traefik-chart="traefikChart"
700
705
  :user-chart-values="userChartValues"
701
706
  :version-info="versionInfo"
707
+ :original-ingress-controller="originalIngressController"
702
708
  @update-values="(name, val) => $emit('update-values', name, val)"
703
709
  @error="$emit('error', $event)"
704
710
  @yaml-validation-changed="e => $emit('yaml-validation-changed', e)"
@@ -24,6 +24,7 @@ interface Props {
24
24
  traefikChart: string;
25
25
  userChartValues: any;
26
26
  versionInfo: any;
27
+ originalIngressController?: string | string[];
27
28
  }
28
29
  const {
29
30
  mode = _CREATE,
@@ -33,7 +34,8 @@ const {
33
34
  nginxSupported,
34
35
  traefikSupported,
35
36
  userChartValues,
36
- versionInfo
37
+ versionInfo,
38
+ originalIngressController = INGRESS_NONE
37
39
  } = defineProps<Props>();
38
40
 
39
41
  const emit = defineEmits(['update:value', 'error', 'config-validation-changed', 'yaml-validation-changed', 'update-values']);
@@ -179,7 +181,10 @@ const compatibilityMode = computed({
179
181
 
180
182
  function selectIngress(id: string) {
181
183
  if ( id === INGRESS_DUAL) {
182
- emit('update:value', [TRAEFIK, INGRESS_NGINX]);
184
+ const newValue: string | string[] = !Array.isArray(originalIngressController) ? (originalIngressController === TRAEFIK ? [TRAEFIK, INGRESS_NGINX] : [INGRESS_NGINX, TRAEFIK]) : originalIngressController;
185
+
186
+ emit('update:value', newValue);
187
+
183
188
  preconfigureTraefik();
184
189
  } else {
185
190
  emit('update:value', id);
@@ -220,7 +220,6 @@ export default {
220
220
  :use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering"
221
221
  :data-testid="'cluster-list'"
222
222
  :force-update-live-and-delayed="forceUpdateLiveAndDelayed"
223
- :sub-rows="true"
224
223
  >
225
224
  <!-- Why are state column and subrow overwritten here? -->
226
225
  <!-- for rke1 clusters, where they try to use the mgmt cluster stateObj instead of prov cluster stateObj, -->
package/list/workload.vue CHANGED
@@ -99,13 +99,21 @@ export default {
99
99
  const schema = type !== workloadSchema.id ? this.$store.getters['cluster/schemaFor'](type) : workloadSchema;
100
100
  const paginationEnabled = !allTypes && this.$store.getters[`cluster/paginationEnabled`]?.({ id: type });
101
101
 
102
+ const workloadIncludeAssociatedData = paginationEnabled && [
103
+ WORKLOAD_TYPES.DEPLOYMENT,
104
+ WORKLOAD_TYPES.DAEMON_SET,
105
+ WORKLOAD_TYPES.STATEFUL_SET,
106
+ WORKLOAD_TYPES.JOB,
107
+ ].includes(type);
108
+
102
109
  return {
103
110
  allTypes,
104
111
  schema,
105
112
  paginationEnabled,
106
113
  resources: [],
107
114
  loadResources,
108
- loadIndeterminate
115
+ loadIndeterminate,
116
+ workloadIncludeAssociatedData
109
117
  };
110
118
  },
111
119
 
@@ -143,10 +151,8 @@ export default {
143
151
  * Fetch resources required to populate POD_RESTARTS and WORKLOAD_HEALTH_SCALE columns
144
152
  */
145
153
  loadHeathResources() {
146
- // See https://github.com/rancher/dashboard/issues/10417, health comes from selectors applied locally to all pods (bad)
147
154
  if (this.paginationEnabled) {
148
- // Unfortunately with SSP enabled we cannot fetch all pods to then let each row find applicable pods by locally applied selectors (bad for scaling)
149
- // See https://github.com/rancher/dashboard/issues/14211
155
+ // When SSP is enabled we efficiently fetch stats for health column imbedded in the original resource type by supplying `includeAssociatedData` param
150
156
  return;
151
157
  }
152
158
 
@@ -184,6 +190,7 @@ export default {
184
190
  v-if="paginationEnabled"
185
191
  :schema="schema"
186
192
  :use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering"
193
+ :includeAssociatedData="workloadIncludeAssociatedData"
187
194
  />
188
195
  <ResourceTable
189
196
  v-else