Merge pull request #4588 from rancher/workload-health-scaling

Show Workload Pod/Job State and Replica Scale buttons
This commit is contained in:
Richard Cox 2021-11-24 11:30:50 +00:00 committed by GitHub
commit ea5592322c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 533 additions and 121 deletions

View File

@ -4195,6 +4195,7 @@ tableHeaders:
one { Host } one { Host }
other { Hosts } other { Hosts }
} }
health: Health
id: ID id: ID
image: Image image: Image
imageSize: Size imageSize: Size
@ -4732,6 +4733,8 @@ workload:
label: Successful Job History Limit label: Successful Job History Limit
tip: The number of successful finished jobs to retain. tip: The number of successful finished jobs to retain.
suspend: Suspend suspend: Suspend
list:
errorCannotScale: Failed to scale {workloadName} {direction, select, up { up } down { down } }
metrics: metrics:
pod: Pod Metrics pod: Pod Metrics
metricsView: Metrics View metricsView: Metrics View

View File

@ -97,6 +97,7 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.growl-container { .growl-container {
z-index: 15;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;

View File

@ -63,6 +63,15 @@ export default {
type: String, type: String,
default: 'resourceTable.groupBy.namespace', default: 'resourceTable.groupBy.namespace',
}, },
overflowX: {
type: Boolean,
default: false
},
overflowY: {
type: Boolean,
default: false
},
}, },
computed: { computed: {
@ -263,6 +272,8 @@ export default {
:paging-params="pagingParams" :paging-params="pagingParams"
:paging-label="pagingLabel" :paging-label="pagingLabel"
:table-actions="_showBulkActions" :table-actions="_showBulkActions"
:overflow-x="overflowX"
:overflow-y="overflowY"
key-field="_key" key-field="_key"
:sort-generation-fn="sortGenerationFn" :sort-generation-fn="sortGenerationFn"
v-on="$listeners" v-on="$listeners"

View File

@ -135,7 +135,7 @@ export default {
:class="{ sortable: col.sort, [col.breakpoint]: !!col.breakpoint}" :class="{ sortable: col.sort, [col.breakpoint]: !!col.breakpoint}"
@click.prevent="changeSort($event, col)" @click.prevent="changeSort($event, col)"
> >
<span v-if="col.sort" @click="$router.applyQuery(queryFor(col))"> <span v-if="col.sort" v-tooltip="col.tooltip" @click="$router.applyQuery(queryFor(col))">
<span v-html="labelFor(col)" /> <span v-html="labelFor(col)" />
<span class="icon-stack"> <span class="icon-stack">
<i class="icon icon-sort icon-stack-1x faded" /> <i class="icon icon-sort icon-stack-1x faded" />
@ -143,7 +143,7 @@ export default {
<i v-if="isCurrent(col) && descending" class="icon icon-sort-up icon-stack-1x" /> <i v-if="isCurrent(col) && descending" class="icon icon-sort-up icon-stack-1x" />
</span> </span>
</span> </span>
<span v-else>{{ labelFor(col) }}</span> <span v-else v-tooltip="col.tooltip">{{ labelFor(col) }}</span>
</th> </th>
<th v-if="rowActions" :width="rowActionsWidth"> <th v-if="rowActions" :width="rowActionsWidth">
</th> </th>

View File

@ -173,6 +173,15 @@ export default {
default: false default: false
}, },
overflowX: {
type: Boolean,
default: false
},
overflowY: {
type: Boolean,
default: false
},
/** /**
* If pagination of the data is enabled or not * If pagination of the data is enabled or not
*/ */
@ -391,12 +400,15 @@ export default {
classObject() { classObject() {
return { return {
'top-divider': this.topDivider, 'top-divider': this.topDivider,
'body-dividers': this.bodyDividers 'body-dividers': this.bodyDividers,
'overflow-y': this.overflowY,
'overflow-x': this.overflowX,
}; };
} }
}, },
methods: { methods: {
get, get,
dasherize, dasherize,
@ -675,7 +687,7 @@ export default {
:key="col.name" :key="col.name"
:data-title="labelFor(col)" :data-title="labelFor(col)"
:align="col.align || 'left'" :align="col.align || 'left'"
:class="{['col-'+dasherize(col.formatter||'')]: !!col.formatter, [col.breakpoint]: !!col.breakpoint}" :class="{['col-'+dasherize(col.formatter||'')]: !!col.formatter, [col.breakpoint]: !!col.breakpoint, ['skip-select']: col.skipSelect}"
:width="col.width" :width="col.width"
> >
<slot :name="'cell:' + col.name" :row="row" :col="col" :value="valueFor(row,col)"> <slot :name="'cell:' + col.name" :row="row" :col="col" :value="valueFor(row,col)">
@ -686,6 +698,7 @@ export default {
:row="row" :row="row"
:col="col" :col="col"
v-bind="col.formatterOpts" v-bind="col.formatterOpts"
:row-key="get(row,keyField)"
/> />
<template v-else-if="valueFor(row,col) !== ''"> <template v-else-if="valueFor(row,col) !== ''">
{{ formatValue(row,col) }} {{ formatValue(row,col) }}
@ -831,6 +844,13 @@ $spacing: 10px;
background: var(--sortable-table-bg); background: var(--sortable-table-bg);
border-radius: 4px; border-radius: 4px;
&.overflow-x {
overflow-x: visible;
}
&.overflow-y {
overflow-y: visible;
}
td { td {
padding: 8px 5px; padding: 8px 5px;
border: 0; border: 0;

View File

@ -179,6 +179,11 @@ export default {
async onRowClick(e) { async onRowClick(e) {
const node = this.nodeForEvent(e); const node = this.nodeForEvent(e);
const td = $(e.target).closest('TD'); const td = $(e.target).closest('TD');
const skipSelect = td.hasClass('skip-select');
if (skipSelect) {
return;
}
const selection = this.selectedNodes; const selection = this.selectedNodes;
const isCheckbox = this.isSelectionCheckbox(e.target) || td.hasClass('row-check'); const isCheckbox = this.isSelectionCheckbox(e.target) || td.hasClass('row-check');
const isExpand = td.hasClass('row-expand'); const isExpand = td.hasClass('row-expand');

View File

@ -0,0 +1,71 @@
<script>
export default {
props: {
value: {
type: Number,
required: true,
},
minusTooltip: {
type: String,
default: null,
},
plusTooltip: {
type: String,
default: null,
},
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: null,
},
disabled: {
type: Boolean,
default: false,
}
},
computed: {
canMinus() {
return (this.min !== undefined && this.min !== null) ? this.value > this.min : true;
},
canPlus() {
return (this.max !== undefined && this.max !== null) ? this.value < this.max : true;
}
}
};
</script>
<template>
<div class="plus-minus">
<button v-tooltip="minusTooltip" :disabled="disabled || !canMinus" type="button" class="btn btn-sm role-secondary" @click="$emit('minus')">
<i class="icon icon-sm icon-minus" />
</button>
<div class="value">
{{ value }}
</div>
<button v-tooltip="plusTooltip" :disabled="disabled || !canPlus" type="button" class="btn btn-sm role-secondary" @click="$emit('plus')">
<i class="icon icon-sm icon-plus" />
</button>
</div>
</template>
<style lang="scss" scoped>
.plus-minus {
min-width: 70px;
display: flex;
align-items: center;
button:hover {
background-color: var(--accent-btn);
}
.value {
width: 25px;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,243 @@
<script>
import Vue from 'vue';
import ProgressBarMulti from '@/components/ProgressBarMulti';
import PlusMinus from '@/components/form/PlusMinus';
import { SCALABLE_WORKLOAD_TYPES } from '~/config/types';
import { ucFirst } from '~/utils/string';
const SCALABLE_TYPES = Object.values(SCALABLE_WORKLOAD_TYPES);
export default {
components: { PlusMinus, ProgressBarMulti },
props: {
row: {
type: Object,
required: true
},
col: {
type: Object,
required: true
},
rowKey: {
type: String,
required: true
},
},
mounted() {
document.addEventListener('click', this.onClickOutside);
},
beforeDestroy() {
document.removeEventListener('click', this.onClickOutside);
},
data() {
return { disabled: false, expanded: false };
},
computed: {
id() {
return `${ this.rowKey }-workload-health-scale`.replaceAll('-', '');
},
canScale() {
return !!SCALABLE_TYPES.includes(this.row.type) && this.row.canUpdate;
},
parts() {
return Object.entries(this.row.jobGauges || this.row.podGauges || [])
.map(([name, value]) => ({
color: `bg-${ value.color }`,
value: value.count || 0,
label: ucFirst(name)
})).filter(x => x.value > 0);
},
},
methods: {
onClickOutside(event) {
const { [`root-${ this.id }`]: component } = this.$refs;
if (!component || component.contains(event.target)) {
return;
}
this.expanded = false;
},
async scaleDown() {
await this.scale(false);
},
async scaleUp() {
await this.scale(true);
},
async scale(isUp) {
Vue.set(this, 'disabled', true);
try {
if (isUp) {
await this.row.scaleUp();
} else {
await this.row.scaleDown();
}
} catch (err) {
this.$store.dispatch('growl/fromError', {
title: this.t('workload.list.errorCannotScale', { direction: isUp ? 'up' : 'down', workloadName: this.row.name }),
err
},
{ root: true });
}
Vue.set(this, 'disabled', false);
},
insideBounds(bounding, bounds) {
return bounding.top >= bounds.top &&
bounding.left >= bounds.left &&
bounding.right <= bounds.right &&
bounding.bottom <= bounds.bottom;
},
},
watch: {
expanded(neu) {
// If the drop down content appears outside of the window then move it to be above the trigger
// Do this is three steps
// expanded: false & expanded-checked = false - Content does not appear in DOM
// expanded: true & expanded-checked = false - Content appears in DOM (so it's location can be calcualated to be in or out of an area) but isn't visible (user doesn't see content blip from below to above trigger)
// expanded: true & expanded-checked = true - Content appears in DOM and is visible (it's final location is known so user can see)
setTimeout(() => { // There be beasts without this (classes don't get applied... so drop down never gets shown)
const dropdown = document.getElementById(this.id);
if (!neu) {
dropdown.classList.remove('expanded-checked');
return;
}
// Ensire drop down will be inside of the window, otherwise show above the trigger
const bounding = dropdown.getBoundingClientRect();
const insideWindow = this.insideBounds(bounding, {
top: 0,
left: 0,
right: window.innerWidth || document.documentElement.clientWidth,
bottom: window.innerHeight || document.documentElement.clientHeight,
});
if (insideWindow) {
dropdown.classList.remove('out-of-view');
} else {
dropdown.classList.add('out-of-view');
}
// This will trigger the actual display of the drop down (after we've calculated if it goes below or above trigger)
dropdown.classList.add('expanded-checked');
});
}
}
};
</script>
<template>
<div :id="`root-${id}`" :ref="`root-${id}`" class="hs-popover">
<div id="trigger" class="hs-popover__trigger" :class="{expanded}" @click="expanded = !expanded">
<ProgressBarMulti v-if="parts" class="health" :values="parts" :show-zeros="true" />
<i :class="{icon: true, 'icon-chevron-up': expanded, 'icon-chevron-down': !expanded}" />
</div>
<div :id="id" class="hs-popover__content" :class="{expanded, [id]:true}">
<div>
<div v-for="obj in parts" :key="obj.label" class="counts">
<span>{{ obj.label }}</span>
<span>{{ obj.value }}</span>
</div>
<div v-if="canScale" class="text-center scale">
<span>{{ t('tableHeaders.scale') }} </span>
<PlusMinus :value="row.spec.replicas" :disabled="disabled" @minus="scaleDown" @plus="scaleUp" />
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
$height: 30px;
$width: 150px;
.hs-popover {
position: relative;
&__trigger {
display: flex;
align-items: center;
width: $width;
height: $height;
border: solid thin var(--sortable-table-top-divider);
&.expanded {
background-color: var(--sortable-table-row-bg);
}
&:not(.expanded):hover {
background-color: var(--accent-btn);
.icon {
color: unset;
}
}
.health {
width: $width - $height; // height is width of icon;
margin-left: 5px;
}
.icon {
font-size: $height;
width: $height;
color: var(--primary);
margin-top: 1px;
}
}
&__content {
z-index: 14;
width: $width;
border: solid thin var(--sortable-table-top-divider);
background-color: var(--sortable-table-row-bg);
position: absolute;
margin-top: -1px;
display: none;
visibility: hidden;
&.expanded {
display: inline;
}
&.expanded-checked {
visibility: visible;
}
&.out-of-view {
// Flip to show drop down above trigger
bottom: 0;
margin-bottom: $height - 1px;
}
& > div {
padding: 10px;
}
.counts {
display: flex;
justify-content: space-between;
}
.scale {
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: baseline;
}
}
}
</style>

View File

@ -16,7 +16,7 @@ import {
USER_ID, USERNAME, USER_DISPLAY_NAME, USER_PROVIDER, WORKLOAD_ENDPOINTS, STORAGE_CLASS_DEFAULT, USER_ID, USERNAME, USER_DISPLAY_NAME, USER_PROVIDER, WORKLOAD_ENDPOINTS, STORAGE_CLASS_DEFAULT,
STORAGE_CLASS_PROVISIONER, PERSISTENT_VOLUME_SOURCE, STORAGE_CLASS_PROVISIONER, PERSISTENT_VOLUME_SOURCE,
HPA_REFERENCE, MIN_REPLICA, MAX_REPLICA, CURRENT_REPLICA, 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'; } from '@/config/table-headers';
import { DSL } from '@/store/type-map'; 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(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(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, [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]); 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]); 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]); 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]); 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]); 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]); 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]); 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(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]); headers(STORAGE_CLASS, [STATE, NAME_COL, STORAGE_CLASS_PROVISIONER, STORAGE_CLASS_DEFAULT, AGE]);

View File

@ -1,5 +1,6 @@
import { CATTLE_PUBLIC_ENDPOINTS } from '@/config/labels-annotations'; import { CATTLE_PUBLIC_ENDPOINTS } from '@/config/labels-annotations';
import { NODE as NODE_TYPE } from '@/config/types'; 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. // 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'], 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 = { export const WORKLOAD_ENDPOINTS = {
name: 'workloadEndpoints', name: 'workloadEndpoints',
@ -610,6 +615,15 @@ export const WORKLOAD_ENDPOINTS = {
value: `$['metadata']['annotations']['${ CATTLE_PUBLIC_ENDPOINTS }']`, value: `$['metadata']['annotations']['${ CATTLE_PUBLIC_ENDPOINTS }']`,
formatter: 'Endpoints', formatter: 'Endpoints',
dashIfEmpty: true, 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 = { export const FLEET_SUMMARY = {

View File

@ -8,7 +8,6 @@ import Loading from '@/components/Loading';
import ResourceTabs from '@/components/form/ResourceTabs'; import ResourceTabs from '@/components/form/ResourceTabs';
import CountGauge from '@/components/CountGauge'; import CountGauge from '@/components/CountGauge';
import { allHash } from '@/utils/promise'; import { allHash } from '@/utils/promise';
import { get } from '@/utils/object';
import DashboardMetrics from '@/components/DashboardMetrics'; import DashboardMetrics from '@/components/DashboardMetrics';
import V1WorkloadMetrics from '@/mixins/v1-workload-metrics'; import V1WorkloadMetrics from '@/mixins/v1-workload-metrics';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
@ -72,16 +71,7 @@ export default {
computed: { computed: {
...mapGetters(['currentCluster']), ...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() { isJob() {
return this.value.type === WORKLOAD_TYPES.JOB; return this.value.type === WORKLOAD_TYPES.JOB;
}, },
@ -108,24 +98,6 @@ export default {
return this.podTemplateSpec?.containers[0]; 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() { jobSchema() {
return this.$store.getters['cluster/schemaFor'](WORKLOAD_TYPES.JOB); return this.$store.getters['cluster/schemaFor'](WORKLOAD_TYPES.JOB);
}, },
@ -134,38 +106,12 @@ export default {
return this.$store.getters['type-map/headersFor'](this.jobSchema); 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() { totalRuns() {
if (!this.jobs) { if (!this.value.jobs) {
return; return;
} }
return this.jobs.reduce((total, job) => { return this.value.jobs.reduce((total, job) => {
const { status = {} } = job; const { status = {} } = job;
total += (status.active || 0); total += (status.active || 0);
@ -177,7 +123,7 @@ export default {
}, },
podRestarts() { podRestarts() {
return this.pods.reduce((total, pod) => { return this.value.pods.reduce((total, pod) => {
const { status:{ containerStatuses = [] } } = pod; const { status:{ containerStatuses = [] } } = pod;
if (containerStatuses.length) { if (containerStatuses.length) {
@ -192,39 +138,6 @@ export default {
}, 0); }, 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() { podHeaders() {
return [ return [
STATE, STATE,
@ -255,12 +168,12 @@ export default {
<h3> <h3>
{{ isJob || isCronJob ? t('workload.detailTop.runs') :t('workload.detailTop.pods') }} {{ isJob || isCronJob ? t('workload.detailTop.runs') :t('workload.detailTop.pods') }}
</h3> </h3>
<div v-if="pods || jobGauges" class="gauges mb-20"> <div v-if="value.pods || value.jobGauges" class="gauges mb-20">
<template v-if="jobGauges"> <template v-if="value.jobGauges">
<CountGauge <CountGauge
v-for="(group, key) in jobGauges" v-for="(group, key) in value.jobGauges"
:key="key" :key="key"
:total="isCronJob? totalRuns : pods.length" :total="isCronJob? totalRuns : value.pods.length"
:useful="group.count || 0" :useful="group.count || 0"
:primary-color-var="`--sizzle-${group.color}`" :primary-color-var="`--sizzle-${group.color}`"
:name="t(`workload.gaugeStates.${key}`)" :name="t(`workload.gaugeStates.${key}`)"
@ -268,9 +181,9 @@ export default {
</template> </template>
<template v-else> <template v-else>
<CountGauge <CountGauge
v-for="(group, key) in podGauges" v-for="(group, key) in value.podGauges"
:key="key" :key="key"
:total="pods.length" :total="value.pods.length"
:useful="group.count || 0" :useful="group.count || 0"
:primary-color-var="`--sizzle-${group.color}`" :primary-color-var="`--sizzle-${group.color}`"
:name="t(`workload.gaugeStates.${key}`)" :name="t(`workload.gaugeStates.${key}`)"
@ -280,7 +193,7 @@ export default {
<ResourceTabs :value="value"> <ResourceTabs :value="value">
<Tab v-if="isCronJob" name="jobs" :label="t('tableHeaders.jobs')" :weight="4"> <Tab v-if="isCronJob" name="jobs" :label="t('tableHeaders.jobs')" :weight="4">
<SortableTable <SortableTable
:rows="jobs" :rows="value.jobs"
:headers="jobHeaders" :headers="jobHeaders"
key-field="id" key-field="id"
:schema="jobSchema" :schema="jobSchema"
@ -290,8 +203,8 @@ export default {
</Tab> </Tab>
<Tab v-else name="pods" :label="t('tableHeaders.pods')" :weight="4"> <Tab v-else name="pods" :label="t('tableHeaders.pods')" :weight="4">
<SortableTable <SortableTable
v-if="pods" v-if="value.pods"
:rows="pods" :rows="value.pods"
:headers="podHeaders" :headers="podHeaders"
key-field="id" key-field="id"
:table-actions="false" :table-actions="false"

View File

@ -1,6 +1,6 @@
<script> <script>
import ResourceTable from '@/components/ResourceTable'; 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'; import Loading from '@/components/Loading';
const schema = { const schema = {
@ -28,6 +28,8 @@ export default {
let resources; let resources;
this.loadHeathResources();
if ( this.allTypes ) { if ( this.allTypes ) {
resources = await Promise.all(Object.values(WORKLOAD_TYPES).map((type) => { resources = await Promise.all(Object.values(WORKLOAD_TYPES).map((type) => {
// You may not have RBAC to see some of the types // 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() { typeDisplay() {
const { params:{ resource:type } } = this.$route; const { params:{ resource:type } } = this.$route;
let paramSchema = schema; let paramSchema = schema;
@ -103,5 +128,5 @@ export default {
<template> <template>
<Loading v-if="$fetchState.pending" /> <Loading v-if="$fetchState.pending" />
<ResourceTable v-else :schema="schema" :rows="rows" /> <ResourceTable v-else :schema="schema" :rows="rows" :overflow-y="true" />
</template> </template>

View File

@ -142,7 +142,7 @@ export default class ClusterRepo extends SteveModel {
get stateObj() { get stateObj() {
return this.metadata?.state ? { return this.metadata?.state ? {
...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; } : undefined;
} }

View File

@ -1,6 +1,6 @@
import { findBy, insertAt } from '@/utils/array'; import { findBy, insertAt } from '@/utils/array';
import { TARGET_WORKLOADS, TIMESTAMP, UI_MANAGED } from '@/config/labels-annotations'; 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 { clone, get, set } from '@/utils/object';
import day from 'dayjs'; import day from 'dayjs';
import SteveModel from '@/plugins/steve/steve-class'; import SteveModel from '@/plugins/steve/steve-class';
@ -127,6 +127,20 @@ export default class Workload extends SteveModel {
this.save(); 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() { get state() {
if ( this.spec?.paused === true ) { if ( this.spec?.paused === true ) {
return 'paused'; return 'paused';
@ -612,4 +626,93 @@ export default class Workload extends SteveModel {
return null; 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;
}
} }

View File

@ -932,15 +932,18 @@ export const getters = {
const exists = rootGetters['i18n/exists']; const exists = rootGetters['i18n/exists'];
const t = rootGetters['i18n/t']; const t = rootGetters['i18n/t'];
const labelKey = `tableHeaders.${ col.name }`; const labelKey = `tableHeaders.${ col.name }`;
const description = col.description || '';
const tooltip = description && description[description.length - 1] === '.' ? description.slice(0, -1) : description;
return { return {
name: col.name.toLowerCase(), name: col.name.toLowerCase(),
label: exists(labelKey) ? t(labelKey) : col.name, label: exists(labelKey) ? t(labelKey) : col.name,
value: col.field.startsWith('.') ? `$${ col.field }` : col.field, value: col.field.startsWith('.') ? `$${ col.field }` : col.field,
sort: [col.field], sort: [col.field],
formatter, formatter,
formatterOpts, formatterOpts,
width, width,
tooltip
}; };
} }
}; };