Merge pull request #3293 from richard-cox/beefier-kube-nodes

Add grouping and more info to the explorer kube node list
This commit is contained in:
Richard Cox 2021-06-28 13:54:23 +01:00 committed by GitHub
commit 3f97fcf101
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 845 additions and 93 deletions

View File

@ -41,8 +41,8 @@ $z-indexes: (
// @media only screen and (min-width: map-get($breakpoints, '--viewport-*')) { // @media only screen and (min-width: map-get($breakpoints, '--viewport-*')) {
// } // }
$breakpoints: ( $breakpoints: (
'--viewport-4': 480px, '--viewport-4': 480px, // Phone
'--viewport-7': 768px, '--viewport-7': 768px, // Tablet
'--viewport-9': 992px, '--viewport-9': 992px, // Laptop/Desktop
'--viewport-12': 1281px, '--viewport-12': 1281px, // Desktop
); );

View File

@ -475,6 +475,10 @@ asyncButton:
action: Download action: Download
success: Saving success: Saving
waiting: Downloading… waiting: Downloading…
drain:
action: Drain
success: Drained
waiting: Draining…
edit: edit:
action: Save action: Save
success: Saved success: Saved
@ -1136,6 +1140,7 @@ cluster:
cluster: Cluster Configuration cluster: Cluster Configuration
etcd: etcd etcd: etcd
networking: Networking networking: Networking
nodePools: Node Pools
machinePools: Machine Pools machinePools: Machine Pools
registry: Private Registry registry: Private Registry
upgrade: Upgrade Strategy upgrade: Upgrade Strategy
@ -1145,6 +1150,9 @@ cluster:
services: Services services: Services
allServices: Rotate all service certificates allServices: Rotate all service certificates
selectService: Rotate an individual service selectService: Rotate an individual service
nodePool:
scaleDown: Scale Pool Down
scaleUp: Scale Pool Up
clusterIndexPage: clusterIndexPage:
hardwareResourceGauge: hardwareResourceGauge:
@ -1223,6 +1231,27 @@ detailText:
other {+ {n, number} more chars} other {+ {n, number} more chars}
} }
drainNode:
action: 'Drain'
actionStop: 'Stop Drain'
titleOne: Drain {name}
titleMultiple: 'Drain {count} Nodes'
deleteLocalData: Delete Empty Dir Data
force: Force
safe:
label: Safe
helpText: If a node has standalone pods or ephemeral data it will be cordoned but not drained.
gracePeriod:
title: Grace period for pods to terminate themselves
default: Honor the default from each pod
placeholder: e.g. 30
custom: "Ignore the defaults and give each pod:"
timeout:
title: "Drain timeout"
default: Keep trying forever
placeholder: e.g. 60
custom: "Give up after:"
etcdInfoBanner: etcdInfoBanner:
hasLeader: "Etcd has a leader:" hasLeader: "Etcd has a leader:"
leaderChanges: "Number of leader changes:" leaderChanges: "Number of leader changes:"
@ -2110,6 +2139,12 @@ namespaceList:
addLabel: Add Namespace addLabel: Add Namespace
node: node:
list:
pool: Pool
nodeTaint: Taints
poolDescription:
noSize: No Size
noLocation: No Location
detail: detail:
detailTop: detailTop:
containerRuntime: Container Runtime containerRuntime: Container Runtime
@ -2888,6 +2923,8 @@ resourceTable:
project: "<span>Project:</span> {name}" project: "<span>Project:</span> {name}"
notInAWorkspace: Not in a Workspace notInAWorkspace: Not in a Workspace
workspace: "<span>Workspace:</span> {name}" workspace: "<span>Workspace:</span> {name}"
notInANodePool: "Not in a Pool"
nodePool: "<span>Node Pool:</span> {name} ({count})"
resourceTabs: resourceTabs:
conditions: conditions:
@ -3542,6 +3579,7 @@ tableHeaders:
subType: Kind subType: Kind
success: Success success: Success
summary: Summary summary: Summary
taints: Taints
target: Target target: Target
targetKind: Target Type targetKind: Target Type
targetPort: Target targetPort: Target

View File

@ -132,7 +132,7 @@ export default {
:key="col.name" :key="col.name"
:align="col.align || 'left'" :align="col.align || 'left'"
:width="col.width" :width="col.width"
:class="{ sortable: col.sort }" :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" @click="$router.applyQuery(queryFor(col))">
@ -180,6 +180,26 @@ export default {
& A { & A {
color: var(--body-text); color: var(--body-text);
} }
// Aligns with COLUMN_BREAKPOINTS
@media only screen and (max-width: map-get($breakpoints, '--viewport-4')) {
// HIDE column on sizes below 480px
&.tablet, &.laptop, &.desktop {
display: none;
}
}
@media only screen and (max-width: map-get($breakpoints, '--viewport-9')) {
// HIDE column on sizes below 992px
&.laptop, &.desktop {
display: none;
}
}
@media only screen and (max-width: map-get($breakpoints, '--viewport-12')) {
// HIDE column on sizes below 1281px
&.desktop {
display: none;
}
}
} }
.icon-stack { .icon-stack {

View File

@ -13,6 +13,21 @@ import sorting from './sorting';
import paging from './paging'; import paging from './paging';
import grouping from './grouping'; import grouping from './grouping';
export const COLUMN_BREAKPOINTS = {
/**
* Only show column if at tablet width or wider
*/
TABLET: 'tablet',
/**
* Only show column if at laptop width or wider
*/
LAPTOP: 'laptop',
/**
* Only show column if at desktop width or wider
*/
DESKTOP: 'desktop'
};
// @TODO: // @TODO:
// Fixed header/scrolling // Fixed header/scrolling
@ -598,7 +613,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}" :class="{['col-'+dasherize(col.formatter||'')]: !!col.formatter, [col.breakpoint]: !!col.breakpoint}"
: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)">
@ -708,6 +723,26 @@ export default {
box-shadow: none; box-shadow: none;
} }
} }
// Aligns with COLUMN_BREAKPOINTS
@media only screen and (max-width: map-get($breakpoints, '--viewport-4')) {
// HIDE column on sizes below 480px
&.tablet, &.laptop, &.desktop {
display: none;
}
}
@media only screen and (max-width: map-get($breakpoints, '--viewport-9')) {
// HIDE column on sizes below 992px
&.laptop, &.desktop {
display: none;
}
}
@media only screen and (max-width: map-get($breakpoints, '--viewport-12')) {
// HIDE column on sizes below 1281px
&.desktop {
display: none;
}
}
} }
</style> </style>

View File

@ -111,7 +111,7 @@ export default {
onRowMouseEnter(e) { onRowMouseEnter(e) {
const tr = $(e.target).closest('TR'); const tr = $(e.target).closest('TR');
if (tr.hasClass('state-description')) { if (tr.hasClass('sub-row')) {
const trMainRow = tr.prev('TR'); const trMainRow = tr.prev('TR');
trMainRow.toggleClass('sub-row-hovered', true); trMainRow.toggleClass('sub-row-hovered', true);
@ -121,7 +121,7 @@ export default {
onRowMouseLeave(e) { onRowMouseLeave(e) {
const tr = $(e.target).closest('TR'); const tr = $(e.target).closest('TR');
if (tr.hasClass('state-description')) { if (tr.hasClass('sub-row')) {
const trMainRow = tr.prev('TR'); const trMainRow = tr.prev('TR');
trMainRow.toggleClass('sub-row-hovered', false); trMainRow.toggleClass('sub-row-hovered', false);

View File

@ -0,0 +1,218 @@
<script>
import Vue from 'vue';
import AsyncButton from '@/components/AsyncButton';
import Banner from '@/components/Banner';
import Card from '@/components/Card';
import RadioGroup from '@/components/form/RadioGroup';
import UnitInput from '@/components/form/UnitInput';
import { _EDIT, _VIEW } from '@/config/query-params';
import { exceptionToErrorsArray } from '@/utils/error';
export default {
components: {
AsyncButton,
Banner,
Card,
RadioGroup,
UnitInput
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
return {
radioOptions: [{
label: this.t('generic.yes'),
value: true,
}, {
label: this.t('generic.no'),
value: false,
}],
gracePeriodOptions: [{
label: this.t('drainNode.gracePeriod.default'),
value: false,
}, {
label: this.t('drainNode.gracePeriod.custom'),
value: true,
}],
timeoutOptions: [{
label: this.t('drainNode.timeout.default'),
value: false,
}, {
label: this.t('drainNode.timeout.custom'),
value: true,
}],
gracePeriod: false,
timeout: false,
body: {
deleteLocalData: false,
force: false,
gracePeriod: null,
timeout: null
},
EDIT: _EDIT,
VIEW: _VIEW,
errors: [],
};
},
computed: {
kubeNodes() {
return this.resources[0];
},
normanNodeId() {
return this.resources[1];
},
},
watch: {
gracePeriod(neu) {
if (neu && !this.body.gracePeriod) {
this.body.gracePeriod = 30;
}
},
'body.gracePeriod'(neu) {
if (neu && neu < 1) {
Vue.set(this.body, 'gracePeriod', 1);
}
},
timeout(neu) {
if (neu && !this.body.timeout) {
this.body.timeout = 60;
}
},
'body.timeout'(neu) {
if (neu) {
if (neu < 1) {
Vue.set(this.body, 'timeout', 1);
} else if (neu > 10800) {
Vue.set(this.body, 'timeout', 10800);
}
}
},
},
methods: {
close() {
this.$emit('close');
},
async apply(buttonDone) {
const { gracePeriod, timeout, ...parsedBody } = this.body;
if (this.gracePeriod) {
parsedBody.gracePeriod = gracePeriod;
}
if (this.timeout) {
parsedBody.timeout = timeout;
}
try {
await Promise.all(this.kubeNodes.map(node => node.normanNode.doAction('drain', parsedBody)));
this.close();
} catch (e) {
this.errors = exceptionToErrorsArray(e);
buttonDone(false);
}
}
}
};
</script>
<template>
<Card class="prompt-rotate" :show-highlight-border="false">
<h4 slot="title" class="text-default-text">
<template v-if="kubeNodes.length > 1">
{{ t('drainNode.titleMultiple', { count: kubeNodes.length }) }}
</template>
<template v-else>
{{ t('drainNode.titleOne', { name: kubeNodes[0].name }, true) }}
</template>
</h4>
<div slot="body" class="pl-10 pr-10">
<div>
<RadioGroup
v-model="body.deleteLocalData"
name="deleteLocalData"
:options="radioOptions"
:row="true"
class="mb-15"
>
<template #label>
<h5>{{ t('drainNode.deleteLocalData') }}</h5>
</template>
</RadioGroup>
<RadioGroup v-model="body.force" name="force" :options="radioOptions" :row="true" class="mb-15">
<template #label>
<h5>{{ t('drainNode.force') }}</h5>
</template>
</RadioGroup>
<RadioGroup v-model="gracePeriod" name="gracePeriod" :options="gracePeriodOptions" class="mb-15">
<template #label>
<h5>{{ t('drainNode.gracePeriod.title') }}</h5>
</template>
</RadioGroup>
<UnitInput
v-model="body.gracePeriod"
:mode="gracePeriod ? EDIT : VIEW"
type="number"
min="1"
:suffix="t('suffix.sec')"
:placeholder="t('drainNode.gracePeriod.placeholder')"
class="mb-10"
/>
<RadioGroup v-model="timeout" name="timeout" :options="timeoutOptions" class="mb-15">
<template #label>
<h5>{{ t('drainNode.timeout.title') }}</h5>
</template>
</RadioGroup>
<UnitInput
v-model="body.timeout"
:mode="timeout ? EDIT : VIEW"
type="number"
min="1"
max="10800"
:suffix="t('suffix.sec')"
:placeholder="t('drainNode.timeout.placeholder')"
/>
</div>
<Banner v-for="(err, i) in errors" :key="i" color="error" :label="err" />
</div>
<div slot="actions" class="buttons">
<button class="btn role-secondary mr-10" @click="close">
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="drain"
@click="apply"
>
</AsyncButton>
</div>
</Card>
</template>
<style lang='scss' scoped>
.prompt-rotate {
margin: 0;
}
.card-title h4 {
margin-bottom: 0;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>

View File

@ -121,7 +121,7 @@ export default {
<template> <template>
<div> <div>
<div v-if="label || labelKey || tooltip || tooltipKey" class="radio-group label"> <div v-if="label || labelKey || tooltip || tooltipKey || $slots.label" class="radio-group label">
<slot name="label"> <slot name="label">
<h3> <h3>
<t v-if="labelKey" :k="labelKey" /> <t v-if="labelKey" :k="labelKey" />

View File

@ -23,7 +23,7 @@ export default {
}, },
computed: { computed: {
percentage() { percentage() {
return Number.parseFloat(this.value) / 100; return Number.parseFloat(this.value);
} }
} }
}; };

View File

@ -11,7 +11,7 @@ import {
import { import {
STATE, NAME as NAME_COL, NAMESPACE as NAMESPACE_COL, AGE, KEYS, STATE, NAME as NAME_COL, NAMESPACE as NAMESPACE_COL, AGE, KEYS,
INGRESS_DEFAULT_BACKEND, INGRESS_TARGET, ROLES, VERSION, INTERNAL_EXTERNAL_IP, CPU, RAM, INGRESS_DEFAULT_BACKEND, INGRESS_TARGET,
SPEC_TYPE, TARGET_PORT, SELECTOR, NODE as NODE_COL, TYPE, WORKLOAD_IMAGES, POD_IMAGES, SPEC_TYPE, TARGET_PORT, SELECTOR, NODE as NODE_COL, TYPE, WORKLOAD_IMAGES, POD_IMAGES,
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,
@ -167,7 +167,6 @@ export function init(store) {
AGE AGE
]); ]);
headers(INGRESS, [STATE, NAME_COL, NAMESPACE_COL, INGRESS_TARGET, INGRESS_DEFAULT_BACKEND, AGE]); headers(INGRESS, [STATE, NAME_COL, NAMESPACE_COL, INGRESS_TARGET, INGRESS_DEFAULT_BACKEND, AGE]);
headers(NODE, [STATE, NAME_COL, ROLES, VERSION, INTERNAL_EXTERNAL_IP, CPU, RAM, AGE]);
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]);

View File

@ -198,8 +198,10 @@ export const PODS = {
name: 'pods', name: 'pods',
labelKey: 'tableHeaders.pods', labelKey: 'tableHeaders.pods',
sort: 'pods', sort: 'pods',
value: 'podUsage', search: false,
formatter: 'PercentageBar' value: 'podConsumedUsage',
formatter: 'PercentageBar',
width: 120,
}; };
export const AGE = { export const AGE = {

View File

@ -17,6 +17,8 @@ export const NORMAN = {
CLUSTER_TOKEN: 'clusterregistrationtoken', CLUSTER_TOKEN: 'clusterregistrationtoken',
CLUSTER_ROLE_TEMPLATE_BINDING: 'clusterRoleTemplateBinding', CLUSTER_ROLE_TEMPLATE_BINDING: 'clusterRoleTemplateBinding',
GROUP: 'group', GROUP: 'group',
// Note - This allows access to node resources, not schema's or custom components (both are accessed via 'type' which clashes with kube node)
NODE: 'node',
PRINCIPAL: 'principal', PRINCIPAL: 'principal',
PROJECT: 'project', PROJECT: 'project',
PROJECT_ROLE_TEMPLATE_BINDING: 'projectRoleTemplateBinding', PROJECT_ROLE_TEMPLATE_BINDING: 'projectRoleTemplateBinding',
@ -137,6 +139,7 @@ export const MANAGEMENT = {
FEATURE: 'management.cattle.io.feature', FEATURE: 'management.cattle.io.feature',
GROUP: 'management.cattle.io.group', GROUP: 'management.cattle.io.group',
KONTANIER_DRIVER: 'management.cattle.io.kontainerdriver', KONTANIER_DRIVER: 'management.cattle.io.kontainerdriver',
NODE: 'management.cattle.io.node',
NODE_DRIVER: 'management.cattle.io.nodedriver', NODE_DRIVER: 'management.cattle.io.nodedriver',
NODE_POOL: 'management.cattle.io.nodepool', NODE_POOL: 'management.cattle.io.nodepool',
NODE_TEMPLATE: 'management.cattle.io.nodetemplate', NODE_TEMPLATE: 'management.cattle.io.nodetemplate',

View File

@ -44,7 +44,6 @@ export default {
:value="value" :value="value"
:namespaced="false" :namespaced="false"
:mode="mode" :mode="mode"
:extra-columns="extraColumns"
/> />
<ResourceTabs v-model="value" :mode="mode"> <ResourceTabs v-model="value" :mode="mode">
<Tab name="taints" :label="t('node.detail.tab.taints')" :weight="0"> <Tab name="taints" :label="t('node.detail.tab.taints')" :weight="0">

View File

@ -1,16 +1,26 @@
<script> <script>
import ResourceTable from '@/components/ResourceTable'; import ResourceTable from '@/components/ResourceTable';
import Loading from '@/components/Loading'; import Loading from '@/components/Loading';
import Tag from '@/components/Tag';
import { import {
STATE, NAME, ROLES, VERSION, INTERNAL_EXTERNAL_IP, CPU, RAM STATE, NAME, ROLES, VERSION, INTERNAL_EXTERNAL_IP, CPU, RAM, PODS, AGE
} from '@/config/table-headers'; } from '@/config/table-headers';
import metricPoller from '@/mixins/metric-poller'; import metricPoller from '@/mixins/metric-poller';
import { CAPI, METRIC, NODE } from '@/config/types'; import {
MANAGEMENT, METRIC, NODE, NORMAN, POD
} from '@/config/types';
import { mapGetters } from 'vuex';
import { allHash } from '@/utils/promise';
import { get } from '@/utils/object';
import { GROUP_RESOURCES, mapPref } from '@/store/prefs';
import { COLUMN_BREAKPOINTS } from '@/components/SortableTable/index.vue';
export default { export default {
name: 'ListNode', name: 'ListNode',
components: { Loading, ResourceTable }, components: {
Loading, ResourceTable, Tag
},
mixins: [metricPoller], mixins: [metricPoller],
props: { props: {
@ -21,20 +31,97 @@ export default {
}, },
async fetch() { async fetch() {
this.rows = await this.$store.dispatch('cluster/findAll', { type: NODE }); const hash = { kubeNodes: this.$store.dispatch('cluster/findAll', { type: NODE }) };
if ( this.$store.getters['management/schemaFor'](CAPI.MACHINE) ) { const canViewNodePools = this.$store.getters[`management/schemaFor`](MANAGEMENT.NODE_POOL);
await this.$store.dispatch('management/findAll', { type: CAPI.MACHINE }); const canViewNodeTemplates = this.$store.getters[`management/schemaFor`](MANAGEMENT.NODE_TEMPLATE);
const canViewPods = this.$store.getters[`cluster/schemaFor`](POD);
const canViewNormanNodes = this.$store.getters[`rancher/schemaFor`](NORMAN.NODE);
if (canViewNormanNodes) {
// Required for Drain action
hash.normanNodes = this.$store.dispatch('rancher/findAll', { type: NORMAN.NODE });
} }
if (canViewNodePools && canViewNodeTemplates) {
// Managemnet Node's required for kube role and some reousrce states
hash.mgmtNodes = this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE });
hash.nodePools = this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE_POOL });
hash.nodeTemplates = this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE_TEMPLATE });
}
if (canViewPods) {
// Used for running pods metrics
hash.pods = this.$store.dispatch('cluster/findAll', { type: POD });
}
const res = await allHash(hash);
this.kubeNodes = res.kubeNodes;
this.nodePools = res.nodePools || [];
this.nodeTemplates = res.nodeTemplates || [];
await this.updateNodePools(res.kubeNodes);
}, },
data() { data() {
return { return {
rows: null, kubeNodes: null,
headers: [STATE, NAME, ROLES, VERSION, INTERNAL_EXTERNAL_IP, CPU, RAM], nodeTemplates: null,
nodePools: null,
headers: [STATE, NAME, ROLES, VERSION, INTERNAL_EXTERNAL_IP, {
...CPU,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP
}, {
...RAM,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP
}, {
...PODS,
breakpoint: COLUMN_BREAKPOINTS.DESKTOP
}, AGE],
}; };
}, },
computed: {
...mapGetters(['currentCluster']),
tableGroup: mapPref(GROUP_RESOURCES),
clusterNodePools() {
return this.nodePools?.filter(pool => pool?.spec?.clusterName === this.currentCluster.id) || [];
},
clusterNodePoolsMap() {
return this.clusterNodePools.reduce((res, node) => {
res[node.id] = node;
return res;
}, {});
},
hasPools() {
return !!this.clusterNodePools.length;
},
groupBy() {
if (!this.hasPools) {
return null;
}
return this.tableGroup === 'none' ? '' : 'nodePoolId';
}
},
watch: {
kubeNodes: {
deep: true,
handler(neu, old) {
this.updateNodePools(neu);
}
},
},
methods: { methods: {
async loadMetrics() { async loadMetrics() {
const schema = this.$store.getters['cluster/schemaFor'](METRIC.NODE); const schema = this.$store.getters['cluster/schemaFor'](METRIC.NODE);
@ -47,7 +134,30 @@ export default {
this.$forceUpdate(); this.$forceUpdate();
} }
},
updateNodePools(nodes = []) {
nodes.forEach((node) => {
const sNode = node.managementNode;
if (sNode) {
node.nodePoolId = sNode.spec.nodePoolName?.replace(':', '/') || '' ;
} }
});
},
getNodePoolFromTableGroup(group) {
return this.getNodePool(group.key);
},
getNodeTemplate(nodeTemplateName) {
const parsedName = nodeTemplateName.replace(':', '/');
return this.nodeTemplates.find(nt => nt.id === parsedName);
},
get,
} }
}; };
@ -60,9 +170,95 @@ export default {
v-bind="$attrs" v-bind="$attrs"
:schema="schema" :schema="schema"
:headers="headers" :headers="headers"
:rows="[...rows]" :rows="[...kubeNodes]"
key-field="_key" :groupable="hasPools"
:group-by="groupBy"
group-tooltip="node.list.pool"
:sub-rows="true"
v-on="$listeners" v-on="$listeners"
> >
<template #group-by="{group}">
<div class="pool-row" :class="{'has-description':clusterNodePoolsMap[group.key] && clusterNodePoolsMap[group.key].nodeTemplate}">
<div v-trim-whitespace class="group-tab">
<div v-if="clusterNodePoolsMap[group.key]" class="project-name" v-html="t('resourceTable.groupLabel.nodePool', { name: clusterNodePoolsMap[group.key].spec.hostnamePrefix, count: group.rows.length}, true)">
</div>
<div v-else class="project-name" v-html="t('resourceTable.groupLabel.notInANodePool')">
</div>
<div v-if="clusterNodePoolsMap[group.key] && clusterNodePoolsMap[group.key].nodeTemplate" class="description text-muted text-small">
{{ clusterNodePoolsMap[group.key].providerDisplay }} &ndash; {{ clusterNodePoolsMap[group.key].providerLocation }} / {{ clusterNodePoolsMap[group.key].providerSize }} ({{ clusterNodePoolsMap[group.key].providerName }})
</div>
</div>
</div>
</template>
<template #sub-row="{fullColspan, row}">
<tr class="taints sub-row" :class="{'empty-taints': !row.spec.taints || !row.spec.taints.length}">
<template v-if="row.spec.taints && row.spec.taints.length">
<td>&nbsp;</td>
<td>&nbsp;</td>
<td :colspan="fullColspan-2">
{{ t('node.list.nodeTaint') }}:
<Tag v-for="taint in row.spec.taints" :key="taint.key + taint.value + taint.effect" class="mr-5">
{{ taint.key }}={{ taint.value }}:{{ taint.effect }}
</Tag>
</td>
</template>
<td v-else :colspan="fullColspan">
&nbsp;
</td>
</tr>
</template>
</ResourceTable> </ResourceTable>
</template> </template>
<style lang='scss' scoped>
.pool-row {
display: flex;
justify-content: space-between;
.project-name {
line-height: 30px;
}
&.has-description {
.right {
margin-top: 5px;
}
.group-tab {
&, &::after {
height: 50px;
}
&::after {
right: -20px;
}
.description {
margin-top: -20px;
}
}
}
BUTTON {
line-height: 1em;
}
}
.taints {
td {
padding-top:0;
.tag {
margin-right: 5px
}
}
&.empty-taints {
// No taints... so hide sub-row (but not bottom-border)
height: 0;
line-height: 0;
td {
padding: 0;
}
}
}
</style>

View File

@ -1,16 +1,19 @@
import Vue from 'vue';
import { formatPercent } from '@/utils/string'; import { formatPercent } from '@/utils/string';
import { CAPI as CAPI_ANNOTATIONS, NODE_ROLES, RKE } from '@/config/labels-annotations.js'; import { CAPI as CAPI_ANNOTATIONS, NODE_ROLES, RKE } from '@/config/labels-annotations.js';
import { CAPI, METRIC, POD } from '@/config/types'; import {
CAPI, MANAGEMENT, METRIC, NORMAN, POD
} from '@/config/types';
import { parseSi } from '@/utils/units'; import { parseSi } from '@/utils/units';
import { PRIVATE } from '@/plugins/steve/resource-proxy'; import { PRIVATE } from '@/plugins/steve/resource-proxy';
import findLast from 'lodash/findLast'; import findLast from 'lodash/findLast';
export default { export default {
_availableActions() { _availableActions() {
const normanAction = this.normanNode?.actions || {};
const cordon = { const cordon = {
action: 'cordon', action: 'cordon',
enabled: this.hasLink('update') && this.isWorker && !this.isCordoned, enabled: !!normanAction.cordon,
icon: 'icon icon-fw icon-pause', icon: 'icon icon-fw icon-pause',
label: 'Cordon', label: 'Cordon',
total: 1, total: 1,
@ -19,13 +22,30 @@ export default {
const uncordon = { const uncordon = {
action: 'uncordon', action: 'uncordon',
enabled: this.hasLink('update') && this.isWorker && this.isCordoned, enabled: !!normanAction.uncordon,
icon: 'icon icon-fw icon-play', icon: 'icon icon-fw icon-play',
label: 'Uncordon', label: 'Uncordon',
total: 1, total: 1,
bulkable: true bulkable: true
}; };
const drain = {
action: 'drain',
enabled: !!normanAction.drain,
icon: 'icon icon-fw icon-dot-open',
label: this.t('drainNode.action'),
bulkable: true,
bulkAction: 'drain'
};
const stopDrain = {
action: 'stopDrain',
enabled: !!normanAction.stopDrain,
icon: 'icon icon-fw icon-x',
label: this.t('drainNode.actionStop'),
bulkable: true,
};
const openSsh = { const openSsh = {
action: 'openSsh', action: 'openSsh',
enabled: !!this.provisionedMachine?.links?.shell, enabled: !!this.provisionedMachine?.links?.shell,
@ -46,6 +66,8 @@ export default {
{ divider: true }, { divider: true },
cordon, cordon,
uncordon, uncordon,
drain,
stopDrain,
{ divider: true }, { divider: true },
...this._standardActions ...this._standardActions
]; ];
@ -90,11 +112,13 @@ export default {
}, },
isWorker() { isWorker() {
return `${ this.labels[NODE_ROLES.WORKER] }` === 'true'; return this.managementNode ? this.managementNode.isWorker : `${ this.labels[NODE_ROLES.WORKER] }` === 'true';
}, },
isControlPlane() { isControlPlane() {
if ( if (this.managementNode) {
return this.managementNode.isControlPlane;
} else if (
`${ this.labels[NODE_ROLES.CONTROL_PLANE] }` === 'true' || `${ this.labels[NODE_ROLES.CONTROL_PLANE] }` === 'true' ||
`${ this.labels[NODE_ROLES.CONTROL_PLANE_OLD] }` === 'true' `${ this.labels[NODE_ROLES.CONTROL_PLANE_OLD] }` === 'true'
) { ) {
@ -105,9 +129,7 @@ export default {
}, },
isEtcd() { isEtcd() {
const { ETCD: etcd } = NODE_ROLES; return this.managementNode ? this.managementNode.isEtcd : `${ this.labels[NODE_ROLES.ETCD] }` === 'true';
return `${ this.labels[etcd] }` === 'true';
}, },
hasARole() { hasARole() {
@ -170,7 +192,7 @@ export default {
}, },
cpuUsagePercentage() { cpuUsagePercentage() {
return ((this.cpuUsage * 10000) / this.cpuCapacity).toString(); return ((this.cpuUsage * 100) / this.cpuCapacity).toString();
}, },
ramUsage() { ramUsage() {
@ -182,13 +204,17 @@ export default {
}, },
ramUsagePercentage() { ramUsagePercentage() {
return ((this.ramUsage * 10000) / this.ramCapacity).toString(); return ((this.ramUsage * 100) / this.ramCapacity).toString();
}, },
podUsage() { podUsage() {
return calculatePercentage(this.status.allocatable.pods, this.status.capacity.pods); return calculatePercentage(this.status.allocatable.pods, this.status.capacity.pods);
}, },
podConsumedUsage() {
return ((this.podConsumed / this.podCapacity) * 100).toString();
},
podCapacity() { podCapacity() {
return Number.parseInt(this.status.capacity.pods); return Number.parseInt(this.status.capacity.pods);
}, },
@ -217,6 +243,21 @@ export default {
return !!this.spec.unschedulable; return !!this.spec.unschedulable;
}, },
drainedState() {
const sNodeCondition = this.managementNode?.status.conditions.find(c => c.type === 'Drained');
if (sNodeCondition) {
if (sNodeCondition.status === 'True') {
return 'drained';
}
if (sNodeCondition.transitioning) {
return 'draining';
}
}
return null;
},
containerRuntimeVersion() { containerRuntimeVersion() {
return this.status.nodeInfo.containerRuntimeVersion.replace('docker://', ''); return this.status.nodeInfo.containerRuntimeVersion.replace('docker://', '');
}, },
@ -230,20 +271,71 @@ export default {
}, },
cordon() { cordon() {
return async() => { return async(resources) => {
Vue.set(this.spec, 'unschedulable', true); const safeResources = Array.isArray(resources) ? resources : [this];
await this.save();
await Promise.all(safeResources.map((node) => {
return node.normanNode.doAction('cordon');
}));
}; };
}, },
uncordon() { uncordon() {
return async() => { return async(resources) => {
Vue.set(this.spec, 'unschedulable', false); const safeResources = Array.isArray(resources) ? resources : [this];
await this.save();
await Promise.all(safeResources.map((node) => {
return node.normanNode.doAction('uncordon');
}));
};
},
clusterId() {
const parts = this.links.self.split('/');
return parts[parts.length - 4];
},
normanNodeId() {
const managementNode = (this.$rootGetters['management/all'](MANAGEMENT.NODE) || []).find((n) => {
return n.id.startsWith(this.clusterId) && n.status.nodeName === this.name;
});
if (managementNode) {
return managementNode.id.replace('/', ':');
}
},
normanNode() {
return this.$rootGetters['rancher/byId'](NORMAN.NODE, this.normanNodeId);
},
managementNode() {
return this.$rootGetters['management/all'](MANAGEMENT.NODE).find((mNode) => {
return mNode.id.startsWith(this.clusterId) && mNode.status.nodeName === this.id;
});
},
drain() {
return (resources) => {
this.$dispatch('promptModal', { component: 'DrainNode', resources: [resources || [this], this.normanNodeId] });
};
},
stopDrain() {
return async(resources) => {
const safeResources = Array.isArray(resources) ? resources : [this];
await Promise.all(safeResources.map((node) => {
return node.normanNode.doAction('stopDrain');
}));
}; };
}, },
state() { state() {
if (this.drainedState) {
return this.drainedState;
}
if ( !this[PRIVATE].isDetailPage && this.isCordoned ) { if ( !this[PRIVATE].isDetailPage && this.isCordoned ) {
return 'cordoned'; return 'cordoned';
} }
@ -310,6 +402,7 @@ export default {
return this.$rootGetters['management/byId'](CAPI.MACHINE, `${ namespace }/${ name }`); return this.$rootGetters['management/byId'](CAPI.MACHINE, `${ namespace }/${ name }`);
} }
}, },
}; };
function calculatePercentage(allocatable, capacity) { function calculatePercentage(allocatable, capacity) {

View File

@ -0,0 +1,14 @@
export default {
isWorker() {
return this.spec.worker;
},
isControlPlane() {
return this.spec.controlPlane;
},
isEtcd() {
return this.spec.etcd;
},
};

View File

@ -12,7 +12,20 @@ export default {
return this.nodeTemplate?.provider; return this.nodeTemplate?.provider;
}, },
providerName() {
return this.nodeTemplate?.nameDisplay;
},
providerDisplay() { providerDisplay() {
return this.nodeTemplate?.providerDisplay; return this.nodeTemplate?.providerDisplay;
}, },
providerLocation() {
return this.nodeTemplate?.providerLocation;
},
providerSize() {
return this.nodeTemplate?.providerSize;
},
}; };

View File

@ -1,3 +1,78 @@
import { formatSi } from '@/utils/units';
const CONFIG_KEYS = [
{
driver: 'aliyunecs',
size: { key: 'instanceType' },
location: {
getDisplayProperty(that) {
return `${ that.providerConfig.region }${ that.providerConfig.zone }`;
}
}
},
{
driver: 'amazonec2',
size: { key: 'instanceType' },
location: {
getDisplayProperty(that) {
return `${ that.providerConfig.region }${ that.providerConfig.zone }`;
}
}
},
{
driver: 'azure',
size: { key: 'size' },
location: { key: 'location' }
},
{
driver: 'digitalocean',
size: { key: 'size' },
location: { key: 'region' }
},
{
driver: 'exoscale',
size: { key: 'instanceProfile' },
location: { key: 'availabilityZone' }
},
{
driver: 'linode',
size: { key: 'instanceType' },
location: { key: 'region' }
},
{
driver: 'oci',
size: { key: 'nodeShape' },
location: {}
},
{
driver: 'packet',
size: { key: 'plan' },
location: { key: 'facilityCode' }
},
{
driver: 'pnap',
size: { key: 'serverType' },
location: { key: 'serverLocation' }
},
{
driver: 'rackspace',
size: { key: 'flavorId' },
location: { key: 'region' }
},
{
driver: 'vmwarevsphere',
size: {
getDisplayProperty(that) {
const size = formatSi(that.providerConfig.memorySize * 1048576, 1024, 'iB');
return `${ size }, ${ that.providerConfig.cpuCount } Core`;
}
},
location: { key: null }
},
];
export default { export default {
provider() { provider() {
const allKeys = Object.keys(this); const allKeys = Object.keys(this);
@ -8,9 +83,51 @@ export default {
} }
}, },
providerConfig() {
return this[`${ this.provider }Config`];
},
providerDisplay() { providerDisplay() {
const provider = (this.provider || '').toLowerCase(); const provider = (this.provider || '').toLowerCase();
return this.$rootGetters['i18n/withFallback'](`cluster.provider."${ provider }"`, null, 'generic.unknown', true); return this.$rootGetters['i18n/withFallback'](`cluster.provider."${ provider }"`, null, 'generic.unknown', true);
}, },
providerLocation() {
if (this.provider) {
const config = CONFIG_KEYS.find(k => k.driver === this.provider);
if (config?.location) {
if (config.location.getDisplayProperty) {
return config.location.getDisplayProperty(this);
}
const value = this.providerConfig[config.location.key];
if (value) {
return value;
}
}
}
return this.providerConfig?.region || this.t('node.list.poolDescription.noLocation');
},
providerSize() {
if (this.provider) {
const config = CONFIG_KEYS.find(k => k.driver === this.provider);
if (config?.size) {
if (config.size.getDisplayProperty) {
return config.size.getDisplayProperty(this);
}
const value = this.providerConfig[config.size.key];
if (value) {
return value;
}
}
}
return this.providerConfig?.size || this.t('node.list.poolDescription.noSize');
}
}; };

View File

@ -175,8 +175,7 @@ export default {
/> />
<ResourceTable <ResourceTable
ref="table" ref="table"
class=" class="table"
table"
v-bind="$attrs" v-bind="$attrs"
:schema="schema" :schema="schema"
:headers="headers" :headers="headers"

View File

@ -372,7 +372,7 @@ export default {
</td> </td>
</template> </template>
<template #col:pods="{row}"> <template #col:pods="{row}">
<td v-if="row.status.allocatable.pods && row.status.allocatable.pods!== '0'"> <td v-if="row.status.allocatable && row.status.allocatable.pods!== '0'">
{{ `${row.status.requested.pods}/${row.status.allocatable.pods}` }} {{ `${row.status.requested.pods}/${row.status.allocatable.pods}` }}
</td> </td>
<td v-else> <td v-else>

View File

@ -2,20 +2,7 @@ import { normalizeType } from './normalize';
const cache = {}; const cache = {};
/** function find(cache, type, appSpecializationName) {
* This will lookup and load a model based on the type and appSpecializationName if specified.
*
* We want to have the ability to treat chart apps as if they were native resources.
* As part of this desire to treat apps as a native resource we also want to be able to customize their models.
* If we attempt to load an 'app' type with an 'appSpecializationName' we will first
* load the 'app' type and then merge that with a model found in '@/models/apps/${appSpecializationName}'
* if the file exists.
* @param {*} type the type we'd like to lookup
* @param {*} appSpecializationName the name of the app so we can lookup a model with the given name and merge that with the app base type.
*/
export function lookup(type, appSpecializationName) {
type = normalizeType(type).replace(/\//g, '');
const impl = cache[type]; const impl = cache[type];
if ( impl ) { if ( impl ) {
@ -47,3 +34,21 @@ export function lookup(type, appSpecializationName) {
return null; return null;
} }
/**
* This will lookup and load a model based on the type and appSpecializationName if specified.
*
* We want to have the ability to treat chart apps as if they were native resources.
* As part of this desire to treat apps as a native resource we also want to be able to customize their models.
* If we attempt to load an 'app' type with an 'appSpecializationName' we will first
* load the 'app' type and then merge that with a model found in '@/models/apps/${appSpecializationName}'
* if the file exists.
* @param {*} store the name of the store that the type comes from
* @param {*} type the type we'd like to lookup
* @param {*} appSpecializationName the name of the app so we can lookup a model with the given name and merge that with the app base type.
*/
export function lookup(store, type, appSpecializationName) {
type = normalizeType(type).replace(/\//g, '');
return find(cache, `${ store }/${ type }`, appSpecializationName) || find(cache, type, appSpecializationName) || null;
}

View File

@ -92,6 +92,8 @@ export const STATES = {
deployed: { color: 'success', icon: 'dot-open' }, deployed: { color: 'success', icon: 'dot-open' },
disabled: { color: 'warning', icon: 'error' }, disabled: { color: 'warning', icon: 'error' },
disconnected: { color: 'warning', icon: 'error' }, disconnected: { color: 'warning', icon: 'error' },
drained: { color: 'info', icon: 'tag' },
draining: { color: 'warning', icon: 'tag' },
errapplied: { color: 'error', icon: 'error' }, errapplied: { color: 'error', icon: 'error' },
error: { color: 'error', icon: 'error' }, error: { color: 'error', icon: 'error' },
erroring: { color: 'error', icon: 'error' }, erroring: { color: 'error', icon: 'error' },

View File

@ -37,9 +37,8 @@ export function proxyFor(ctx, obj, isClone = false) {
}); });
} }
} }
const mappedType = ctx.rootGetters['type-map/componentFor'](obj.type); const mappedType = ctx.rootGetters['type-map/componentFor'](obj.type);
const customModel = lookup(mappedType, obj?.metadata?.name); const customModel = lookup(ctx.state.config.namespace, mappedType, obj?.metadata?.name);
const model = customModel || ResourceInstance; const model = customModel || ResourceInstance;
remapSpecialKeys(obj); remapSpecialKeys(obj);