dashboard/shell/detail/provisioning.cattle.io.clus...

1194 lines
37 KiB
Vue

<script>
import { Banner } from '@components/Banner';
import ResourceTable, { defaultTableSortGenerationFn } from '@shell/components/ResourceTable';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import SortableTable from '@shell/components/SortableTable';
import CopyCode from '@shell/components/CopyCode';
import Tab from '@shell/components/Tabbed/Tab';
import { allHash } from '@shell/utils/promise';
import { CAPI, MANAGEMENT, NORMAN, SNAPSHOT } from '@shell/config/types';
import {
STATE, NAME as NAME_COL, AGE, INTERNAL_EXTERNAL_IP, STATE_NORMAN, ROLES, MACHINE_NODE_OS, MANAGEMENT_NODE_OS, NAME,
} from '@shell/config/table-headers';
import { STATES_ENUM } from '@shell/plugins/dashboard-store/resource-class';
import CustomCommand from '@shell/edit/provisioning.cattle.io.cluster/CustomCommand';
import AsyncButton from '@shell/components/AsyncButton.vue';
import AnsiUp from 'ansi_up';
import day from 'dayjs';
import { addParams } from '@shell/utils/url';
import { base64Decode } from '@shell/utils/crypto';
import { DATE_FORMAT, TIME_FORMAT, SCALE_POOL_PROMPT } from '@shell/store/prefs';
import { escapeHtml } from '@shell/utils/string';
import MachineSummaryGraph from '@shell/components/formatter/MachineSummaryGraph';
import Socket, {
EVENT_CONNECTED,
EVENT_DISCONNECTED,
EVENT_MESSAGE,
// EVENT_FRAME_TIMEOUT,
EVENT_CONNECT_ERROR
} from '@shell/utils/socket';
import { get } from '@shell/utils/object';
import CapiMachineDeployment from '@shell/models/cluster.x-k8s.io.machinedeployment';
import { isAlternate } from '@shell/utils/platform';
import DetailPage from '@shell/components/Resource/Detail/Page.vue';
import Masthead from '@shell/components/Resource/Detail/Masthead/index.vue';
import { useDefaultMastheadProps } from '@shell/components/Resource/Detail/Masthead/composable';
let lastId = 1;
const ansiup = new AnsiUp();
/**
* Machine Deployment has a reference to the 'template' used to create that deployment
* For an empty machine pool, we (obviously) don't get any machine deployments for that pool.
*
* This class allows us to fake a machine deployment - when created, we set additional properties (_cluster etc)
* and use these in the getters.
**/
class EmptyCapiMachineDeployment extends CapiMachineDeployment {
get inClusterSpec() {
return this._clusterSpec;
}
get cluster() {
return this._cluster;
}
get template() {
return this._template;
}
}
export default {
emits: ['input'],
components: {
Banner,
ResourceTable,
ResourceTabs,
SortableTable,
Tab,
CopyCode,
CustomCommand,
AsyncButton,
MachineSummaryGraph,
DetailPage,
Masthead,
},
props: {
value: {
type: Object,
default: () => {
return {};
}
}
},
setup(props) {
return { defaultMastheadProps: useDefaultMastheadProps(props.value) };
},
async fetch() {
await this.$store.dispatch(`management/find`, { type: MANAGEMENT.CLUSTER, id: this.value.mgmtClusterId });
await this.value.waitForProvisioner();
// Support for the 'provisioner' extension
const extClass = this.$plugin.getDynamic('provisioner', this.value.machineProvider);
if (extClass) {
this.extProvider = new extClass({
dispatch: this.$store.dispatch,
getters: this.$store.getters,
axios: this.$store.$axios,
$plugin: this.$store.app.$plugin,
$t: this.t
});
this.extDetailTabs = {
...this.extDetailTabs,
...this.extProvider.detailTabs
};
this.extCustomParams = { provider: this.value.machineProvider };
}
// Support for a model extension
if (this.value.customProvisionerHelper) {
this.extDetailTabs = {
...this.extDetailTabs,
...this.value.customProvisionerHelper.detailTabs
};
this.extCustomParams = { provider: this.value.machineProvider };
}
const schema = this.$store.getters[`management/schemaFor`](CAPI.RANCHER_CLUSTER);
const fetchOne = { schemaDefinitions: schema.fetchResourceFields() };
if ( this.$store.getters['management/canList'](CAPI.MACHINE_DEPLOYMENT) ) {
fetchOne.machineDeployments = this.$store.dispatch('management/findAll', { type: CAPI.MACHINE_DEPLOYMENT });
}
if ( this.$store.getters['management/canList'](CAPI.MACHINE) ) {
fetchOne.machines = this.$store.dispatch('management/findAll', { type: CAPI.MACHINE });
}
if ( this.$store.getters['management/canList'](SNAPSHOT) ) {
fetchOne.snapshots = this.$store.dispatch('management/findAll', { type: SNAPSHOT });
}
if ( this.value.isImported || this.value.isCustom || this.value.isHostedKubernetesProvider ) {
fetchOne.clusterToken = this.value.getOrCreateToken();
}
// Need to get Norman clusters so that we can check if user has permissions to access the local cluster
if ( this.$store.getters['rancher/canList'](NORMAN.CLUSTER) ) {
fetchOne.normanClusters = this.$store.dispatch('rancher/findAll', { type: NORMAN.CLUSTER });
}
if ( this.value.isRke1 && this.$store.getters['isRancher'] ) {
fetchOne.normanNodePools = this.$store.dispatch('rancher/findAll', { type: NORMAN.NODE_POOL });
}
const fetchOneRes = await allHash(fetchOne);
this.allMachines = fetchOneRes.machines || [];
this.allMachineDeployments = fetchOneRes.machineDeployments || [];
this.haveMachines = !!fetchOneRes.machines;
this.haveDeployments = !!fetchOneRes.machineDeployments;
this.clusterToken = fetchOneRes.clusterToken;
if (fetchOneRes.normanClusters) {
// Does the user have access to the local cluster? Need to in order to be able to show the 'Related Resources' tab
this.hasLocalAccess = !!fetchOneRes.normanClusters.find((c) => c.internal);
}
const fetchTwo = {};
const thisClusterMachines = this.allMachineDeployments.filter((deployment) => {
return deployment?.spec?.clusterName === this.value.metadata.name;
});
const machineDeploymentTemplateType = thisClusterMachines?.[0]?.templateType;
if (machineDeploymentTemplateType && this.$store.getters['management/schemaFor'](machineDeploymentTemplateType) ) {
fetchTwo.mdtt = this.$store.dispatch('management/findAll', { type: machineDeploymentTemplateType });
}
if (!this.showMachines) {
if ( this.$store.getters['management/canList'](MANAGEMENT.NODE) ) {
fetchTwo.allNodes = this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE });
}
if ( this.$store.getters['management/canList'](MANAGEMENT.NODE_POOL) ) {
fetchTwo.allNodePools = this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE_POOL });
}
if ( this.$store.getters['management/canList'](MANAGEMENT.NODE_TEMPLATE) ) {
fetchTwo.nodeTemplates = this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE_TEMPLATE });
}
}
const fetchTwoRes = await allHash(fetchTwo);
this.allNodes = fetchTwoRes.allNodes || [];
this.haveNodes = !!fetchTwoRes.allNodes;
this.allNodePools = fetchTwoRes.allNodePools || [];
this.haveNodePools = !!fetchTwoRes.allNodePools;
this.machineTemplates = fetchTwoRes.mdtt || [];
// Fetch RKE template revisions so we can show when an updated template is available
// This request does not need to be blocking
if ( this.$store.getters['management/canList'](MANAGEMENT.RKE_TEMPLATE) ) {
this.$store.dispatch('management/findAll', { type: MANAGEMENT.RKE_TEMPLATE });
}
if ( this.$store.getters['management/canList'](MANAGEMENT.RKE_TEMPLATE_REVISION) ) {
this.$store.dispatch('management/findAll', { type: MANAGEMENT.RKE_TEMPLATE_REVISION });
}
},
created() {
if ( this.showLog ) {
this.connectLog();
}
},
beforeUnmount() {
if ( this.logSocket ) {
this.logSocket.disconnect();
this.logSocket = null;
}
},
data() {
const noneGroupOption = {
tooltipKey: 'resourceTable.groupBy.none',
icon: 'icon-list-flat',
value: 'none',
};
const poolColumn = {
name: 'pool',
labelKey: 'cluster.machinePool.name.label',
value: 'spec.nodePoolName',
getValue: (row) => row.spec.nodePoolName,
sort: ['spec.nodePoolName'],
};
const poolGroupOption = {
tooltipKey: 'resourceTable.groupBy.pool',
icon: 'icon-cluster',
hideColumn: poolColumn.name,
value: 'spec.nodePoolName',
field: 'spec.nodePoolName'
};
const machineColumn = {
name: 'pool',
labelKey: 'cluster.machinePool.name.label',
value: 'pool.nameDisplay',
getValue: (row) => row.pool.nameDisplay,
sort: ['pool.nameDisplay'],
};
const machineGroupOption = {
tooltipKey: 'resourceTable.groupBy.pool',
icon: 'icon-cluster',
hideColumn: machineColumn.name,
value: 'poolId',
field: 'poolId'
};
return {
allMachines: [],
allMachineDeployments: [],
allNodes: [],
allNodePools: [],
haveMachines: false,
haveDeployments: false,
haveNodes: false,
haveNodePools: false,
hasLocalAccess: false,
mgmtNodeSchema: this.$store.getters[`management/schemaFor`](MANAGEMENT.NODE),
machineSchema: this.$store.getters[`management/schemaFor`](CAPI.MACHINE),
clusterToken: null,
logOpen: false,
logSocket: null,
logs: [],
extProvider: null,
extCustomParams: null,
extDetailTabs: {
machines: true, // in this component
logs: true, // in this component
registration: true, // in this component
snapshots: true, // in this component
related: true, // in ResourceTabs
events: true, // in ResourceTabs
conditions: true, // in ResourceTabs
},
showWindowsWarning: false,
machineColumn,
poolColumn,
noneGroupOption,
machineGroupOption,
machineGroupOptions: [
noneGroupOption,
machineGroupOption
],
poolGroupOption,
poolGroupOptions: [
noneGroupOption,
poolGroupOption,
]
};
},
watch: {
showNodes(neu) {
if (neu) {
this.$store.dispatch('rancher/findAll', { type: NORMAN.NODE });
}
},
},
computed: {
defaultTab() {
if (this.showRegistration) {
if (this.value.isRke2 ? !this.machines?.length : !this.nodes?.length) {
return 'registration';
}
}
if (this.showMachines) {
return 'machine-pools';
}
if (this.showNodes) {
return 'node-pools';
}
return '';
},
// Used to show summary graph for each node pool group in the machine pool table
poolSummaryInfo() {
const info = {};
this.value?.pools.forEach((p) => {
const group = `[${ p.type }: ${ p.id }]`;
info[group] = p;
});
return info;
},
fakeMachines() {
const machineNameFn = (clusterName, machinePoolName) => `${ clusterName }-${ machinePoolName }`;
// When we scale up, the quantity will change to N+1 - so from 0 to 1, the quantity changes,
// but it takes tiem for the machine to appear, so the pool is empty, but if we just go off on a non-zero quqntity
// then the pool would be hidden - so we find empty pool by checking the machines
const emptyPools = (this.value.spec.rkeConfig?.machinePools || []).filter((mp) => {
const machineFullName = machineNameFn(this.value.name, mp.name);
const machines = this.value.machines.filter((machine) => {
const isElementalCluster = machine.spec?.infrastructureRef?.apiVersion.startsWith('elemental.cattle.io');
const machinePoolInfName = machine.spec?.infrastructureRef?.name;
if (isElementalCluster) {
return machinePoolInfName.includes(machineFullName);
}
// if labels exist, then the machineFullName must unequivocally be equal to manchineLabelFullName (based on labels)
const machineLabelClusterName = machine.metadata?.labels?.['cluster.x-k8s.io/cluster-name'];
const machineLabelPoolName = machine.metadata?.labels?.['rke.cattle.io/rke-machine-pool-name'];
if (machineLabelClusterName && machineLabelPoolName) {
const manchineLabelFullName = machineNameFn(machineLabelClusterName, machineLabelPoolName);
return machineFullName === manchineLabelFullName;
}
return machinePoolInfName.startsWith(machineFullName);
});
return machines.length === 0;
});
// When a deployment has no machines it's not shown.... so add a fake machine to it
// This is a catch all scenario seen in older node pool world but not deployments
return emptyPools.map((mp, i) => {
const pool = new EmptyCapiMachineDeployment(
{
id: i,
metadata: {
name: `${ this.value.nameDisplay }-${ mp.name }`,
namespace: this.value.namespace,
},
spec: {}
},
{
getters: this.$store.getters,
rootGetters: this.$root.$store.getters,
}
);
const templateNamePrefix = `${ pool.metadata.name }-`;
// All of these properties are needed to ensure the pool displays correctly and that we can scale up and down
pool._template = this.machineTemplates.find((t) => t.metadata.name.startsWith(templateNamePrefix));
pool._cluster = this.value;
pool._clusterSpec = mp;
return {
poolId: pool.id,
mainRowKey: 'isFake',
pool,
availableActions: []
};
});
},
machines() {
return [...this.value.machines, ...this.fakeMachines];
},
nodes() {
const nodes = this.allNodes.filter((x) => x.mgmtClusterId === this.value.mgmtClusterId);
return [...nodes, ...this.fakeNodes];
},
fakeNodes() {
// When a pool has no nodes it's not shown.... so add a fake node to it
const emptyNodePools = this.allNodePools.filter((x) => x.spec.clusterName === this.value.mgmtClusterId && x.spec.quantity === 0);
return emptyNodePools.map((np) => ({
spec: { nodePoolName: np.id.replace('/', ':') },
mainRowKey: 'isFake',
pool: np,
availableActions: []
}));
},
showMachines() {
const showMachines = this.haveMachines && (this.value.isRke2 || !!this.machines.length);
return showMachines && this.extDetailTabs.machines;
},
showNodes() {
return !this.showMachines && this.haveNodes && !!this.nodes.length && this.extDetailTabs.machines;
},
showSnapshots() {
if (this.value.isRke1) {
return false;
} else if (this.value.isRke2) {
return this.$store.getters['management/canList'](SNAPSHOT) && this.extDetailTabs.snapshots;
}
return false;
},
isRke1() {
return this.value.isRke1;
},
showEksNodeGroupWarning() {
if ( this.value.provisioner === 'EKS' && this.value.state !== STATES_ENUM.ACTIVE) {
const desiredTotal = this.value.eksNodeGroups.filter((g) => g.desiredSize === 0);
if ( desiredTotal.length === this.value.eksNodeGroups.length ) {
return true;
}
}
return false;
},
machineHeaders() {
const headers = [
STATE,
NAME_COL,
{
name: 'node-name',
labelKey: 'tableHeaders.machineNodeName',
sort: 'status.nodeRef.name',
value: 'status.nodeRef.name',
formatter: 'LinkDetail',
formatterOpts: { reference: 'kubeNodeDetailLocation' },
dashIfEmpty: true,
},
INTERNAL_EXTERNAL_IP,
MACHINE_NODE_OS,
ROLES,
AGE,
];
if (!this.value.isCustom) {
headers.splice(3, 0, this.machineColumn);
}
return headers;
},
mgmtNodeSchemaHeaders() {
// Remove Cluster name being a link
const RKE1_NAME_COL = {
...NAME_COL,
formatter: undefined
};
const headers = [
STATE, RKE1_NAME_COL,
{
name: 'node-name',
labelKey: 'tableHeaders.machineNodeName',
sort: 'kubeNodeName',
value: 'kubeNodeName',
formatter: 'LinkDetail',
formatterOpts: { reference: 'kubeNodeDetailLocation' },
dashIfEmpty: true,
},
INTERNAL_EXTERNAL_IP,
MANAGEMENT_NODE_OS,
ROLES,
AGE
];
if (!this.value.isCustom) {
headers.splice(3, 0, this.poolColumn);
}
return headers;
},
rke2Snapshots() {
return this.value.etcdSnapshots;
},
rke2SnapshotHeaders() {
return [
{
...STATE_NORMAN, value: 'snapshotFile.status', formatterOpts: { arbitrary: true }
},
NAME,
{
name: 'size',
labelKey: 'tableHeaders.size',
value: 'snapshotFile.size',
sort: 'snapshotFile.size',
formatter: 'Si',
width: 150,
},
{
...AGE,
sort: 'snapshotFile.createdAt:desc',
canBeVariable: true
},
];
},
showRegistration() {
if ( !this.clusterToken ) {
return false;
}
if ( this.value.isCustom ) {
return this.extDetailTabs.registration;
}
if ( this.value.isImported ) {
return !this.value.mgmt?.isReady && this.extDetailTabs.registration;
}
// Hosted kubernetes providers with private endpoints need the registration tab
// https://github.com/rancher/dashboard/issues/6036
// https://github.com/rancher/dashboard/issues/4545
if ( this.value.isHostedKubernetesProvider && this.value.isPrivateHostedProvider && !this.isClusterReady ) {
return this.extDetailTabs.registration;
}
return false;
},
isClusterReady() {
return this.value.mgmt?.isReady;
},
showLog() {
const showLog = this.value.mgmt?.hasLink('log');
return showLog && this.extDetailTabs.logs;
},
dateTimeFormatStr() {
const dateFormat = escapeHtml( this.$store.getters['prefs/get'](DATE_FORMAT));
return `${ dateFormat } ${ this.timeFormatStr }`;
},
timeFormatStr() {
return escapeHtml( this.$store.getters['prefs/get'](TIME_FORMAT));
},
hasWindowsMachine() {
return this.machines.some((machine) => get(machine, 'status.nodeInfo.operatingSystem') === 'windows');
},
snapshotsGroupBy() {
return 'backupLocation';
},
extDetailTabsRelated() {
return this.extDetailTabs?.related;
},
extDetailTabsEvents() {
return this.extDetailTabs?.events;
},
extDetailTabsConditions() {
return this.extDetailTabs?.conditions;
}
},
methods: {
toggleScaleDownModal( event, resources ) {
// Check if the user held alt key when an action is clicked.
const alt = isAlternate(event);
const showScalePoolPrompt = this.$store.getters['prefs/get'](SCALE_POOL_PROMPT);
// Prompt if showScalePoolPrompt pref not store and user did not held alt key
if (!alt && !showScalePoolPrompt) {
this.$store.dispatch('management/promptModal', {
component: 'ScalePoolDownDialog',
resources,
modalWidth: '450px'
});
} else {
// User held alt key, so don't prompt
resources.scalePool(-1);
}
},
async takeSnapshot(btnCb) {
try {
await this.value.takeSnapshot();
// Give the change event some time to show up
setTimeout(() => {
btnCb(true);
}, 1000);
} catch (err) {
this.$store.dispatch('growl/fromError', { title: 'Error creating snapshot', err });
btnCb(false);
}
},
showPoolAction(event, pool) {
this.$store.commit(`action-menu/show`, {
resources: [pool],
elem: event.target
});
},
showPoolActionButton(pool) {
return !!pool.availableActions?.length;
},
async connectLog() {
if ( this.logSocket ) {
await this.logSocket.disconnect();
this.logSocket = null;
}
const params = {
follow: true,
timestamps: true,
pretty: true,
};
let url = this.value.mgmt?.linkFor('log');
url = addParams(url.replace(/^http/, 'ws'), params);
this.logSocket = new Socket(url, true, 0);
this.logSocket.addEventListener(EVENT_CONNECTED, (e) => {
this.logs = [];
this.logOpen = true;
});
this.logSocket.addEventListener(EVENT_DISCONNECTED, (e) => {
this.logOpen = false;
});
this.logSocket.addEventListener(EVENT_CONNECT_ERROR, (e) => {
this.logOpen = false;
console.error('Connect Error', e); // eslint-disable-line no-console
});
this.logSocket.addEventListener(EVENT_MESSAGE, (e) => {
const line = base64Decode(e.detail.data);
let msg = line;
let time = null;
const idx = line.indexOf(' ');
if ( idx > 0 ) {
const timeStr = line.substr(0, idx);
const date = new Date(timeStr);
if ( !isNaN(date.getSeconds()) ) {
time = date.toISOString();
msg = line.substr(idx + 1);
}
}
this.logs.push({
id: lastId++,
msg: ansiup.ansi_to_html(msg),
rawMsg: msg,
time,
});
});
this.logSocket.connect();
},
format(time) {
if ( !time ) {
return '';
}
const val = day(time);
const today = day().format('YYYY-MM-DD');
if ( val.format('YYYY-MM-DD') === today ) {
return day(time).format(this.timeFormatStr);
} else {
return day(time).format(this.dateTimeFormatStr);
}
},
machineSortGenerationFn() {
// The sort generation function creates a unique value and is used to create a key including sort details.
// The unique key determines if the list is redrawn or a cached version is shown.
// Because we ensure the 'not in a pool' group is there via a row, and timing issues, the unqiue key doesn't change
// after a machine is added/removed... so the list won't update... so we need to inject a string to ensure the key is fresh
const base = defaultTableSortGenerationFn(this.machineSchema, this.$store);
return base + (!!this.fakeMachines.length ? '-fake' : '');
},
nodeSortGenerationFn() {
// The sort generation function creates a unique value and is used to create a key including sort details.
// The unique key determines if the list is redrawn or a cached version is shown.
// Because we ensure the 'not in a pool' group is there via a row, and timing issues, the unqiue key doesn't change
// after a machine is added/removed... so the list won't update... so we need to inject a string to ensure the key is fresh
const base = defaultTableSortGenerationFn(this.mgmtNodeSchema, this.$store);
return base + (!!this.fakeNodes.length ? '-fake' : '');
},
}
};
</script>
<template>
<DetailPage :loading="$fetchState.pending">
<template #top-area>
<Masthead v-bind="defaultMastheadProps">
<template #additional-actions>
<button
data-testid="detail-explore-button"
type="button"
class="btn role-primary actions"
:disabled="!value.canExplore"
@click="value.explore()"
>
{{ t('cluster.explore') }}
</button>
</template>
</Masthead>
</template>
<template #bottom-area>
<div>
<Banner
v-if="showWindowsWarning"
color="error"
:label="t('cluster.banner.os', { newOS: 'Windows', existingOS: 'Linux' })"
/>
<Banner
v-if="showEksNodeGroupWarning"
color="error"
:label="t('cluster.banner.desiredNodeGroupWarning')"
/>
<Banner
v-if="$fetchState.error"
color="error"
:label="$fetchState.error"
/>
<Banner
v-if="value.isRke1"
color="warning"
label-key="cluster.banner.rke1DeprecationMessage"
/>
<ResourceTabs
:value="value"
:default-tab="defaultTab"
:need-related="hasLocalAccess"
:extension-params="extCustomParams"
:needRelated="extDetailTabsRelated"
:needEvents="extDetailTabsEvents"
:needConditions="extDetailTabsConditions"
@update:value="$emit('input', $event)"
>
<Tab
v-if="showMachines"
name="machine-pools"
:label-key="value.isCustom ? 'cluster.tabs.machines' : 'cluster.tabs.machinePools'"
:weight="4"
>
<ResourceTable
:rows="machines"
:schema="machineSchema"
:headers="machineHeaders"
default-sort-by="name"
group-ref="pool"
:group-default="machineGroupOption.value"
:group-sort="['pool.nameDisplay']"
:group-options="value.isCustom ? [noneGroupOption] : machineGroupOptions"
:sort-generation-fn="machineSortGenerationFn"
>
<template #main-row:isFake="{fullColspan}">
<tr class="main-row">
<td
:colspan="fullColspan"
class="no-entries"
>
{{ t('node.list.noNodes') }}
</td>
</tr>
</template>
<template #group-by="{group}">
<div
class="pool-row"
:class="{'has-description':group.ref && group.ref.template}"
>
<div
v-trim-whitespace
class="group-tab"
>
<div
v-if="group && group.ref"
v-clean-html="group.ref.groupByPoolShortLabel"
/>
<div
v-else
v-clean-html="t('resourceTable.groupLabel.notInANodePool')"
/>
<div
v-if="group.ref && group.ref.providerSummary"
class="description text-muted text-small"
>
{{ group.ref.providerSummary }}
</div>
</div>
<div
v-if="group.ref"
class="right group-header-buttons mr-20"
>
<MachineSummaryGraph
v-if="poolSummaryInfo[group.ref]"
:row="poolSummaryInfo[group.ref]"
:horizontal="true"
class="mr-20"
/>
<template v-if="value.hasLink('update') && group.ref.showScalePool">
<button
v-clean-tooltip="t('node.list.scaleDown')"
:disabled="!group.ref.canScaleDownPool()"
type="button"
class="btn btn-sm role-secondary"
@click="toggleScaleDownModal($event, group.ref)"
>
<i class="icon icon-sm icon-minus" />
</button>
<button
v-clean-tooltip="t('node.list.scaleUp')"
:disabled="!group.ref.canScaleUpPool()"
type="button"
class="btn btn-sm role-secondary ml-10"
@click="group.ref.scalePool(1)"
>
<i class="icon icon-sm icon-plus" />
</button>
</template>
</div>
</div>
</template>
</ResourceTable>
</Tab>
<Tab
v-else-if="showNodes"
name="node-pools"
:label-key="value.isCustom ? 'cluster.tabs.machines' : 'cluster.tabs.machinePools'"
:weight="4"
>
<ResourceTable
:schema="mgmtNodeSchema"
:headers="mgmtNodeSchemaHeaders"
:rows="nodes"
group-ref="pool"
:group-default="poolGroupOption.value"
:group-sort="['pool.nameDisplay']"
:group-options="value.isCustom ? [noneGroupOption] : poolGroupOptions"
:sort-generation-fn="nodeSortGenerationFn"
:hide-grouping-controls="true"
>
<template #main-row:isFake="{fullColspan}">
<tr class="main-row">
<td
:colspan="fullColspan"
class="no-entries"
>
{{ t('node.list.noNodes') }}
</td>
</tr>
</template>
<template #group-by="{group}">
<div
class="pool-row"
:class="{'has-description':group.ref && group.ref.nodeTemplate}"
>
<div
v-trim-whitespace
class="group-tab"
>
<div
v-if="group.ref"
v-clean-html="t('resourceTable.groupLabel.nodePool', { name: group.ref.spec.hostnamePrefix}, true)"
/>
<div
v-else
v-clean-html="t('resourceTable.groupLabel.notInANodePool')"
/>
<div
v-if="group.ref && group.ref.nodeTemplate"
class="description text-muted text-small"
>
{{ group.ref.providerDisplay }} &ndash; {{ group.ref.providerLocation }} / {{ group.ref.providerSize }} ({{ group.ref.providerName }})
</div>
</div>
<div
v-if="group.ref && !isRke1"
class="right group-header-buttons"
>
<MachineSummaryGraph
:row="poolSummaryInfo[group.ref]"
:horizontal="true"
class="mr-20"
/>
<template v-if="group.ref.hasLink('update')">
<button
v-clean-tooltip="t('node.list.scaleDown')"
:disabled="!group.ref.canScaleDownPool()"
type="button"
class="btn btn-sm role-secondary"
@click="toggleScaleDownModal($event, group.ref)"
>
<i class="icon icon-sm icon-minus" />
</button>
<button
v-clean-tooltip="t('node.list.scaleUp')"
type="button"
class="btn btn-sm role-secondary ml-10"
@click="group.ref.scalePool(1)"
>
<i class="icon icon-sm icon-plus" />
</button>
</template>
<button
type="button"
class="project-action btn btn-sm role-multi-action actions mr-5 ml-15"
:class="{invisible: !showPoolActionButton(group.ref)}"
@click="showPoolAction($event, group.ref)"
>
<i class="icon icon-actions" />
</button>
</div>
</div>
</template>
</ResourceTable>
</Tab>
<Tab
v-if="showLog"
name="log"
:label="t('cluster.tabs.log')"
:weight="3"
class="logs-container"
>
<table
class="fixed"
cellpadding="0"
cellspacing="0"
>
<tbody class="logs-body">
<template v-if="logs.length">
<tr
v-for="(line, i) in logs"
:key="i"
>
<td
v-clean-html="format(line.time)"
class="time"
/>
<td
v-clean-html="line.msg"
class="msg"
/>
</tr>
</template>
<tr
v-else-if="!logOpen"
v-t="'cluster.log.connecting'"
colspan="2"
class="msg text-muted"
/>
<tr
v-else
v-t="'cluster.log.noData'"
colspan="2"
class="msg text-muted"
/>
</tbody>
</table>
</Tab>
<Tab
v-if="showRegistration"
name="registration"
:label="t('cluster.tabs.registration')"
:weight="2"
>
<Banner
v-if="!value.isCustom"
color="warning"
:label="t('cluster.import.warningBanner')"
/>
<CustomCommand
v-if="value.isCustom"
:cluster-token="clusterToken"
:cluster="value"
@copied-windows="hasWindowsMachine ? null : showWindowsWarning = true"
/>
<template v-else>
<h4 v-clean-html="t('cluster.import.commandInstructions', null, true)" />
<CopyCode class="m-10 p-10">
{{ clusterToken.command }}
</CopyCode>
<h4
v-clean-html="t('cluster.import.commandInstructionsInsecure', null, true)"
class="mt-10"
/>
<CopyCode class="m-10 p-10">
{{ clusterToken.insecureCommand }}
</CopyCode>
<h4
v-clean-html="t('cluster.import.clusterRoleBindingInstructions', null, true)"
class="mt-10"
/>
<CopyCode class="m-10 p-10">
{{ t('cluster.import.clusterRoleBindingCommand', null, true) }}
</CopyCode>
</template>
</Tab>
<Tab
v-if="showSnapshots"
name="snapshots"
label="Snapshots"
:weight="1"
>
<SortableTable
class="snapshots"
:data-testid="'cluster-snapshots-list'"
:headers="rke2SnapshotHeaders"
default-sort-by="age"
:table-actions="value.isRke1"
:rows="rke2Snapshots"
:search="false"
:groupable="true"
:group-by="snapshotsGroupBy"
>
<template #header-right>
<AsyncButton
mode="snapshot"
class="btn role-primary"
:disabled="!isClusterReady"
@click="takeSnapshot"
/>
</template>
<template #group-by="{group}">
<div class="group-bar">
<div class="group-tab">
{{ t('cluster.snapshot.groupLabel') }}: {{ group.key }}
</div>
</div>
</template>
</SortableTable>
</Tab>
</ResourceTabs>
</div>
</template>
</DetailPage>
</template>
<style lang='scss' scoped>
.main-row .no-entries {
text-align: center;
}
.pool-row {
display: flex;
align-items: center;
justify-content: space-between;
&.has-description {
.group-tab {
&, &::after {
height: 50px;
}
&::after {
right: -20px;
}
.description {
margin-top: -20px;
}
}
}
.group-header-buttons {
align-items: center;
display: flex;
}
}
.logs-container {
height: 100%;
overflow: auto;
padding: 5px;
background-color: var(--logs-bg);
font-family: Menlo,Consolas,monospace;
color: var(--logs-text);
.closed {
opacity: 0.25;
}
.time {
white-space: nowrap;
width: auto;
padding-right: 15px;
user-select: none;
}
.msg {
white-space: normal;
.highlight {
color: var(--logs-highlight);
background-color: var(--logs-highlight-bg);
}
}
}
.snapshots :deep() .state-description{
font-size: .8em;
color: var(--error);
}
</style>