@rancher/shell 2.0.2-rc.1 → 2.0.2

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 (37) hide show
  1. package/assets/translations/en-us.yaml +44 -30
  2. package/components/PromptRemove.vue +8 -3
  3. package/components/ResourceDetail/Masthead.vue +1 -0
  4. package/components/fleet/FleetClusters.vue +0 -3
  5. package/components/formatter/CloudCredExpired.vue +69 -0
  6. package/components/formatter/Date.vue +1 -1
  7. package/components/nav/Header.vue +9 -5
  8. package/components/nav/TopLevelMenu.vue +115 -51
  9. package/components/nav/__tests__/TopLevelMenu.test.ts +53 -27
  10. package/config/labels-annotations.js +2 -0
  11. package/detail/catalog.cattle.io.app.vue +17 -4
  12. package/detail/fleet.cattle.io.cluster.vue +11 -9
  13. package/detail/fleet.cattle.io.gitrepo.vue +1 -1
  14. package/edit/provisioning.cattle.io.cluster/rke2.vue +13 -0
  15. package/list/provisioning.cattle.io.cluster.vue +56 -5
  16. package/mixins/chart.js +6 -2
  17. package/models/catalog.cattle.io.app.js +108 -21
  18. package/models/cloudcredential.js +159 -2
  19. package/models/fleet.cattle.io.gitrepo.js +4 -13
  20. package/models/management.cattle.io.cluster.js +13 -2
  21. package/models/provisioning.cattle.io.cluster.js +37 -3
  22. package/package.json +1 -1
  23. package/pages/c/_cluster/apps/charts/install.vue +2 -1
  24. package/pages/c/_cluster/explorer/__tests__/index.test.ts +1 -1
  25. package/pages/c/_cluster/explorer/index.vue +1 -2
  26. package/pages/c/_cluster/fleet/index.vue +11 -5
  27. package/pages/c/_cluster/manager/cloudCredential/index.vue +68 -4
  28. package/pages/c/_cluster/uiplugins/index.vue +4 -2
  29. package/pages/home.vue +1 -0
  30. package/scripts/extension/bundle +1 -1
  31. package/scripts/publish-shell.sh +3 -4
  32. package/scripts/typegen.sh +26 -23
  33. package/types/shell/index.d.ts +4595 -0
  34. package/utils/cluster.js +1 -1
  35. package/utils/string.js +9 -0
  36. package/utils/v-sphere.ts +251 -0
  37. package/shell/types/shell/index.d.ts +0 -2
@@ -4,7 +4,7 @@ import {
4
4
  import { CATALOG as CATALOG_ANNOTATIONS, FLEET } from '@shell/config/labels-annotations';
5
5
  import { compare, isPrerelease, sortable } from '@shell/utils/version';
6
6
  import { filterBy } from '@shell/utils/array';
7
- import { CATALOG, MANAGEMENT, NORMAN } from '@shell/config/types';
7
+ import { CATALOG, MANAGEMENT, NORMAN, SECRET } from '@shell/config/types';
8
8
  import { SHOW_PRE_RELEASE } from '@shell/store/prefs';
9
9
  import { set } from '@shell/utils/object';
10
10
 
@@ -279,28 +279,115 @@ export default class CatalogApp extends SteveModel {
279
279
  };
280
280
  }
281
281
 
282
- get deployedAsLegacy() {
283
- return async() => {
284
- if (this.spec?.values?.global) {
285
- const { clusterName, projectName } = this.spec.values.global;
286
-
287
- if (clusterName && projectName) {
288
- try {
289
- const legacyApp = await this.$dispatch('rancher/find', {
290
- type: NORMAN.APP,
291
- id: `${ projectName }:${ this.metadata?.name }`,
292
- opt: { url: `/v3/project/${ clusterName }:${ projectName }/apps/${ projectName }:${ this.metadata?.name }` }
293
- }, { root: true });
294
-
295
- if (legacyApp) {
296
- return legacyApp;
297
- }
298
- } catch (e) {}
299
- }
282
+ async deployedAsLegacy() {
283
+ await this.fetchValues();
284
+
285
+ if (this.values?.global) {
286
+ const { clusterName, projectName } = this.values.global;
287
+
288
+ if (clusterName && projectName) {
289
+ try {
290
+ const legacyApp = await this.$dispatch('rancher/find', {
291
+ type: NORMAN.APP,
292
+ id: `${ projectName }:${ this.metadata?.name }`,
293
+ opt: { url: `/v3/project/${ clusterName }:${ projectName }/apps/${ projectName }:${ this.metadata?.name }` }
294
+ }, { root: true });
295
+
296
+ if (legacyApp) {
297
+ return legacyApp;
298
+ }
299
+ } catch (e) {}
300
300
  }
301
+ }
301
302
 
302
- return false;
303
- };
303
+ return false;
304
+ }
305
+
306
+ /**
307
+ * User and Chart values live in a helm secret, so fetch it (with special param)
308
+ */
309
+ async fetchValues(force = false) {
310
+ if (!this.secretId) {
311
+ // If there's no secret id this isn't ever going to work, no need to carry on
312
+ return;
313
+ }
314
+
315
+ const haveValues = !!this._values && !!this._chartValues;
316
+
317
+ if (haveValues && !force) {
318
+ // If we already have the required values and we're not forced to re-fetch, no need to carry on
319
+ return;
320
+ }
321
+
322
+ try {
323
+ await this.$dispatch('find', {
324
+ type: SECRET,
325
+ id: this.secretId,
326
+ opt: {
327
+ force: force || (!!this._secret && !haveValues), // force if explicitly requested or there's ean existing secret without the required values we have a secret without the values in (Secret has been fetched another way)
328
+ watch: false, // Cannot watch with custom params (they are dropped on calls made when resyncing over socket)
329
+ params: { includeHelmData: true }
330
+ }
331
+ });
332
+ } catch (e) {
333
+ console.error(`Cannot find values for ${ this.id } (unable to fetch)`, e); // eslint-disable-line no-console
334
+ }
335
+ }
336
+
337
+ get secretId() {
338
+ const metadata = this.metadata;
339
+ const secretReference = metadata.ownerReferences?.find((ow) => ow.kind.toLowerCase() === SECRET);
340
+
341
+ const secretId = secretReference?.name;
342
+ const secretNamespace = metadata.namespace;
343
+
344
+ if (!secretNamespace || !secretId) {
345
+ console.warn(`Cannot find values for ${ this.id } (cannot find related secret namespace or id)`); // eslint-disable-line no-console
346
+
347
+ return null;
348
+ }
349
+
350
+ return `${ secretNamespace }/${ secretId }`;
351
+ }
352
+
353
+ get _secret() {
354
+ return this.secretId ? this.$getters['byId'](SECRET, this.secretId) : null;
355
+ }
356
+
357
+ _validateSecret(noun) {
358
+ if (this._secret === undefined) {
359
+ throw new Error(`Cannot find ${ noun } for ${ this.id } (chart secret has not been fetched via app \`fetchValues\`)`);
360
+ }
361
+
362
+ if (this._secret === null) {
363
+ throw new Error(`Cannot find ${ noun } for ${ this.id } (chart secret cannot or has failed to fetch) `);
364
+ }
365
+ }
366
+
367
+ /**
368
+ * The user's helm values
369
+ */
370
+ get values() {
371
+ this._validateSecret('values');
372
+
373
+ return this._values;
374
+ }
375
+
376
+ get _values() {
377
+ return this._secret?.data?.release?.config;
378
+ }
379
+
380
+ /**
381
+ * The Charts default helm values
382
+ */
383
+ get chartValues() {
384
+ this._validateSecret('chartValues');
385
+
386
+ return this._chartValues;
387
+ }
388
+
389
+ get _chartValues() {
390
+ return this._secret?.data?.release?.chart?.values;
304
391
  }
305
392
  }
306
393
 
@@ -1,11 +1,65 @@
1
- import { CAPI } from '@shell/config/labels-annotations';
1
+ import { CAPI, CLOUD_CREDENTIALS } from '@shell/config/labels-annotations';
2
2
  import { fullFields, prefixFields, simplify, suffixFields } from '@shell/store/plugins';
3
3
  import { isEmpty, set } from '@shell/utils/object';
4
- import { SECRET } from '@shell/config/types';
4
+ import { MANAGEMENT, SECRET } from '@shell/config/types';
5
5
  import { escapeHtml } from '@shell/utils/string';
6
6
  import NormanModel from '@shell/plugins/steve/norman-class';
7
+ import { DATE_FORMAT, TIME_FORMAT } from '@shell/store/prefs';
8
+ import day from 'dayjs';
9
+
10
+ const harvesterProvider = 'harvester';
11
+
12
+ const renew = {
13
+ [harvesterProvider]: {
14
+ renew: ({ cloudCredential, $ctx }) => {
15
+ return renew[harvesterProvider].renewBulk(
16
+ { cloudCredentials: [cloudCredential], $ctx }
17
+ );
18
+ },
19
+ renewBulk: async({ cloudCredentials, $ctx }) => {
20
+ // A harvester cloud credential (at the moment) is a kubeconfig complete with expiring token
21
+ // So to renew we just need to generate a new kubeconfig and save it to the cc (similar to shell/cloud-credential/harvester.vue)
22
+ await Promise.all(cloudCredentials.map(async(cc) => {
23
+ try {
24
+ if (!cc.harvestercredentialConfig?.clusterId) {
25
+ throw new Error(`credential has no matching harvester cluster`);
26
+ }
27
+ const mgmtCluster = $ctx.rootGetters['management/byId'](MANAGEMENT.CLUSTER, cc.harvestercredentialConfig.clusterId);
28
+
29
+ if (!mgmtCluster) {
30
+ throw new Error(`cannot find harvester cluster`);
31
+ }
32
+
33
+ const kubeconfigContent = await mgmtCluster.generateKubeConfig();
34
+
35
+ cc.setData('kubeconfigContent', kubeconfigContent);
36
+
37
+ await cc.save();
38
+ } catch (error) {
39
+ console.error(`Unable to refresh harvester cloud credential '${ cc.id }'`, error); // eslint-disable-line no-console
40
+ }
41
+ }));
42
+ }
43
+ }
44
+ };
7
45
 
8
46
  export default class CloudCredential extends NormanModel {
47
+ get _availableActions() {
48
+ const out = super._availableActions;
49
+
50
+ out.splice(0, 0, { divider: true });
51
+ out.splice(0, 0, {
52
+ action: 'renew',
53
+ enabled: this.canRenew,
54
+ bulkable: this.canBulkRenew,
55
+ bulkAction: 'renewBulk',
56
+ icon: 'icon icon-fw icon-refresh',
57
+ label: this.t('manager.cloudCredentials.renew'),
58
+ });
59
+
60
+ return out;
61
+ }
62
+
9
63
  get hasSensitiveData() {
10
64
  return true;
11
65
  }
@@ -177,4 +231,107 @@ export default class CloudCredential extends NormanModel {
177
231
  get doneRoute() {
178
232
  return 'c-cluster-manager-secret';
179
233
  }
234
+
235
+ get canRenew() {
236
+ return !!renew[this.provider]?.renew && this.expires !== undefined && this.canUpdate;
237
+ }
238
+
239
+ get canBulkRenew() {
240
+ return !!renew[this.provider]?.renewBulk;
241
+ }
242
+
243
+ get expiresForSort() {
244
+ // Why not just `expires`?
245
+ // Ensures the correct sort order:
246
+ // 'expired' --> 'expiring soon' --> 'never expires'
247
+ return this.expires !== undefined ? this.expires : Number.MAX_SAFE_INTEGER;
248
+ }
249
+
250
+ get expires() {
251
+ const expires = this.annotations[CLOUD_CREDENTIALS.EXPIRATION];
252
+
253
+ if (typeof expires === 'string') {
254
+ return parseInt(expires);
255
+ } else if (typeof expires === 'number') {
256
+ return expires;
257
+ }
258
+
259
+ return undefined; // Weird things happen if this isn't a number
260
+ }
261
+
262
+ get expireData() {
263
+ if (typeof this.expiresIn !== 'number') {
264
+ return null;
265
+ }
266
+
267
+ const sevenDays = 1000 * 60 * 60 * 24 * 7;
268
+
269
+ if (this.expiresIn === 0) {
270
+ return {
271
+ expired: true,
272
+ expiring: false,
273
+ };
274
+ } else if (this.expiresIn < sevenDays) {
275
+ return {
276
+ expired: false,
277
+ expiring: true,
278
+ };
279
+ }
280
+
281
+ return null;
282
+ }
283
+
284
+ get expiresString() {
285
+ if (this.expires === undefined) {
286
+ return '';
287
+ }
288
+
289
+ if (this.expireData.expired) {
290
+ return this.t('manager.cloudCredentials.expired');
291
+ }
292
+
293
+ const dateFormat = escapeHtml( this.$rootGetters['prefs/get'](DATE_FORMAT));
294
+ const timeFormat = escapeHtml( this.$rootGetters['prefs/get'](TIME_FORMAT));
295
+
296
+ return day(this.expires).format(`${ dateFormat } ${ timeFormat }`);
297
+ }
298
+
299
+ get expiresIn() {
300
+ if (this.expires === undefined) {
301
+ return null;
302
+ }
303
+
304
+ const timeThen = this.expires;
305
+ const timeNow = Date.now();
306
+
307
+ const expiresIn = timeThen - timeNow;
308
+
309
+ return expiresIn < 0 ? 0 : expiresIn;
310
+ }
311
+
312
+ renew() {
313
+ const renewFn = renew[this.provider]?.renew;
314
+
315
+ if (!renewFn) {
316
+ console.error('No fn renew function for ', this.provider); // eslint-disable-line no-console
317
+ }
318
+
319
+ return renewFn({
320
+ cloudCredential: this,
321
+ $ctx: this.$ctx
322
+ });
323
+ }
324
+
325
+ async renewBulk(cloudCredentials = []) {
326
+ const renewBulkFn = renew[this.provider]?.renewBulk;
327
+
328
+ if (!renewBulkFn) {
329
+ console.error('No fn renew bulk function for ', this.provider); // eslint-disable-line no-console
330
+ }
331
+
332
+ return renewBulkFn({
333
+ cloudCredentials,
334
+ $ctx: this.$ctx
335
+ });
336
+ }
180
337
  }
@@ -308,20 +308,11 @@ export default class GitRepo extends SteveModel {
308
308
  bundle.namespacedName.startsWith(`${ this.namespace }:${ this.name }`));
309
309
  }
310
310
 
311
+ /**
312
+ * Bundles with state of active
313
+ */
311
314
  get bundlesReady() {
312
- if (this.bundles && this.bundles.length) {
313
- return this.bundles.filter((bundle) => bundle.state === 'active');
314
- }
315
-
316
- return 0;
317
- }
318
-
319
- get targetClustersReady() {
320
- if (this.targetClusters && this.targetClusters.length) {
321
- return this.targetClusters.filter((cluster) => cluster.state === 'active');
322
- }
323
-
324
- return 0;
315
+ return this.bundles?.filter((bundle) => bundle.state === 'active');
325
316
  }
326
317
 
327
318
  get bundleDeployments() {
@@ -16,6 +16,8 @@ import { KONTAINER_TO_DRIVER } from './management.cattle.io.kontainerdriver';
16
16
  import { PINNED_CLUSTERS } from '@shell/store/prefs';
17
17
  import { copyTextToClipboard } from '@shell/utils/clipboard';
18
18
 
19
+ const DEFAULT_BADGE_COLOR = '#707070';
20
+
19
21
  // See translation file cluster.providers for list of providers
20
22
  // If the logo is not named with the provider name, add an override here
21
23
  const PROVIDER_LOGO_OVERRIDE = {};
@@ -306,13 +308,22 @@ export default class MgmtCluster extends SteveModel {
306
308
  return undefined;
307
309
  }
308
310
 
309
- const color = this.metadata?.annotations[CLUSTER_BADGE.COLOR] || '#7f7f7f';
311
+ let color = this.metadata?.annotations[CLUSTER_BADGE.COLOR] || DEFAULT_BADGE_COLOR;
310
312
  const iconText = this.metadata?.annotations[CLUSTER_BADGE.ICON_TEXT] || '';
313
+ let foregroundColor;
314
+
315
+ try {
316
+ foregroundColor = textColor(parseColor(color.trim())); // Remove any whitespace
317
+ } catch (_e) {
318
+ // If we could not parse the badge color, use the defaults
319
+ color = DEFAULT_BADGE_COLOR;
320
+ foregroundColor = textColor(parseColor(color));
321
+ }
311
322
 
312
323
  return {
313
324
  text: comment || undefined,
314
325
  color,
315
- textColor: textColor(parseColor(color)),
326
+ textColor: foregroundColor,
316
327
  iconText: iconText.substr(0, 3)
317
328
  };
318
329
  }
@@ -10,7 +10,6 @@ import { compare } from '@shell/utils/version';
10
10
  import { AS, MODE, _VIEW, _YAML } from '@shell/config/query-params';
11
11
  import { HARVESTER_NAME as HARVESTER } from '@shell/config/features';
12
12
  import { CAPI as CAPI_ANNOTATIONS, NODE_ARCHITECTURE } from '@shell/config/labels-annotations';
13
- import capitalize from 'lodash/capitalize';
14
13
 
15
14
  /**
16
15
  * Class representing Cluster resource.
@@ -166,6 +165,19 @@ export default class ProvCluster extends SteveModel {
166
165
  enabled: canSaveRKETemplate,
167
166
  }, { divider: true }];
168
167
 
168
+ // Harvester Cluster 1:1 Harvester Cloud Cred
169
+ if (this.cloudCredential?.canRenew || this.cloudCredential?.canBulkRenew) {
170
+ out.splice(0, 0, { divider: true });
171
+ out.splice(0, 0, {
172
+ action: 'renew',
173
+ enabled: this.cloudCredential?.canRenew,
174
+ bulkable: this.cloudCredential?.canBulkRenew,
175
+ bulkAction: 'renewBulk',
176
+ icon: 'icon icon-fw icon-refresh',
177
+ label: this.$rootGetters['i18n/t']('cluster.cloudCredentials.renew'),
178
+ });
179
+ }
180
+
169
181
  return actions.concat(out);
170
182
  }
171
183
 
@@ -411,7 +423,7 @@ export default class ProvCluster extends SteveModel {
411
423
  if (!node.metadata?.state?.transitioning) {
412
424
  const architecture = node.status?.nodeLabels?.[NODE_ARCHITECTURE];
413
425
 
414
- const key = architecture ? capitalize(architecture) : this.t('cluster.architecture.label.unknown');
426
+ const key = architecture || this.t('cluster.architecture.label.unknown');
415
427
 
416
428
  obj[key] = (obj[key] || 0) + 1;
417
429
  }
@@ -885,7 +897,8 @@ export default class ProvCluster extends SteveModel {
885
897
  get agentConfig() {
886
898
  // The one we want is the first one with no selector.
887
899
  // If there are multiple with no selector, that will fall under the unsupported message below.
888
- return this.spec.rkeConfig.machineSelectorConfig.find((x) => !x.machineLabelSelector)?.config;
900
+ return this.spec.rkeConfig?.machineSelectorConfig
901
+ ?.find((x) => !x.machineLabelSelector)?.config || { };
889
902
  }
890
903
 
891
904
  get cloudProvider() {
@@ -990,4 +1003,25 @@ export default class ProvCluster extends SteveModel {
990
1003
  get description() {
991
1004
  return super.description || this.mgmt?.description;
992
1005
  }
1006
+
1007
+ renew() {
1008
+ return this.cloudCredential?.renew();
1009
+ }
1010
+
1011
+ renewBulk(clusters = []) {
1012
+ // In theory we don't need to filter by cloudCred, but do so for safety
1013
+ const cloudCredentials = clusters.filter((c) => c.cloudCredential).map((c) => c.cloudCredential);
1014
+
1015
+ return this.cloudCredential?.renewBulk(cloudCredentials);
1016
+ }
1017
+
1018
+ get cloudCredential() {
1019
+ return this.$rootGetters['rancher/all'](NORMAN.CLOUD_CREDENTIAL).find((cc) => cc.id === this.spec.cloudCredentialSecretName);
1020
+ }
1021
+
1022
+ get cloudCredentialWarning() {
1023
+ const expireData = this.cloudCredential?.expireData;
1024
+
1025
+ return expireData?.expired || expireData?.expiring;
1026
+ }
993
1027
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rancher/shell",
3
- "version": "2.0.2-rc.1",
3
+ "version": "2.0.2",
4
4
  "description": "Rancher Dashboard Shell",
5
5
  "repository": "https://github.com/rancherlabs/dashboard",
6
6
  "license": "Apache-2.0",
@@ -302,8 +302,9 @@ export default {
302
302
  */
303
303
  userValues = diff(this.loadedVersionValues, this.chartValues);
304
304
  } else if ( this.existing ) {
305
+ await this.existing.fetchValues(); // In theory this has already been called, but do again to be safe
305
306
  /* For an already installed app, use the values from the previous install. */
306
- userValues = clone(this.existing.spec?.values || {});
307
+ userValues = clone(this.existing.values || {});
307
308
  } else {
308
309
  /* For an new app, start empty. */
309
310
  userValues = {};
@@ -163,7 +163,7 @@ describe('page: cluster dashboard', () => {
163
163
  ['created', 'glance.created', []],
164
164
  ['architecture', 'mixed', [{ labels: { [NODE_ARCHITECTURE]: 'amd64' } }, { labels: { [NODE_ARCHITECTURE]: 'intel' } }]],
165
165
  ['architecture', 'mixed', [{ labels: { [NODE_ARCHITECTURE]: 'amd64' } }, { labels: { } }]],
166
- ['architecture', 'Amd64', [{ labels: { [NODE_ARCHITECTURE]: 'amd64' } }]],
166
+ ['architecture', 'amd64', [{ labels: { [NODE_ARCHITECTURE]: 'amd64' } }]],
167
167
  ['architecture', 'unknown', [{ labels: { } }]],
168
168
  ['architecture', '—', [{ metadata: { state: { transitioning: true } } }]],
169
169
  ])('should show %p label %p', (label, text, nodes) => {
@@ -47,7 +47,6 @@ import Certificates from '@shell/components/Certificates';
47
47
  import { NAME as EXPLORER } from '@shell/config/product/explorer';
48
48
  import TabTitle from '@shell/components/TabTitle';
49
49
  import { STATES_ENUM } from '@shell/plugins/dashboard-store/resource-class';
50
- import capitalize from 'lodash/capitalize';
51
50
  import paginationUtils from '@shell/utils/pagination-utils';
52
51
 
53
52
  export const RESOURCES = [NAMESPACE, INGRESS, PV, WORKLOAD_TYPES.DEPLOYMENT, WORKLOAD_TYPES.STATEFUL_SET, WORKLOAD_TYPES.JOB, WORKLOAD_TYPES.DAEMON_SET, SERVICE];
@@ -216,7 +215,7 @@ export default {
216
215
  if (!node.metadata?.state?.transitioning) {
217
216
  const architecture = node.labels?.[NODE_ARCHITECTURE];
218
217
 
219
- const key = architecture ? capitalize(architecture) : this.t('cluster.architecture.label.unknown');
218
+ const key = architecture || this.t('cluster.architecture.label.unknown');
220
219
 
221
220
  obj[key] = (obj[key] || 0) + 1;
222
221
  }
@@ -12,6 +12,7 @@ import { WORKSPACE_ANNOTATION } from '@shell/config/labels-annotations';
12
12
  import { filterBy } from '@shell/utils/array';
13
13
  import FleetNoWorkspaces from '@shell/components/fleet/FleetNoWorkspaces.vue';
14
14
  import { NAME } from '@shell/config/product/fleet';
15
+ import { xOfy } from '@shell/utils/string';
15
16
 
16
17
  export default {
17
18
  name: 'FleetDashboard',
@@ -175,7 +176,12 @@ export default {
175
176
  }
176
177
 
177
178
  if (area === 'clusters') {
178
- group = row.targetClusters;
179
+ if (row.clusterInfo?.ready === row.clusterInfo?.total && row.clusterInfo?.ready) {
180
+ return {
181
+ badgeClass: STATES[STATES_ENUM.ACTIVE].color,
182
+ icon: STATES[STATES_ENUM.ACTIVE].compoundIcon
183
+ };
184
+ }
179
185
  } else if (area === 'bundles') {
180
186
  group = row.bundles;
181
187
  } else if (area === 'resources') {
@@ -223,7 +229,7 @@ export default {
223
229
  }
224
230
 
225
231
  if (area === 'clusters') {
226
- group = row.targetClusters;
232
+ group = '';
227
233
  } else if (area === 'bundles') {
228
234
  group = row.bundles;
229
235
  } else if (area === 'resources') {
@@ -262,11 +268,11 @@ export default {
262
268
  }
263
269
 
264
270
  if (area === 'clusters') {
265
- value = `${ row.targetClustersReady?.length || '0' }/${ row.targetClusters?.length || '?' }`;
271
+ return `${ row.clusterInfo.ready }/${ row.clusterInfo.total }`;
266
272
  } else if (area === 'bundles') {
267
- value = `${ row.bundlesReady?.length || '0' }/${ row.bundles?.length || '?' }`;
273
+ value = xOfy(row.bundlesReady?.length, row.bundles?.length);
268
274
  } else if (area === 'resources') {
269
- value = `${ row.status?.resourceCounts?.ready || '0' }/${ row.status?.resourceCounts?.desiredReady || '?' }`;
275
+ value = xOfy(row.status?.resourceCounts?.ready, row.status?.resourceCounts?.desiredReady);
270
276
  }
271
277
 
272
278
  return value;
@@ -9,21 +9,31 @@ import {
9
9
  ID_UNLINKED,
10
10
  NAME_UNLINKED,
11
11
  } from '@shell/config/table-headers';
12
+ import { allHash } from 'utils/promise';
13
+ import { Banner } from '@components/Banner';
12
14
 
13
15
  export default {
14
16
  components: {
15
17
  Loading,
16
18
  ResourceTable,
17
19
  Masthead,
20
+ Banner
18
21
  },
19
22
 
20
23
  async fetch() {
24
+ const promises = {};
25
+
21
26
  if (this.$store.getters['management/schemaFor'](SECRET) && !this.$store.getters[`cluster/paginationEnabled`](SECRET)) {
22
27
  // Having secrets allows showing the public portion of more types but not all users can see them.
23
- await this.$store.dispatch('management/findAll', { type: SECRET });
28
+ promises.secrets = this.$store.dispatch('management/findAll', { type: SECRET });
24
29
  }
30
+ promises.allCredentials = this.$store.dispatch('rancher/findAll', { type: NORMAN.CLOUD_CREDENTIAL });
31
+
32
+ const hash = await allHash(promises);
25
33
 
26
- this.allCredentials = await this.$store.dispatch('rancher/findAll', { type: NORMAN.CLOUD_CREDENTIAL });
34
+ this.allCredentials = hash.allCredentials;
35
+ // This can be optimized in future to to a quick fetch for those with annotation `"provisioning.cattle.io/driver": "harvester"`
36
+ this.hasHarvester = !!this.allCredentials.find((cc) => !!cc.harvestercredentialConfig);
27
37
  },
28
38
 
29
39
  data() {
@@ -40,7 +50,7 @@ export default {
40
50
  },
41
51
 
42
52
  headers() {
43
- return [
53
+ const headers = [
44
54
  ID_UNLINKED,
45
55
  NAME_UNLINKED,
46
56
  {
@@ -52,8 +62,21 @@ export default {
52
62
  formatter: 'CloudCredPublicData',
53
63
  },
54
64
  DESCRIPTION,
55
- AGE_NORMAN,
56
65
  ];
66
+
67
+ if (this.hasHarvester) {
68
+ headers.push({
69
+ name: 'expiresDate',
70
+ labelKey: 'tableHeaders.expires',
71
+ value: 'expires',
72
+ sort: 'expiresForSort',
73
+ formatter: 'CloudCredExpired',
74
+ });
75
+ }
76
+
77
+ headers.push(AGE_NORMAN);
78
+
79
+ return headers;
57
80
  },
58
81
 
59
82
  createLocation() {
@@ -65,6 +88,29 @@ export default {
65
88
  },
66
89
  };
67
90
  },
91
+
92
+ expiredData() {
93
+ const counts = this.allCredentials.reduce((res, cc) => {
94
+ const expireData = cc.expireData;
95
+
96
+ if (expireData?.expiring) {
97
+ res.expiring++;
98
+ }
99
+ if (expireData?.expired) {
100
+ res.expired++;
101
+ }
102
+
103
+ return res;
104
+ }, {
105
+ expiring: 0,
106
+ expired: 0
107
+ });
108
+
109
+ return {
110
+ expiring: counts.expiring ? this.t('manager.cloudCredentials.banners.expiring', { count: counts.expiring }) : '',
111
+ expired: counts.expired ? this.t('manager.cloudCredentials.banners.expired', { count: counts.expired }) : '',
112
+ };
113
+ }
68
114
  },
69
115
 
70
116
  };
@@ -79,6 +125,18 @@ export default {
79
125
  :create-location="createLocation"
80
126
  :type-display="t('manager.cloudCredentials.label')"
81
127
  />
128
+ <Banner
129
+ v-if="expiredData.expiring"
130
+ data-testid="cert-expiring-banner"
131
+ color="warning"
132
+ :label="expiredData.expiring"
133
+ />
134
+ <Banner
135
+ v-if="expiredData.expired"
136
+ color="error"
137
+ :label="expiredData.expired"
138
+ />
139
+
82
140
  <ResourceTable
83
141
  :schema="schema"
84
142
  :rows="rows"
@@ -92,3 +150,9 @@ export default {
92
150
  </ResourceTable>
93
151
  </div>
94
152
  </template>
153
+
154
+ <style lang="scss" scoped>
155
+ .banner {
156
+ margin: 0 0 10px 0
157
+ }
158
+ </style>