@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
@@ -124,7 +124,9 @@ export const createExtensionManager = (context) => {
124
124
  } catch (e) {
125
125
  delete plugins[id];
126
126
 
127
- return reject(new Error('Could not initialize plugin'));
127
+ console.error(`Could not initialize plugin ${ id }`, e); // eslint-disable-line no-console
128
+
129
+ return reject(new Error(`Could not initialize plugin ${ id } - ${ e?.message }`));
128
130
  }
129
131
 
130
132
  // Load all of the types etc from the plugin
@@ -405,6 +407,11 @@ export const createExtensionManager = (context) => {
405
407
  }
406
408
  },
407
409
 
410
+ // Internal use only
411
+ _add(id, plugin) {
412
+ plugins[id] = plugin;
413
+ },
414
+
408
415
  // For debugging
409
416
  getAll() {
410
417
  return dynamic;
@@ -426,7 +433,7 @@ export const createExtensionManager = (context) => {
426
433
  * Return the UI configuration for the given type and location
427
434
  */
428
435
  getUIConfig(type, uiArea) {
429
- return uiConfig[type][uiArea] || [];
436
+ return uiConfig[type]?.[uiArea] || [];
430
437
  },
431
438
 
432
439
  /**
@@ -498,7 +505,11 @@ export const createExtensionManager = (context) => {
498
505
  loadPlugins = Object.values(plugins);
499
506
  }
500
507
 
501
- loadPlugins.forEach((plugin) => {
508
+ // Ensure builtin plugins are processed before external plugins so that
509
+ // core + builtin products are registered first and available for extending
510
+ const orderedPlugins = [...loadPlugins.filter((p) => p.builtin), ...loadPlugins.filter((p) => !p.builtin)];
511
+
512
+ orderedPlugins.forEach((plugin) => {
502
513
  if (plugin.products) {
503
514
  plugin.products.forEach(async(p) => {
504
515
  const impl = await p;
@@ -508,6 +519,26 @@ export const createExtensionManager = (context) => {
508
519
  }
509
520
  });
510
521
  }
522
+
523
+ // Load products and product extensions using the simpler API
524
+ if (plugin.productConfigs?.length) {
525
+ // Add new products first
526
+ plugin.productConfigs.filter((p) => p.newProduct).forEach((productConfig) => {
527
+ productConfig.apply(plugin, store, app.router, pluginRoutes);
528
+ });
529
+
530
+ // Extend existing products after new products are added
531
+ plugin.productConfigs.filter((p) => !p.newProduct).forEach((productConfig) => {
532
+ productConfig.apply(plugin, store, app.router, pluginRoutes);
533
+ });
534
+ }
535
+
536
+ // Apply all type configurations
537
+ if (plugin.resourceTypeConfigs?.length) {
538
+ plugin.resourceTypeConfigs.forEach((resourceTypeConfig) => {
539
+ resourceTypeConfig.apply(plugin, store, app.router, pluginRoutes);
540
+ });
541
+ }
511
542
  });
512
543
  },
513
544
  };
@@ -137,6 +137,30 @@ function checkExtensionRouteBinding($route: any, locationConfig: any, context: a
137
137
  return res;
138
138
  }
139
139
 
140
+ // Track which ExtensionPoint keys are missing from the extension manager's uiConfig.
141
+ // This handles forwards-compatibility when extensions ship a newer shell that defines
142
+ // ExtensionPoint values the running dashboard doesn't know about (e.g. 'Table' on 2.13).
143
+ const _uiConfigPatched: { [point: string]: boolean } = {};
144
+
145
+ function ensureUIConfigCompat(extensionManager: any) {
146
+ const uiConfig = extensionManager.getAllUIConfig?.();
147
+
148
+ if (uiConfig) {
149
+ const missingPoints: { [point: string]: boolean } = {};
150
+
151
+ Object.values(ExtensionPoint).forEach((ep) => {
152
+ if (!uiConfig[ep] && !_uiConfigPatched[ep]) {
153
+ missingPoints[ep] = true;
154
+ _uiConfigPatched[ep] = true;
155
+ }
156
+ });
157
+
158
+ if (Object.keys(missingPoints).length) {
159
+ console.warn(`[plugin-helpers] These ExtensionPoints aren't available for usage in this Rancher version: ${ Object.keys(missingPoints).join(', ') }`); // eslint-disable-line no-console
160
+ }
161
+ }
162
+ }
163
+
140
164
  export function getApplicableExtensionEnhancements<T>(
141
165
  pluginCtx: ComponentOptionsMixin,
142
166
  actionType: ExtensionPoint,
@@ -148,6 +172,13 @@ export function getApplicableExtensionEnhancements<T>(
148
172
 
149
173
  // gate it so that we prevent errors on older versions of dashboard
150
174
  if (pluginCtx.$extension?.getUIConfig) {
175
+ ensureUIConfigCompat(pluginCtx.$extension);
176
+
177
+ // Exit early if actionType doesn't exist in the extension manager's uiConfig
178
+ if (_uiConfigPatched[actionType]) {
179
+ return [];
180
+ }
181
+
151
182
  const actions = pluginCtx.$extension.getUIConfig(actionType, uiArea);
152
183
 
153
184
  actions.forEach((action: any, i: number) => {
@@ -0,0 +1,375 @@
1
+ import { IExtension } from '@shell/core/types';
2
+ import {
3
+ ProductChild, ProductMetadata,
4
+ ConfigureTypeConfiguration, VirtualTypeConfiguration,
5
+ ProductChildCustomPage
6
+ } from '@shell/core/plugin-types';
7
+ import EmptyProductPage from '@shell/components/EmptyProductPage.vue';
8
+ import pluginProductsHelpers from '@shell/core/plugin-products-helpers';
9
+ import {
10
+ isProductChildGroup,
11
+ isProductChildWithComponent,
12
+ isProductChildWithType,
13
+ hasNameProperty,
14
+ hasTypeProperty
15
+ } from '@shell/core/plugin-products-type-guards';
16
+
17
+ /**
18
+ * Base class for product registration in extensions
19
+ * @internal
20
+ */
21
+ export abstract class BasePluginProduct {
22
+ protected name!: string;
23
+
24
+ protected product?: ProductMetadata;
25
+
26
+ protected addedResourceRoutes = false;
27
+
28
+ protected DSLMethods: any;
29
+
30
+ protected config: ProductChild[];
31
+
32
+ constructor(config: ProductChild[]) {
33
+ this.config = config;
34
+ }
35
+
36
+ /**
37
+ * Indicates whether this is a new product or extending an existing one
38
+ */
39
+ abstract get isNewProduct(): boolean;
40
+
41
+ /**
42
+ * Helper to throw errors during product registration
43
+ */
44
+ protected surfaceError(message: string): void {
45
+ throw new Error(`Extensions - product "${ this.name }" registration error ::: ${ message }`);
46
+ }
47
+
48
+ /**
49
+ * Validates and reorders config children by weight
50
+ */
51
+ protected processConfigChildren(): void {
52
+ if (this.config?.length > 0) {
53
+ // consider weights of children to determine default route
54
+ const reorderedChildren = pluginProductsHelpers.gatherChildrenOrdering(this.config);
55
+
56
+ if (reorderedChildren.length === 0) {
57
+ this.surfaceError('No children found for product with config');
58
+ }
59
+
60
+ const firstChild = reorderedChildren[0];
61
+
62
+ if (!hasNameProperty(firstChild) && !hasTypeProperty(firstChild)) {
63
+ this.surfaceError('Invalid child item for product default route - missing name or type');
64
+ }
65
+
66
+ // update config with reordered children
67
+ this.config = reorderedChildren;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * This is where we register the product and its children via the DSL
73
+ */
74
+ apply(plugin: IExtension, store: any): void {
75
+ // store the DSL methods for easier access
76
+ this.DSLMethods = plugin.DSL(store, this.name);
77
+
78
+ const { basicType } = this.DSLMethods;
79
+
80
+ // execute the product registration
81
+ // this.product is NOT set when extending existing standard products
82
+ // this is deliberate as we don't need to re-register existing products
83
+ // we just leverage the DSL to add routes and configure types/virtual types with the correct product context
84
+ if (this.product) {
85
+ this.handleProductRegistration();
86
+ }
87
+
88
+ // Now deal with each config item
89
+ this.config.forEach((item) => {
90
+ // needs to be "true" so that group base pages are registered correctly (when group parent has component)
91
+ const names = this.getIDsForGroupsOrBasicTypes(this.name, this.config, true);
92
+
93
+ basicType(names);
94
+ this.configurePageItem(this.name, item);
95
+
96
+ if (isProductChildGroup(item)) {
97
+ this.processGroupRecursively(item, this.name);
98
+ }
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Recursively processes a group and all its nested children/groups
104
+ */
105
+ protected processGroupRecursively(item: ProductChild, productName: string, parentGroupName?: string, parentHierarchicalPath?: string): void {
106
+ const {
107
+ basicType, labelGroup, setGroupDefaultType, weightGroup
108
+ } = this.DSLMethods;
109
+
110
+ // Type guard to ensure we're working with a group
111
+ if (!isProductChildGroup(item)) {
112
+ return;
113
+ }
114
+
115
+ const itemGroup = item;
116
+ const groupName = parentGroupName ? `${ productName }-${ parentGroupName }-${ itemGroup.name }` : `${ productName }-${ itemGroup.name }`;
117
+
118
+ if (!Array.isArray(itemGroup.children)) {
119
+ this.surfaceError('Children defined for group are not in an array format');
120
+
121
+ return;
122
+ }
123
+
124
+ const navNames = this.getIDsForGroupsOrBasicTypes(groupName, itemGroup.children);
125
+
126
+ // Build the full hierarchical path with :: separators for the store's _ensureGroup function
127
+ // For example: "explorer-root::explorer-root-group1" tells the store to nest group1 inside root
128
+ const hierarchicalPath = parentHierarchicalPath ? `${ parentHierarchicalPath }::${ groupName }` : groupName;
129
+
130
+ // For root-level groups (no parent), add the group itself to establish its identity.
131
+ // For nested groups, skip this - they're already registered in their parent's basicType.
132
+ // Adding nested groups here would overwrite their parent registration and make them
133
+ // appear at the wrong level in the hierarchy.
134
+ if (!parentGroupName) {
135
+ navNames.push(groupName);
136
+ }
137
+
138
+ // Register the group's children (and possibly itself if root) under this group's hierarchical path
139
+ // This uses :: separators so the store knows to create nested group structure
140
+ basicType(navNames, hierarchicalPath);
141
+
142
+ // register virtualTypes/configureTypes for each child item
143
+ itemGroup.children.forEach((subItem: ProductChild) => {
144
+ const currentGroupName = parentGroupName ? `${ parentGroupName }-${ itemGroup.name }` : itemGroup.name;
145
+
146
+ this.configurePageItem(productName, subItem, currentGroupName);
147
+
148
+ // Recursively process nested groups, passing the full hierarchical path
149
+ if (isProductChildGroup(subItem)) {
150
+ this.processGroupRecursively(subItem, productName, currentGroupName, hierarchicalPath);
151
+ }
152
+ });
153
+
154
+ // set the group indication based on whether the group has its own component page
155
+ if (!itemGroup.component) {
156
+ // Group without component - route to first child
157
+ setGroupDefaultType(groupName, navNames[0]);
158
+ } else {
159
+ // Group with component - route to the group's own page
160
+ setGroupDefaultType(groupName, groupName);
161
+ }
162
+
163
+ // group weight
164
+ if (itemGroup.weight) {
165
+ weightGroup(groupName, itemGroup.weight, true);
166
+ }
167
+
168
+ // group label
169
+ if (itemGroup.label || itemGroup.labelKey) {
170
+ labelGroup(groupName, itemGroup.label, itemGroup.labelKey);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Handles product registration via DSL
176
+ */
177
+ protected handleProductRegistration(): void {
178
+ const { basicType, product } = this.DSLMethods;
179
+
180
+ let defaultRoute;
181
+ const names = this.getIDsForGroupsOrBasicTypes(this.name, this.config);
182
+ const defaultResource = names[0] || '';
183
+
184
+ // this is the default "to" route for a product with config (at least 1 item on config) ordered by weight
185
+ if (defaultResource) {
186
+ const firstConfig = this.config[0];
187
+
188
+ if (isProductChildGroup(firstConfig)) {
189
+ // First config item is a group
190
+ if (firstConfig.children.length) {
191
+ const entryChild = firstConfig.children[0];
192
+
193
+ if (!firstConfig.component) {
194
+ // Group without component - route to first child
195
+ if (isProductChildWithType(entryChild)) {
196
+ defaultRoute = pluginProductsHelpers.generateConfigureTypeRoute(this.name, entryChild, { omitPath: true, extendProduct: !this.isNewProduct });
197
+ } else if (isProductChildWithComponent(entryChild)) {
198
+ defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, entryChild, { omitPath: true, extendProduct: !this.isNewProduct });
199
+ }
200
+ } else {
201
+ // Group with component - route to the group page itself
202
+ defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, undefined, {
203
+ omitPath: true, component: firstConfig.component, extendProduct: !this.isNewProduct
204
+ });
205
+ }
206
+ } else if (firstConfig.component) {
207
+ // Group with component but no children - route to the group page itself
208
+ defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, undefined, {
209
+ omitPath: true, component: firstConfig.component, extendProduct: !this.isNewProduct
210
+ });
211
+ }
212
+ } else if (isProductChildWithType(firstConfig)) {
213
+ // Simple configureType page (resource page)
214
+ defaultRoute = pluginProductsHelpers.generateConfigureTypeRoute(this.name, firstConfig, { omitPath: true, extendProduct: !this.isNewProduct });
215
+ } else if (isProductChildWithComponent(firstConfig)) {
216
+ // Simple virtual type page (custom page)
217
+ defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, firstConfig, { omitPath: true, extendProduct: !this.isNewProduct });
218
+ }
219
+ } else if (this.isNewProduct) {
220
+ // this is the "to" route for a simple page product (no config items)
221
+ defaultRoute = pluginProductsHelpers.generateTopLevelExtensionSimpleBaseRoute(this.name, { omitPath: true });
222
+ basicType(names);
223
+ }
224
+
225
+ // register the product via DSL
226
+ product({
227
+ showClusterSwitcher: false,
228
+ extendable: false,
229
+ ...this.product,
230
+ category: 'global',
231
+ to: defaultRoute,
232
+ icon: this.product?.icon || 'extension',
233
+ version: 2,
234
+ inStore: 'management',
235
+ name: this.name,
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Configure virtualType (custom page) or configureType (resource page) for a page item
241
+ */
242
+ protected configurePageItem(parentName: string, item: ProductChild, groupNaming?: string): void {
243
+ const { configureType, virtualType, weightType } = this.DSLMethods;
244
+
245
+ // Page with a "component" specified maps to a virtualType
246
+ if (isProductChildWithComponent(item) || (isProductChildGroup(item) && item.component)) {
247
+ // Extract properties we need from the narrowed item
248
+ const name = `${ parentName }-${ item.name }`;
249
+ const finalName = groupNaming ? `${ parentName }-${ groupNaming }-${ item.name }` : name;
250
+
251
+ const virtualTypeConfig: VirtualTypeConfiguration = {
252
+ label: item.label,
253
+ labelKey: item.labelKey,
254
+ namespaced: false,
255
+ name: finalName,
256
+ weight: item.weight, // ordering is done here and not via "weightType"
257
+ };
258
+
259
+ // if the item with COMPONENT has children then it's a GROUP virtualType, so set "exact" and "overview" to "true"
260
+ // so that when navigating to the group page, it shows the custom page for the group
261
+ if (isProductChildGroup(item)) {
262
+ virtualTypeConfig.exact = true;
263
+ virtualTypeConfig.overview = true;
264
+ virtualTypeConfig.route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, undefined, { extendProduct: !this.isNewProduct });
265
+ } else {
266
+ virtualTypeConfig.route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, item, { extendProduct: !this.isNewProduct });
267
+ }
268
+
269
+ virtualType({ ...virtualTypeConfig, ...(isProductChildWithComponent(item) ? item.config || {} : {}) });
270
+ } else if (isProductChildWithType(item)) {
271
+ // Page with a "type" specified maps to a configureType
272
+ const typeValue = item.type;
273
+ const route = pluginProductsHelpers.generateConfigureTypeRoute(parentName, item, { extendProduct: !this.isNewProduct });
274
+
275
+ const configureTypeConfig: ConfigureTypeConfiguration = {
276
+ isCreatable: true,
277
+ isEditable: true,
278
+ isRemovable: true,
279
+ canYaml: true,
280
+ customRoute: route
281
+ };
282
+
283
+ configureType(typeValue, { ...configureTypeConfig, ...(item.config || {}) });
284
+
285
+ if (item.weight) {
286
+ weightType(typeValue, item.weight, true);
287
+ }
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Add routes in Vue-router for any items that need them
293
+ */
294
+ protected addRoutes(plugin: IExtension, parentName: string, item: ProductChild[]): void {
295
+ item.forEach((child) => {
296
+ // if the child has children, then it's a group
297
+ if (isProductChildGroup(child)) {
298
+ // Validate group doesn't have type property
299
+ if (hasTypeProperty(child)) {
300
+ this.surfaceError('Group items cannot have a "type" property - only custom pages can have groups.');
301
+ }
302
+
303
+ if (child.component && !this.isNewProduct) {
304
+ this.surfaceError('When extending an existing product, group parent items cannot have a component because of route matching conflicts.');
305
+ }
306
+
307
+ let route;
308
+
309
+ if (!child.component) {
310
+ // Create minimal page object for route generation
311
+ const pageForRoute: ProductChildCustomPage = {
312
+ name: child.name,
313
+ label: child.label || child.labelKey || child.name,
314
+ component: EmptyProductPage
315
+ };
316
+
317
+ route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, pageForRoute, { extendProduct: !this.isNewProduct });
318
+ } else {
319
+ route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, undefined, { component: child.component, extendProduct: !this.isNewProduct });
320
+ }
321
+
322
+ // add the route for the group page/parent
323
+ plugin.addRoute(route);
324
+
325
+ // add children routes
326
+ this.addRoutes(plugin, `${ parentName }`, child.children);
327
+ } else if (isProductChildWithComponent(child)) {
328
+ // virtualType page
329
+ if (hasTypeProperty(child)) {
330
+ this.surfaceError('Custom pages cannot have a "type" property - only resource pages can use "type".');
331
+ }
332
+
333
+ const route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, child, { component: child.component, extendProduct: !this.isNewProduct });
334
+
335
+ plugin.addRoute(route);
336
+ } else if (isProductChildWithType(child)) {
337
+ // Validate type-based children don't have component property
338
+ // The type guard ensures child has 'type', so we just need to check component doesn't exist
339
+ // Since ProductChildPage with type has component?: never, this is a runtime validation
340
+
341
+ // configureType page (resource)
342
+ if (!this.addedResourceRoutes) {
343
+ this.addedResourceRoutes = true;
344
+
345
+ const resourceRoutes = pluginProductsHelpers.generateResourceRoutes(parentName, child, { extendProduct: !this.isNewProduct });
346
+
347
+ resourceRoutes.forEach((resRoute) => {
348
+ plugin.addRoute(resRoute);
349
+ });
350
+ }
351
+ }
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Get IDs for groups/basicTypes
357
+ */
358
+ protected getIDsForGroupsOrBasicTypes(parent: string, data: ProductChild[], excludeGrouping = false): string[] {
359
+ return data.map((item) => {
360
+ if (excludeGrouping && isProductChildGroup(item)) {
361
+ return null;
362
+ }
363
+
364
+ if (typeof item === 'string') {
365
+ return item;
366
+ } else if (hasNameProperty(item)) {
367
+ return `${ parent }-${ item.name }`;
368
+ } else if (hasTypeProperty(item)) {
369
+ return item.type;
370
+ }
371
+
372
+ return '';
373
+ }).filter((name): name is string => typeof name === 'string' && name.length > 0);
374
+ }
375
+ }
@@ -0,0 +1,44 @@
1
+ import { IExtension } from '@shell/core/types';
2
+ import { ProductChild, StandardProductName } from '@shell/core/plugin-types';
3
+ import EmptyProductPage from '@shell/components/EmptyProductPage.vue';
4
+ import { BasePluginProduct } from '@shell/core/plugin-products-base';
5
+
6
+ /**
7
+ * Represents extending an existing standard product
8
+ * @internal
9
+ */
10
+ export class ExtendingPluginProduct extends BasePluginProduct {
11
+ get isNewProduct(): boolean {
12
+ return false;
13
+ }
14
+
15
+ constructor(plugin: IExtension, productName: StandardProductName | string, config: ProductChild[]) {
16
+ super(config);
17
+
18
+ // existing standard product - no need to add routes
19
+ this.name = productName;
20
+
21
+ if (this.config?.length > 0) {
22
+ this.processConfigChildren();
23
+ } else {
24
+ // If no config is provided, add a default empty page
25
+ this.config = [{
26
+ name: 'main',
27
+ label: 'Main',
28
+ component: EmptyProductPage,
29
+ }];
30
+ }
31
+
32
+ this.addRoutes(plugin, this.name, this.config);
33
+ }
34
+
35
+ apply(plugin: IExtension, store: any): void {
36
+ const product = store.getters['type-map/productByName'](this.name);
37
+
38
+ if (!product?.extendable) {
39
+ this.surfaceError(`Product "${ this.name }" is not extendable. You can only extend core Dashboard products or builtin extensions.`);
40
+ }
41
+
42
+ super.apply(plugin, store);
43
+ }
44
+ }