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 }
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

View File

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

View File

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

View File

@ -135,7 +135,7 @@ export default {
:class="{ sortable: col.sort, [col.breakpoint]: !!col.breakpoint}"
@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 class="icon-stack">
<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" />
</span>
</span>
<span v-else>{{ labelFor(col) }}</span>
<span v-else v-tooltip="col.tooltip">{{ labelFor(col) }}</span>
</th>
<th v-if="rowActions" :width="rowActionsWidth">
</th>

View File

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

View File

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

View File

@ -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 = {

View File

@ -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 {
<h3>
{{ isJob || isCronJob ? t('workload.detailTop.runs') :t('workload.detailTop.pods') }}
</h3>
<div v-if="pods || jobGauges" class="gauges mb-20">
<template v-if="jobGauges">
<div v-if="value.pods || value.jobGauges" class="gauges mb-20">
<template v-if="value.jobGauges">
<CountGauge
v-for="(group, key) in jobGauges"
v-for="(group, key) in value.jobGauges"
:key="key"
:total="isCronJob? totalRuns : pods.length"
:total="isCronJob? totalRuns : value.pods.length"
:useful="group.count || 0"
:primary-color-var="`--sizzle-${group.color}`"
:name="t(`workload.gaugeStates.${key}`)"
@ -268,9 +181,9 @@ export default {
</template>
<template v-else>
<CountGauge
v-for="(group, key) in podGauges"
v-for="(group, key) in value.podGauges"
:key="key"
:total="pods.length"
:total="value.pods.length"
:useful="group.count || 0"
:primary-color-var="`--sizzle-${group.color}`"
:name="t(`workload.gaugeStates.${key}`)"
@ -280,7 +193,7 @@ export default {
<ResourceTabs :value="value">
<Tab v-if="isCronJob" name="jobs" :label="t('tableHeaders.jobs')" :weight="4">
<SortableTable
:rows="jobs"
:rows="value.jobs"
:headers="jobHeaders"
key-field="id"
:schema="jobSchema"
@ -290,8 +203,8 @@ export default {
</Tab>
<Tab v-else name="pods" :label="t('tableHeaders.pods')" :weight="4">
<SortableTable
v-if="pods"
:rows="pods"
v-if="value.pods"
:rows="value.pods"
:headers="podHeaders"
key-field="id"
:table-actions="false"

View File

@ -1,6 +1,6 @@
<script>
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 {
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable v-else :schema="schema" :rows="rows" />
<ResourceTable v-else :schema="schema" :rows="rows" :overflow-y="true" />
</template>

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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
};
}
};