@rancher/shell 3.0.9 → 3.0.10

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 (45) hide show
  1. package/assets/styles/base/_color.scss +4 -0
  2. package/assets/styles/themes/_light.scss +6 -6
  3. package/assets/styles/themes/_modern.scss +14 -6
  4. package/assets/translations/en-us.yaml +2 -5
  5. package/components/CopyToClipboard.vue +28 -0
  6. package/components/CopyToClipboardText.vue +4 -0
  7. package/components/CruResource.vue +1 -0
  8. package/components/GlobalRoleBindings.vue +1 -5
  9. package/components/ResourceDetail/index.vue +0 -21
  10. package/components/__tests__/CruResource.test.ts +35 -1
  11. package/composables/useIsNewDetailPageEnabled.test.ts +98 -0
  12. package/composables/useIsNewDetailPageEnabled.ts +12 -0
  13. package/config/product/explorer.js +11 -1
  14. package/config/table-headers.js +0 -9
  15. package/config/types.js +0 -1
  16. package/edit/auth/github-app-steps.vue +2 -0
  17. package/edit/auth/github-steps.vue +2 -0
  18. package/edit/management.cattle.io.user.vue +60 -35
  19. package/edit/token.vue +29 -68
  20. package/models/token.js +0 -4
  21. package/package.json +8 -8
  22. package/pages/account/index.vue +67 -96
  23. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +66 -9
  24. package/pages/c/_cluster/explorer/index.vue +2 -19
  25. package/pkg/auto-import.js +41 -0
  26. package/plugins/dashboard-store/resource-class.js +2 -2
  27. package/plugins/steve/__tests__/steve-class.test.ts +1 -1
  28. package/plugins/steve/steve-class.js +3 -3
  29. package/plugins/steve/steve-pagination-utils.ts +2 -4
  30. package/rancher-components/Pill/RcCounterBadge/RcCounterBadge.vue +7 -7
  31. package/rancher-components/Pill/RcStatusBadge/RcStatusBadge.vue +5 -2
  32. package/rancher-components/RcIcon/types.ts +2 -2
  33. package/rancher-components/RcSection/RcSection.test.ts +323 -0
  34. package/rancher-components/RcSection/RcSection.vue +252 -0
  35. package/rancher-components/RcSection/RcSectionActions.test.ts +212 -0
  36. package/rancher-components/RcSection/RcSectionActions.vue +85 -0
  37. package/rancher-components/RcSection/RcSectionBadges.test.ts +149 -0
  38. package/rancher-components/RcSection/RcSectionBadges.vue +29 -0
  39. package/rancher-components/RcSection/index.ts +12 -0
  40. package/rancher-components/RcSection/types.ts +86 -0
  41. package/scripts/test-plugins-build.sh +5 -4
  42. package/types/shell/index.d.ts +92 -108
  43. package/utils/style.ts +17 -0
  44. package/utils/units.js +14 -5
  45. package/models/ext.cattle.io.token.js +0 -48
package/edit/token.vue CHANGED
@@ -2,7 +2,7 @@
2
2
  import { mapGetters } from 'vuex';
3
3
  import day from 'dayjs';
4
4
  import sortBy from 'lodash/sortBy';
5
- import { MANAGEMENT, EXT } from '@shell/config/types';
5
+ import { MANAGEMENT, NORMAN } from '@shell/config/types';
6
6
  import { Banner } from '@components/Banner';
7
7
  import DetailText from '@shell/components/DetailText';
8
8
  import Footer from '@shell/components/form/Footer';
@@ -14,7 +14,6 @@ import CreateEditView from '@shell/mixins/create-edit-view';
14
14
  import { diffFrom } from '@shell/utils/time';
15
15
  import { filterHiddenLocalCluster, filterOnlyKubernetesClusters } from '@shell/utils/cluster';
16
16
  import { SETTING } from '@shell/config/settings';
17
- import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
18
17
 
19
18
  export default {
20
19
  components: {
@@ -25,7 +24,6 @@ export default {
25
24
  LabeledSelect,
26
25
  RadioGroup,
27
26
  Select,
28
- Checkbox,
29
27
  },
30
28
 
31
29
  mixins: [CreateEditView],
@@ -43,11 +41,7 @@ export default {
43
41
 
44
42
  return {
45
43
  errors: null,
46
- user: null,
47
44
  form: {
48
- enabled: true,
49
- description: '',
50
- clusterName: '',
51
45
  expiryType: 'never',
52
46
  customExpiry: 0,
53
47
  customExpiryUnits: 'minute',
@@ -57,13 +51,11 @@ export default {
57
51
  accessKey: '',
58
52
  secretKey: '',
59
53
  maxTTL,
60
- ttl: ''
61
54
  };
62
55
  },
63
56
 
64
57
  computed: {
65
58
  ...mapGetters({ t: 'i18n/t' }),
66
-
67
59
  scopes() {
68
60
  const all = this.$store.getters['management/all'](MANAGEMENT.CLUSTER);
69
61
  const kubeClusters = filterHiddenLocalCluster(filterOnlyKubernetesClusters(all, this.$store), this.$store);
@@ -79,20 +71,16 @@ export default {
79
71
  const options = ['never', 'day', 'month', 'year', 'custom'];
80
72
  let opts = options.map((opt) => ({ value: opt, label: this.t(`accountAndKeys.apiKeys.add.expiry.options.${ opt }`) }));
81
73
 
82
- // When the TTL is greater than 0, present only two options
74
+ // When the TTL is anything other than 0, present only two options
83
75
  // (1) The maximum allowed
84
76
  // (2) Custom
85
- if (this.maxTTL > 0 ) {
77
+ if (this.maxTTL !== 0 ) {
86
78
  const now = day();
87
79
  const expiry = now.add(this.maxTTL, 'minute');
88
80
  const max = diffFrom(expiry, now, this.t);
89
81
 
90
82
  opts = opts.filter((opt) => opt.value === 'custom');
91
83
  opts.unshift({ value: 'max', label: this.t('accountAndKeys.apiKeys.add.expiry.options.maximum', { value: max.string }) });
92
- } else {
93
- // maxTTL <= 0 means there is no maximum, so we can show the 'never' option which results in an infinite TTL
94
- // OR if we set a positive TTL, then it assumes that value
95
- opts = opts.filter((opt) => opt.value === 'never' || opt.value === 'custom');
96
84
  }
97
85
 
98
86
  return opts;
@@ -103,9 +91,6 @@ export default {
103
91
 
104
92
  return filtered.map((opt) => ({ value: opt, label: this.t(`accountAndKeys.apiKeys.add.customExpiry.options.${ opt }`) }));
105
93
  },
106
- hasNeverOption() {
107
- return this.expiryOptions?.filter((opt) => opt.value === 'never')?.length === 1;
108
- }
109
94
  },
110
95
 
111
96
  mounted() {
@@ -130,33 +115,31 @@ export default {
130
115
  });
131
116
  },
132
117
 
133
- async actuallySave() {
134
- // update expiration value before save
118
+ async actuallySave(url) {
135
119
  this.updateExpiry();
136
-
137
120
  if ( this.isCreate ) {
138
- const steveToken = await this.$store.dispatch('management/create', {
139
- type: EXT.TOKEN,
140
- spec: {
141
- description: this.form.description,
142
- kind: '',
143
- userPrincipal: null, // will be set by the backend to the current user
144
- clusterName: this.form.clusterName,
145
- enabled: this.form.enabled,
146
- ttl: this.ttl
147
- // userID: not needed as it will be set by the backend to the current user
148
- }
149
- });
150
-
151
- const steveTokenSaved = await steveToken.save();
152
-
153
- this.created = steveTokenSaved;
154
- this.ttlLimited = this.created?.spec?.ttl !== this.ttl;
155
- const token = this.created?.status?.bearerToken?.split(':');
121
+ // Description is a bit weird, so need to clone and set this
122
+ // rather than use this.value - need to find a way to set this if we ever
123
+ // want to allow edit (which I don't think we do)
124
+ const res = await this.value.save();
125
+
126
+ this.created = res;
127
+ this.ttlLimited = res.ttl !== this.value.ttl;
128
+ const token = this.created.token.split(':');
156
129
 
157
130
  this.accessKey = token[0];
158
131
  this.secretKey = (token.length > 1) ? token[1] : '';
159
- this.token = this.created?.status?.bearerToken;
132
+ this.token = this.created.token;
133
+
134
+ // Force a refresh of the token so we get the expiry date correctly
135
+ await this.$store.dispatch('rancher/find', {
136
+ type: NORMAN.TOKEN,
137
+ id: res.id,
138
+ opt: { force: true }
139
+ }, { root: true });
140
+ } else {
141
+ // Note: update of existing key not supported currently
142
+ await this.value.save();
160
143
  }
161
144
  },
162
145
 
@@ -176,9 +159,7 @@ export default {
176
159
  const units = (v === 'custom') ? this.form.customExpiryUnits : v;
177
160
  let ttl = 0;
178
161
 
179
- if (v === 'never') {
180
- ttl = -1;
181
- } else if (units === 'max') {
162
+ if (units === 'max') {
182
163
  ttl = this.maxTTL * 60 * 1000;
183
164
  } else if ( units !== 'never' ) {
184
165
  const now = day();
@@ -186,8 +167,7 @@ export default {
186
167
 
187
168
  ttl = expiry.diff(now);
188
169
  }
189
-
190
- this.ttl = ttl;
170
+ this.value.ttl = ttl;
191
171
  }
192
172
  }
193
173
  };
@@ -198,7 +178,7 @@ export default {
198
178
  <div class="pl-10 pr-10">
199
179
  <LabeledInput
200
180
  key="description"
201
- v-model:value="form.description"
181
+ v-model:value="value.description"
202
182
  :placeholder="t('accountAndKeys.apiKeys.add.description.placeholder')"
203
183
  label-key="accountAndKeys.apiKeys.add.description.label"
204
184
  mode="edit"
@@ -206,30 +186,13 @@ export default {
206
186
  />
207
187
 
208
188
  <LabeledSelect
209
- v-model:value="form.clusterName"
189
+ v-model:value="value.clusterId"
210
190
  class="mt-20 scope-select"
211
191
  label-key="accountAndKeys.apiKeys.add.scope"
212
192
  :options="scopes"
213
193
  />
214
194
 
215
- <Checkbox
216
- v-model:value="form.enabled"
217
- class="mt-20 mb-20"
218
- :mode="mode"
219
- label-key="accountAndKeys.apiKeys.add.enabled"
220
- />
221
-
222
- <Banner
223
- v-if="hasNeverOption"
224
- color="warning"
225
- class="mt-20"
226
- >
227
- <div>
228
- {{ t('accountAndKeys.apiKeys.info.expiryOptionsWithNever') }}
229
- </div>
230
- </Banner>
231
-
232
- <h5 class="mb-20">
195
+ <h5 class="pt-20">
233
196
  {{ t('accountAndKeys.apiKeys.add.expiry.label') }}
234
197
  </h5>
235
198
 
@@ -241,9 +204,7 @@ export default {
241
204
  class="mr-20"
242
205
  name="expiryGroup"
243
206
  />
244
- <div
245
- class="ml-20 mt-10 expiry"
246
- >
207
+ <div class="ml-20 mt-10 expiry">
247
208
  <input
248
209
  v-model="form.customExpiry"
249
210
  :disabled="form.expiryType !== 'custom'"
package/models/token.js CHANGED
@@ -16,8 +16,4 @@ export default class extends NormanModel {
16
16
 
17
17
  return expiry.isBefore(day());
18
18
  }
19
-
20
- get isDeprecated() {
21
- return true;
22
- }
23
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rancher/shell",
3
- "version": "3.0.9",
3
+ "version": "3.0.10",
4
4
  "description": "Rancher Dashboard Shell",
5
5
  "repository": "https://github.com/rancher/dashboard",
6
6
  "license": "Apache-2.0",
@@ -8,7 +8,7 @@
8
8
  "private": false,
9
9
  "types": "types/shell/index.d.ts",
10
10
  "engines": {
11
- "node": ">=20.0.0"
11
+ "node": ">=24.0.0"
12
12
  },
13
13
  "files": [
14
14
  "**/*"
@@ -41,7 +41,7 @@
41
41
  "@popperjs/core": "2.11.8",
42
42
  "@rancher/icons": "2.0.55",
43
43
  "@types/is-url": "1.2.30",
44
- "@types/node": "20.10.8",
44
+ "@types/node": "25.3.3",
45
45
  "@types/semver": "^7.5.8",
46
46
  "@typescript-eslint/eslint-plugin": "5.62.0",
47
47
  "@typescript-eslint/parser": "5.62.0",
@@ -65,7 +65,7 @@
65
65
  "color": "5.0.3",
66
66
  "cookie-universal": "2.2.2",
67
67
  "cookie": "0.7.0",
68
- "core-js": "3.45.0",
68
+ "core-js": "3.48.0",
69
69
  "cron-validator": "1.4.0",
70
70
  "cronstrue": "3.9.0",
71
71
  "cross-env": "7.0.3",
@@ -119,7 +119,7 @@
119
119
  "papaparse": "5.3.0",
120
120
  "portal-vue": "~3.0.0",
121
121
  "sass-loader": "12.6.0",
122
- "sass": "1.89.2",
122
+ "sass": "1.97.3",
123
123
  "serve-static": "1.14.1",
124
124
  "set-cookie-parser": "2.4.6",
125
125
  "shell-quote": "1.7.3",
@@ -131,10 +131,10 @@
131
131
  "ufo": "0.7.11",
132
132
  "unfetch": "4.2.0",
133
133
  "url-parse": "1.5.10",
134
- "vue-router": "4.5.1",
134
+ "vue-router": "4.6.4",
135
135
  "vue-select": "4.0.0-beta.6",
136
136
  "vue-server-renderer": "2.7.16",
137
- "vue": "3.5.18",
137
+ "vue": "3.5.29",
138
138
  "vue3-resize": "0.2.0",
139
139
  "vue3-virtual-scroll-list": "0.2.1",
140
140
  "vuedraggable": "4.1.0",
@@ -164,7 +164,7 @@
164
164
  "roarr": "7.0.4",
165
165
  "semver": "7.5.4",
166
166
  "@types/lodash": "4.17.5",
167
- "@types/node": "20.10.8",
167
+ "@types/node": "25.3.3",
168
168
  "@vue/cli-service/html-webpack-plugin": "^5.0.0"
169
169
  },
170
170
  "nyc": {
@@ -9,107 +9,94 @@ import { mapGetters } from 'vuex';
9
9
 
10
10
  import { Banner } from '@components/Banner';
11
11
  import ResourceTable from '@shell/components/ResourceTable';
12
+ import CopyToClipboardText from '@shell/components/CopyToClipboardText';
12
13
  import TabTitle from '@shell/components/TabTitle';
13
14
 
14
- import { allHash } from '@shell/utils/promise';
15
-
16
- import {
17
- ACCESS_KEY, DESCRIPTION, EXPIRES, EXPIRY_STATE,
18
- LAST_USED, AGE_NORMAN, SCOPE_NORMAN, NORMAN_KEY_DEPRECATION
19
- } from '@shell/config/table-headers';
20
- import { FilterArgs, PaginationParamFilter } from '@shell/types/store/pagination.types';
15
+ const API_ENDPOINT = '/v3';
21
16
 
22
17
  export default {
23
18
  components: {
24
- BackLink, Banner, Loading, ResourceTable, Principal, TabTitle
19
+ CopyToClipboardText, BackLink, Banner, Loading, ResourceTable, Principal, TabTitle
25
20
  },
26
21
  mixins: [BackRoute],
27
22
  async fetch() {
28
- const hashedRequests = {};
29
-
30
23
  this.canChangePassword = await this.calcCanChangePassword();
31
24
 
32
- this.normanTokenSchema = this.$store.getters[`rancher/schemaFor`](NORMAN.TOKEN);
33
- this.steveTokenSchema = this.$store.getters[`management/schemaFor`](EXT.TOKEN);
25
+ if (this.apiKeySchema) {
26
+ this.rows = await this.$store.dispatch('rancher/findAll', { type: NORMAN.TOKEN });
27
+ }
34
28
 
35
- const selfUser = await this.$store.dispatch('auth/getSelfUser');
29
+ // Get all settings - the API host setting may not be set, so this avoids a 404 request if we look for the specific setting
30
+ const allSettings = await this.$store.dispatch('management/findAll', { type: MANAGEMENT.SETTING });
31
+ const apiHostSetting = allSettings.find((i) => i.id === SETTING.API_HOST);
32
+ const serverUrlSetting = allSettings.find((i) => i.id === SETTING.SERVER_URL);
36
33
 
37
- if (this.normanTokenSchema) {
38
- hashedRequests.normanTokens = this.$store.dispatch('rancher/findAll', { type: NORMAN.TOKEN });
39
- }
34
+ this.apiHostSetting = apiHostSetting?.value;
35
+ this.serverUrlSetting = serverUrlSetting?.value;
40
36
 
41
- if (this.steveTokenSchema) {
42
- this.filterByUserTokens = this.$store.getters[`management/paginationEnabled`](EXT.TOKEN);
43
-
44
- if (this.filterByUserTokens && selfUser?.status?.userID) {
45
- // Only get associated with the current user
46
- const opt = { // Of type ActionFindPageArgs
47
- pagination: new FilterArgs({
48
- filters: PaginationParamFilter.createSingleField({
49
- field: 'metadata.fields.1',
50
- value: selfUser.status?.userID,
51
- })
52
- })
53
- };
54
-
55
- hashedRequests.steveTokens = this.$store.dispatch(`management/findPage`, { type: EXT.TOKEN, opt });
56
- } else {
57
- hashedRequests.steveTokens = this.$store.dispatch('management/findAll', { type: EXT.TOKEN });
58
- }
59
- }
37
+ const selfUser = await this.$store.dispatch('auth/getSelfUser');
60
38
 
61
39
  if (selfUser?.canGetUser && selfUser.status?.userID) {
62
40
  // Fetch the user info for ChangePassword (ChangePasswordDialog needs the user info for the user whose password is being changed)
63
- hashedRequests.user = this.$store.dispatch('management/find', {
41
+ this.user = await this.$store.dispatch('management/find', {
64
42
  type: MANAGEMENT.USER,
65
43
  id: selfUser.status?.userID
66
44
  });
45
+ } else {
46
+ throw new Error(this.t('changePassword.errors.cannotFetchSelf'));
67
47
  }
68
-
69
- // Get all settings - the API host setting may not be set, so this avoids a 404 request if we look for the specific setting
70
- hashedRequests.allSettings = this.$store.dispatch('management/findAll', { type: MANAGEMENT.SETTING });
71
-
72
- const {
73
- normanTokens, steveTokens, allSettings, user
74
- } = await allHash(hashedRequests);
75
-
76
- this.normanTokens = normanTokens;
77
- this.steveTokens = steveTokens;
78
- this.user = user;
79
-
80
- const apiHostSetting = allSettings.find((i) => i.id === SETTING.API_HOST);
81
- const serverUrlSetting = allSettings.find((i) => i.id === SETTING.SERVER_URL);
82
-
83
- this.apiHostSetting = apiHostSetting?.value;
84
- this.serverUrlSetting = serverUrlSetting?.value;
85
48
  },
86
49
  data() {
87
50
  return {
88
- normanTokenSchema: undefined,
89
- steveTokenSchema: undefined,
90
51
  apiHostSetting: null,
91
52
  serverUrlSetting: null,
92
53
  rows: null,
93
54
  canChangePassword: false,
94
- user: null,
95
- normanTokens: null,
96
- steveTokens: null,
55
+ user: null
97
56
  };
98
57
  },
99
58
  computed: {
100
59
  ...mapGetters({ t: 'i18n/t' }),
101
60
 
102
61
  apiKeyheaders() {
103
- return [
104
- EXPIRY_STATE,
105
- ACCESS_KEY,
106
- DESCRIPTION,
107
- SCOPE_NORMAN,
108
- NORMAN_KEY_DEPRECATION,
109
- LAST_USED,
110
- EXPIRES,
111
- AGE_NORMAN
112
- ];
62
+ return this.apiKeySchema ? this.$store.getters['type-map/headersFor'](this.apiKeySchema) : [];
63
+ },
64
+
65
+ // Port of Ember code for API Url - see: https://github.com/rancher/ui/blob/8e07c492673171731f3b26af14c978bc103d1828/lib/shared/addon/endpoint/service.js#L58
66
+ apiUrlBase() {
67
+ let setting = this.apiHostSetting;
68
+
69
+ if (setting && setting.indexOf('http') !== 0) {
70
+ setting = `http://${ setting }`;
71
+ }
72
+
73
+ // Use Server Setting URL if the api host setting is not set
74
+ let url = setting || this.serverUrlSetting;
75
+
76
+ // If the URL is relative, add on the current base URL from the browser
77
+ if ( url.indexOf('http') !== 0 ) {
78
+ url = `${ window.location.origin }/${ url.replace(/^\/+/, '') }`;
79
+ }
80
+
81
+ // URL must end in a single slash
82
+ url = `${ url.replace(/\/+$/, '') }/`;
83
+
84
+ return url;
85
+ },
86
+
87
+ apiUrl() {
88
+ const base = this.apiUrlBase;
89
+ const path = API_ENDPOINT.replace(/^\/+/, '');
90
+
91
+ return `${ base }${ path }`;
92
+ },
93
+
94
+ apiKeySchema() {
95
+ try {
96
+ return this.$store.getters[`rancher/schemaFor`](NORMAN.TOKEN);
97
+ } catch (e) {}
98
+
99
+ return null;
113
100
  },
114
101
 
115
102
  principal() {
@@ -123,7 +110,7 @@ export default {
123
110
  return principal || {};
124
111
  },
125
112
 
126
- filteredNormanTokens() {
113
+ apiKeys() {
127
114
  // Filter out tokens that are not API Keys and are not expired UI Sessions
128
115
  const isApiKey = (key) => {
129
116
  const labels = key.labels;
@@ -133,24 +120,7 @@ export default {
133
120
  return ( !expired || !labels || !labels['ui-session'] ) && !current;
134
121
  };
135
122
 
136
- return !this.normanTokens ? [] : this.normanTokens.filter(isApiKey);
137
- },
138
-
139
- filteredNewTokens() {
140
- // Filter out tokens that are not API Keys and are not expired UI Sessions
141
- const isApiKey = (key) => {
142
- const labels = key.metadata?.labels;
143
- const expired = key.status?.expired;
144
- const current = key.status?.current;
145
-
146
- return ( !expired || !labels || !labels['ui-session'] ) && !current;
147
- };
148
-
149
- return !this.steveTokens ? [] : this.steveTokens.filter(isApiKey);
150
- },
151
-
152
- apiKeys() {
153
- return (this.filteredNormanTokens || []).concat(this.filteredNewTokens || []);
123
+ return !this.rows ? [] : this.rows.filter(isApiKey);
154
124
  }
155
125
  },
156
126
 
@@ -221,9 +191,16 @@ export default {
221
191
  <div class="keys-header">
222
192
  <div>
223
193
  <h2 v-t="'accountAndKeys.apiKeys.title'" />
194
+ <div class="api-url">
195
+ <span>{{ t("accountAndKeys.apiKeys.apiEndpoint") }}</span>
196
+ <CopyToClipboardText
197
+ :aria-label="t('accountAndKeys.apiKeys.copyApiEnpoint')"
198
+ :text="apiUrl"
199
+ />
200
+ </div>
224
201
  </div>
225
202
  <button
226
- v-if="steveTokenSchema"
203
+ v-if="apiKeySchema"
227
204
  role="button"
228
205
  :aria-label="t('accountAndKeys.apiKeys.add.label')"
229
206
  class="btn role-primary add mb-20"
@@ -234,17 +211,11 @@ export default {
234
211
  </button>
235
212
  </div>
236
213
  <div
237
- v-if="steveTokenSchema"
214
+ v-if="apiKeySchema"
238
215
  class="keys"
239
216
  >
240
- <Banner
241
- v-if="filteredNormanTokens.length"
242
- color="warning"
243
- class="mb-20"
244
- :label="t('accountAndKeys.apiKeys.normanTokenDeprecation')"
245
- />
246
217
  <ResourceTable
247
- :schema="steveTokenSchema"
218
+ :schema="apiKeySchema"
248
219
  :rows="apiKeys"
249
220
  :headers="apiKeyheaders"
250
221
  key-field="id"
@@ -1,27 +1,78 @@
1
1
  <script setup lang="ts">
2
+ import { reactive } from 'vue';
2
3
  import { RcItemCardAction } from '@components/RcItemCard';
3
4
  import { RcButton } from '@components/RcButton';
4
- import { RcIcon } from '@components/RcIcon';
5
+ import { isTruncated } from '@shell/utils/style';
6
+ import RcIcon from '@components/RcIcon/RcIcon.vue';
7
+ import type { RcIconType } from '@components/RcIcon/types';
5
8
 
6
9
  interface FooterItem {
7
- icon?: string;
8
- iconTooltip?: Record<{key?: string, text?: string}>;
10
+ icon?: RcIconType;
11
+ iconTooltip?: { key?: string; text?: string };
9
12
  labels: string[];
10
13
  labelTooltip?: string;
11
14
  type?: string;
12
15
  }
13
16
 
14
- const emit = defineEmits<{(e: 'click:item', type: string, label: string): void; }>();
17
+ const emit = defineEmits<{(e: 'click:item', type: string, label: string): void }>();
15
18
 
16
19
  defineProps<{
17
20
  items: FooterItem[];
18
21
  clickable?: boolean;
19
22
  }>();
20
23
 
21
- function onClickItem(type: string, label: string) {
24
+ const tooltipOverrides = reactive<Record<string, string | undefined>>({});
25
+ const labelElements: Record<string, HTMLElement | null> = {};
26
+
27
+ function onClickItem(type: string, label: string): void {
22
28
  emit('click:item', type, label);
23
29
  }
24
30
 
31
+ /**
32
+ * Creates a unique key for identifying a label element.
33
+ * @param itemIndex - Index of the footer item
34
+ * @param labelIndex - Index of the label within the footer item
35
+ */
36
+ function createLabelKey(itemIndex: number, labelIndex: number): string {
37
+ return `${ itemIndex }-${ labelIndex }`;
38
+ }
39
+
40
+ /**
41
+ * Registers a label element reference for truncation detection.
42
+ * @param key - Unique identifier for the label
43
+ * @param el - The HTML element or null
44
+ */
45
+ function registerLabelRef(key: string, el: HTMLElement | null): void {
46
+ labelElements[key] = el;
47
+ }
48
+
49
+ /**
50
+ * Updates the tooltip content based on whether the label text is truncated.
51
+ * If truncated, prepends the full label text to the base tooltip.
52
+ * @param key - Unique identifier for the label
53
+ * @param label - The label text content
54
+ * @param baseTooltip - The original tooltip content
55
+ */
56
+ function updateTooltipOnHover(key: string, label: string, baseTooltip?: string): void {
57
+ if (!baseTooltip) {
58
+ tooltipOverrides[key] = undefined;
59
+
60
+ return;
61
+ }
62
+
63
+ const element = labelElements[key];
64
+
65
+ tooltipOverrides[key] = isTruncated(element) ? `${ label }. ${ baseTooltip }` : baseTooltip;
66
+ }
67
+
68
+ /**
69
+ * Returns the tooltip to display for a given label.
70
+ * @param key - Unique identifier for the label
71
+ * @param fallback - Fallback tooltip if no override exists
72
+ */
73
+ function getTooltip(key: string, fallback?: string): string | undefined {
74
+ return tooltipOverrides[key] ?? fallback;
75
+ }
25
76
  </script>
26
77
 
27
78
  <template>
@@ -34,7 +85,7 @@ function onClickItem(type: string, label: string) {
34
85
  >
35
86
  <RcIcon
36
87
  v-if="footerItem.icon"
37
- v-clean-tooltip="footerItem.iconTooltip?.key ? t(footerItem.iconTooltip?.key) : undefined"
88
+ v-clean-tooltip="footerItem.iconTooltip?.key ? t(footerItem.iconTooltip.key) : undefined"
38
89
  class="app-chart-card-footer-item-icon"
39
90
  :type="footerItem.icon"
40
91
  />
@@ -47,14 +98,18 @@ function onClickItem(type: string, label: string) {
47
98
  class="app-chart-card-footer-item-action"
48
99
  >
49
100
  <rc-button
50
- v-clean-tooltip="footerItem.labelTooltip"
101
+ v-clean-tooltip="getTooltip(createLabelKey(i, j), footerItem.labelTooltip)"
51
102
  variant="ghost"
52
103
  class="app-chart-card-footer-button secondary-text-link"
53
104
  data-testid="app-chart-card-footer-item-text"
54
105
  :aria-label="t('catalog.charts.appChartCard.footerItem.ariaLabel', { filter: label })"
55
106
  @click="onClickItem(footerItem.type, label)"
107
+ @mouseenter="updateTooltipOnHover(createLabelKey(i, j), label, footerItem.labelTooltip)"
56
108
  >
57
- <span class="app-chart-card-footer-button-label">{{ label }}</span>
109
+ <span
110
+ :ref="(el) => registerLabelRef(createLabelKey(i, j), el as HTMLElement)"
111
+ class="app-chart-card-footer-button-label"
112
+ >{{ label }}</span>
58
113
  </rc-button>
59
114
  <span
60
115
  v-if="footerItem.labels.length > 1 && j !== footerItem.labels.length - 1"
@@ -63,9 +118,11 @@ function onClickItem(type: string, label: string) {
63
118
  </rc-item-card-action>
64
119
  <span
65
120
  v-else
66
- v-clean-tooltip="footerItem.labelTooltip"
121
+ :ref="(el) => registerLabelRef(createLabelKey(i, j), el as HTMLElement)"
122
+ v-clean-tooltip="getTooltip(createLabelKey(i, j), footerItem.labelTooltip)"
67
123
  class="app-chart-card-footer-item-text"
68
124
  data-testid="app-chart-card-footer-item-text"
125
+ @mouseenter="updateTooltipOnHover(createLabelKey(i, j), label, footerItem.labelTooltip)"
69
126
  >
70
127
  {{ label }}
71
128
  <span
@@ -394,29 +394,12 @@ export default {
394
394
  },
395
395
 
396
396
  metricAggregations() {
397
- let checkNodes = this.nodes;
398
-
399
- // Special case local cluster
400
- if (this.currentCluster.isLocal) {
401
- const nodeNames = this.nodes.reduce((acc, n) => {
402
- acc[n.id] = n;
403
-
404
- return acc;
405
- }, {});
406
-
407
- checkNodes = this.mgmtNodes.filter((n) => {
408
- const nodeName = n.metadata?.labels?.['management.cattle.io/nodename'] || n.id;
409
-
410
- return !!nodeNames[nodeName];
411
- });
412
- }
413
-
414
- const someNonWorkerRoles = checkNodes.some((node) => node.hasARole && !node.isWorker);
415
397
  const metrics = this.nodeMetrics.filter((nodeMetrics) => {
416
398
  const node = this.nodes.find((nd) => nd.id === nodeMetrics.id);
417
399
 
418
- return node && (!someNonWorkerRoles || node.isWorker);
400
+ return node;
419
401
  });
402
+
420
403
  const initialAggregation = {
421
404
  cpu: 0,
422
405
  memory: 0