diff --git a/assets/translations/en-us.yaml b/assets/translations/en-us.yaml
index f4aa4a505a..5edfda1c37 100644
--- a/assets/translations/en-us.yaml
+++ b/assets/translations/en-us.yaml
@@ -4195,6 +4195,7 @@ tableHeaders:
one { Host }
other { Hosts }
}
+ health: Health
id: ID
image: Image
imageSize: Size
@@ -4732,6 +4733,8 @@ workload:
label: Successful Job History Limit
tip: The number of successful finished jobs to retain.
suspend: Suspend
+ list:
+ errorCannotScale: Failed to scale {workloadName} {direction, select, up { up } down { down } }
metrics:
pod: Pod Metrics
metricsView: Metrics View
diff --git a/components/GrowlManager.vue b/components/GrowlManager.vue
index db0d9bf6ea..4b24d3d108 100644
--- a/components/GrowlManager.vue
+++ b/components/GrowlManager.vue
@@ -97,6 +97,7 @@ export default {
diff --git a/components/formatter/WorkloadHealthScale.vue b/components/formatter/WorkloadHealthScale.vue
new file mode 100644
index 0000000000..ad9cd490d8
--- /dev/null
+++ b/components/formatter/WorkloadHealthScale.vue
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+
+ {{ obj.label }}
+ {{ obj.value }}
+
+
+
{{ t('tableHeaders.scale') }}
+
+
+
+
+
+
+
+
diff --git a/config/product/explorer.js b/config/product/explorer.js
index fd2dca969f..d184baae64 100644
--- a/config/product/explorer.js
+++ b/config/product/explorer.js
@@ -16,7 +16,7 @@ import {
USER_ID, USERNAME, USER_DISPLAY_NAME, USER_PROVIDER, WORKLOAD_ENDPOINTS, STORAGE_CLASS_DEFAULT,
STORAGE_CLASS_PROVISIONER, PERSISTENT_VOLUME_SOURCE,
HPA_REFERENCE, MIN_REPLICA, MAX_REPLICA, CURRENT_REPLICA,
- ACCESS_KEY, DESCRIPTION, EXPIRES, EXPIRY_STATE, SUB_TYPE, AGE_NORMAN, SCOPE_NORMAN, PERSISTENT_VOLUME_CLAIM, RECLAIM_POLICY, PV_REASON
+ ACCESS_KEY, DESCRIPTION, EXPIRES, EXPIRY_STATE, SUB_TYPE, AGE_NORMAN, SCOPE_NORMAN, PERSISTENT_VOLUME_CLAIM, RECLAIM_POLICY, PV_REASON, WORKLOAD_HEALTH_SCALE
} from '@/config/table-headers';
import { DSL } from '@/store/type-map';
@@ -168,14 +168,14 @@ export function init(store) {
headers(SERVICE, [STATE, NAME_COL, NAMESPACE_COL, TARGET_PORT, SELECTOR, SPEC_TYPE, AGE]);
headers(HPA, [STATE, NAME_COL, HPA_REFERENCE, MIN_REPLICA, MAX_REPLICA, CURRENT_REPLICA, AGE]);
- headers(WORKLOAD, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, TYPE, 'Ready', AGE]);
- headers(WORKLOAD_TYPES.DEPLOYMENT, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Up-to-date', 'Available', AGE]);
- headers(WORKLOAD_TYPES.DAEMON_SET, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', AGE]);
- headers(WORKLOAD_TYPES.REPLICA_SET, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', AGE]);
- headers(WORKLOAD_TYPES.STATEFUL_SET, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', AGE]);
- headers(WORKLOAD_TYPES.JOB, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Completions', 'Duration', AGE]);
- headers(WORKLOAD_TYPES.CRON_JOB, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Schedule', 'Last Schedule', AGE]);
- headers(WORKLOAD_TYPES.REPLICATION_CONTROLLER, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', AGE]);
+ headers(WORKLOAD, [STATE, NAME_COL, NAMESPACE_COL, TYPE, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, AGE, WORKLOAD_HEALTH_SCALE]);
+ headers(WORKLOAD_TYPES.DEPLOYMENT, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Up-to-date', 'Available', AGE, WORKLOAD_HEALTH_SCALE]);
+ headers(WORKLOAD_TYPES.DAEMON_SET, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', AGE, WORKLOAD_HEALTH_SCALE]);
+ headers(WORKLOAD_TYPES.REPLICA_SET, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', AGE, WORKLOAD_HEALTH_SCALE]);
+ headers(WORKLOAD_TYPES.STATEFUL_SET, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', AGE, WORKLOAD_HEALTH_SCALE]);
+ headers(WORKLOAD_TYPES.JOB, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Completions', 'Duration', AGE, WORKLOAD_HEALTH_SCALE]);
+ headers(WORKLOAD_TYPES.CRON_JOB, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Schedule', 'Last Schedule', AGE, WORKLOAD_HEALTH_SCALE]);
+ headers(WORKLOAD_TYPES.REPLICATION_CONTROLLER, [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', AGE, WORKLOAD_HEALTH_SCALE]);
headers(POD, [STATE, NAME_COL, NAMESPACE_COL, POD_IMAGES, 'Ready', 'Restarts', 'IP', NODE_COL, AGE]);
headers(STORAGE_CLASS, [STATE, NAME_COL, STORAGE_CLASS_PROVISIONER, STORAGE_CLASS_DEFAULT, AGE]);
diff --git a/config/table-headers.js b/config/table-headers.js
index 844c126ff5..86e5b8ff24 100644
--- a/config/table-headers.js
+++ b/config/table-headers.js
@@ -1,5 +1,6 @@
import { CATTLE_PUBLIC_ENDPOINTS } from '@/config/labels-annotations';
import { NODE as NODE_TYPE } from '@/config/types';
+import { COLUMN_BREAKPOINTS } from '@/components/SortableTable/index.vue';
// Note: 'id' is always the last sort, so you don't have to specify it here.
@@ -602,7 +603,11 @@ export const WORKSPACE = {
sort: ['metadata.namespace', 'nameSort'],
};
-export const WORKLOAD_IMAGES = { ...POD_IMAGES, value: '' };
+export const WORKLOAD_IMAGES = {
+ ...POD_IMAGES,
+ value: '',
+ breakpoint: COLUMN_BREAKPOINTS.LAPTOP
+};
export const WORKLOAD_ENDPOINTS = {
name: 'workloadEndpoints',
@@ -610,6 +615,15 @@ export const WORKLOAD_ENDPOINTS = {
value: `$['metadata']['annotations']['${ CATTLE_PUBLIC_ENDPOINTS }']`,
formatter: 'Endpoints',
dashIfEmpty: true,
+ breakpoint: COLUMN_BREAKPOINTS.DESKTOP
+};
+
+export const WORKLOAD_HEALTH_SCALE = {
+ name: 'workloadHealthScale',
+ labelKey: 'tableHeaders.health',
+ formatter: 'WorkloadHealthScale',
+ width: 150,
+ skipSelect: true
};
export const FLEET_SUMMARY = {
diff --git a/detail/workload/index.vue b/detail/workload/index.vue
index 12386655f3..353c9aa4d8 100644
--- a/detail/workload/index.vue
+++ b/detail/workload/index.vue
@@ -8,7 +8,6 @@ import Loading from '@/components/Loading';
import ResourceTabs from '@/components/form/ResourceTabs';
import CountGauge from '@/components/CountGauge';
import { allHash } from '@/utils/promise';
-import { get } from '@/utils/object';
import DashboardMetrics from '@/components/DashboardMetrics';
import V1WorkloadMetrics from '@/mixins/v1-workload-metrics';
import { mapGetters } from 'vuex';
@@ -72,16 +71,7 @@ export default {
computed: {
...mapGetters(['currentCluster']),
- pods() {
- const relationships = get(this.value, 'metadata.relationships') || [];
- const podRelationship = relationships.filter(relationship => relationship.toType === POD)[0];
- if (podRelationship) {
- return this.$store.getters['cluster/matching'](POD, podRelationship.selector).filter(pod => pod?.metadata?.namespace === this.value.metadata.namespace);
- } else {
- return [];
- }
- },
isJob() {
return this.value.type === WORKLOAD_TYPES.JOB;
},
@@ -108,24 +98,6 @@ export default {
return this.podTemplateSpec?.containers[0];
},
- jobRelationships() {
- if (!this.isCronJob) {
- return;
- }
-
- return (get(this.value, 'metadata.relationships') || []).filter(relationship => relationship.toType === WORKLOAD_TYPES.JOB);
- },
-
- jobs() {
- if (!this.isCronJob) {
- return;
- }
-
- return this.jobRelationships.map((obj) => {
- return this.$store.getters['cluster/byId'](WORKLOAD_TYPES.JOB, obj.toId );
- }).filter(x => !!x);
- },
-
jobSchema() {
return this.$store.getters['cluster/schemaFor'](WORKLOAD_TYPES.JOB);
},
@@ -134,38 +106,12 @@ export default {
return this.$store.getters['type-map/headersFor'](this.jobSchema);
},
- jobGauges() {
- const out = {
- succeeded: { color: 'success', count: 0 }, running: { color: 'info', count: 0 }, failed: { color: 'error', count: 0 }
- };
-
- if (this.value.type === WORKLOAD_TYPES.CRON_JOB) {
- this.jobs.forEach((job) => {
- const { status = {} } = job;
-
- out.running.count += status.active || 0;
- out.succeeded.count += status.succeeded || 0;
- out.failed.count += status.failed || 0;
- });
- } else if (this.value.type === WORKLOAD_TYPES.JOB) {
- const { status = {} } = this.value;
-
- out.running.count = status.active || 0;
- out.succeeded.count = status.succeeded || 0;
- out.failed.count = status.failed || 0;
- } else {
- return null;
- }
-
- return out;
- },
-
totalRuns() {
- if (!this.jobs) {
+ if (!this.value.jobs) {
return;
}
- return this.jobs.reduce((total, job) => {
+ return this.value.jobs.reduce((total, job) => {
const { status = {} } = job;
total += (status.active || 0);
@@ -177,7 +123,7 @@ export default {
},
podRestarts() {
- return this.pods.reduce((total, pod) => {
+ return this.value.pods.reduce((total, pod) => {
const { status:{ containerStatuses = [] } } = pod;
if (containerStatuses.length) {
@@ -192,39 +138,6 @@ export default {
}, 0);
},
- podGauges() {
- const out = {
- active: { color: 'success' }, transitioning: { color: 'info' }, warning: { color: 'warning' }, error: { color: 'error' }
- };
-
- if (!this.pods) {
- return out;
- }
-
- this.pods.map((pod) => {
- const { status:{ phase } } = pod;
- let group;
-
- switch (phase) {
- case 'Running':
- group = 'active';
- break;
- case 'Pending':
- group = 'transitioning';
- break;
- case 'Failed':
- group = 'error';
- break;
- default:
- group = 'warning';
- }
-
- out[group].count ? out[group].count++ : out[group].count = 1;
- });
-
- return out;
- },
-
podHeaders() {
return [
STATE,
@@ -255,12 +168,12 @@ export default {
{{ isJob || isCronJob ? t('workload.detailTop.runs') :t('workload.detailTop.pods') }}
-
-
+
+
import ResourceTable from '@/components/ResourceTable';
-import { WORKLOAD_TYPES, SCHEMA, NODE } from '@/config/types';
+import { WORKLOAD_TYPES, SCHEMA, NODE, POD } from '@/config/types';
import Loading from '@/components/Loading';
const schema = {
@@ -28,6 +28,8 @@ export default {
let resources;
+ this.loadHeathResources();
+
if ( this.allTypes ) {
resources = await Promise.all(Object.values(WORKLOAD_TYPES).map((type) => {
// You may not have RBAC to see some of the types
@@ -88,6 +90,29 @@ export default {
},
},
+ methods: {
+ loadHeathResources() {
+ // Fetch these in the background to populate workload health
+ if ( this.allTypes ) {
+ this.$store.dispatch('cluster/findAll', { type: POD });
+ this.$store.dispatch('cluster/findAll', { type: WORKLOAD_TYPES.JOB });
+ } else {
+ const type = this.$route.params.resource;
+
+ if (type === WORKLOAD_TYPES.JOB) {
+ // Ignore job (we're fetching this anyway, plus they contain their own state)
+ return;
+ }
+
+ if (type === WORKLOAD_TYPES.CRON_JOB) {
+ this.$store.dispatch('cluster/findAll', { type: WORKLOAD_TYPES.JOB });
+ } else {
+ this.$store.dispatch('cluster/findAll', { type: POD });
+ }
+ }
+ }
+ },
+
typeDisplay() {
const { params:{ resource:type } } = this.$route;
let paramSchema = schema;
@@ -103,5 +128,5 @@ export default {
-
+
diff --git a/models/catalog.cattle.io.clusterrepo.js b/models/catalog.cattle.io.clusterrepo.js
index a0bcef849e..a12a2be618 100644
--- a/models/catalog.cattle.io.clusterrepo.js
+++ b/models/catalog.cattle.io.clusterrepo.js
@@ -142,7 +142,7 @@ export default class ClusterRepo extends SteveModel {
get stateObj() {
return this.metadata?.state ? {
...this.metadata.state,
- transitioning: this.metadata.generation > this.status.observedGeneration ? false : this.metadata.state.transitioning
+ transitioning: this.metadata.generation > this.status?.observedGeneration ? false : this.metadata.state.transitioning
} : undefined;
}
diff --git a/models/workload.js b/models/workload.js
index 62fcac965c..f34a973ca1 100644
--- a/models/workload.js
+++ b/models/workload.js
@@ -1,6 +1,6 @@
import { findBy, insertAt } from '@/utils/array';
import { TARGET_WORKLOADS, TIMESTAMP, UI_MANAGED } from '@/config/labels-annotations';
-import { WORKLOAD_TYPES, SERVICE } from '@/config/types';
+import { POD, WORKLOAD_TYPES, SERVICE } from '@/config/types';
import { clone, get, set } from '@/utils/object';
import day from 'dayjs';
import SteveModel from '@/plugins/steve/steve-class';
@@ -127,6 +127,20 @@ export default class Workload extends SteveModel {
this.save();
}
+ async scaleDown() {
+ const newScale = this.spec.replicas - 1;
+
+ if (newScale >= 0) {
+ set(this.spec, 'replicas', newScale);
+ await this.save();
+ }
+ }
+
+ async scaleUp() {
+ set(this.spec, 'replicas', this.spec.replicas + 1);
+ await this.save();
+ }
+
get state() {
if ( this.spec?.paused === true ) {
return 'paused';
@@ -612,4 +626,93 @@ export default class Workload extends SteveModel {
return null;
}
}
+
+ get pods() {
+ const relationships = get(this, 'metadata.relationships') || [];
+ const podRelationship = relationships.filter(relationship => relationship.toType === POD)[0];
+
+ if (podRelationship) {
+ return this.$getters['matching'](POD, podRelationship.selector).filter(pod => pod?.metadata?.namespace === this.metadata.namespace);
+ } else {
+ return [];
+ }
+ }
+
+ get podGauges() {
+ const out = {
+ active: { color: 'success' }, transitioning: { color: 'info' }, warning: { color: 'warning' }, error: { color: 'error' }
+ };
+
+ if (!this.pods) {
+ return out;
+ }
+
+ this.pods.map((pod) => {
+ const { status:{ phase } } = pod;
+ let group;
+
+ switch (phase) {
+ case 'Running':
+ group = 'active';
+ break;
+ case 'Pending':
+ group = 'transitioning';
+ break;
+ case 'Failed':
+ group = 'error';
+ break;
+ default:
+ group = 'warning';
+ }
+
+ out[group].count ? out[group].count++ : out[group].count = 1;
+ });
+
+ return out;
+ }
+
+ // Job Specific
+ get jobRelationships() {
+ if (this.type !== WORKLOAD_TYPES.CRON_JOB) {
+ return undefined;
+ }
+
+ return (get(this, 'metadata.relationships') || []).filter(relationship => relationship.toType === WORKLOAD_TYPES.JOB);
+ }
+
+ get jobs() {
+ if (this.type !== WORKLOAD_TYPES.CRON_JOB) {
+ return undefined;
+ }
+
+ return this.jobRelationships.map((obj) => {
+ return this.$getters['byId'](WORKLOAD_TYPES.JOB, obj.toId );
+ }).filter(x => !!x);
+ }
+
+ get jobGauges() {
+ const out = {
+ succeeded: { color: 'success', count: 0 }, running: { color: 'info', count: 0 }, failed: { color: 'error', count: 0 }
+ };
+
+ if (this.type === WORKLOAD_TYPES.CRON_JOB) {
+ this.jobs.forEach((job) => {
+ const { status = {} } = job;
+
+ out.running.count += status.active || 0;
+ out.succeeded.count += status.succeeded || 0;
+ out.failed.count += status.failed || 0;
+ });
+ } else if (this.type === WORKLOAD_TYPES.JOB) {
+ const { status = {} } = this;
+
+ out.running.count = status.active || 0;
+ out.succeeded.count = status.succeeded || 0;
+ out.failed.count = status.failed || 0;
+ } else {
+ return null;
+ }
+
+ return out;
+ }
}
diff --git a/store/type-map.js b/store/type-map.js
index a4e1344753..4f4a4dc7ec 100644
--- a/store/type-map.js
+++ b/store/type-map.js
@@ -932,15 +932,18 @@ export const getters = {
const exists = rootGetters['i18n/exists'];
const t = rootGetters['i18n/t'];
const labelKey = `tableHeaders.${ col.name }`;
+ const description = col.description || '';
+ const tooltip = description && description[description.length - 1] === '.' ? description.slice(0, -1) : description;
return {
- name: col.name.toLowerCase(),
- label: exists(labelKey) ? t(labelKey) : col.name,
- value: col.field.startsWith('.') ? `$${ col.field }` : col.field,
- sort: [col.field],
+ name: col.name.toLowerCase(),
+ label: exists(labelKey) ? t(labelKey) : col.name,
+ value: col.field.startsWith('.') ? `$${ col.field }` : col.field,
+ sort: [col.field],
formatter,
formatterOpts,
width,
+ tooltip
};
}
};