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

682 lines
20 KiB
Vue

<script>
import Loading from '@/components/Loading';
import Banner from '@/components/Banner';
import ResourceTable from '@/components/ResourceTable';
import ResourceTabs from '@/components/form/ResourceTabs';
import SortableTable from '@/components/SortableTable';
import CopyCode from '@/components/CopyCode';
import Tab from '@/components/Tabbed/Tab';
import { allHash } from '@/utils/promise';
import { CAPI, MANAGEMENT, NORMAN } from '@/config/types';
import {
STATE, NAME as NAME_COL, AGE, AGE_NORMAN, STATE_NORMAN, ROLES, MACHINE_NODE_OS, MANAGEMENT_NODE_OS
} from '@/config/table-headers';
import CustomCommand from '@/edit/provisioning.cattle.io.cluster/CustomCommand';
import AsyncButton from '@/components/AsyncButton.vue';
import AnsiUp from 'ansi_up';
import day from 'dayjs';
import { addParams } from '@/utils/url';
import { base64Decode } from '@/utils/crypto';
import { DATE_FORMAT, TIME_FORMAT } from '@/store/prefs';
import { escapeHtml } from '@/utils/string';
import Socket, {
EVENT_CONNECTED,
EVENT_DISCONNECTED,
EVENT_MESSAGE,
// EVENT_FRAME_TIMEOUT,
EVENT_CONNECT_ERROR
} from '@/utils/socket';
let lastId = 1;
const ansiup = new AnsiUp();
export default {
components: {
Loading,
Banner,
ResourceTable,
ResourceTabs,
SortableTable,
Tab,
CopyCode,
CustomCommand,
AsyncButton,
},
props: {
value: {
type: Object,
default: () => {
return {};
}
}
},
async fetch() {
await this.value.waitForProvisioner();
const fetchOne = {};
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.value.isImported || this.value.isCustom) {
fetchOne.clusterToken = this.value.getOrCreateToken();
}
if ( this.value.isRke1 && this.$store.getters['isRancher'] ) {
fetchOne.etcdBackups = this.$store.dispatch('rancher/findAll', { type: NORMAN.ETCD_BACKUP });
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;
this.etcdBackups = fetchOneRes.etcdBackups;
const fetchTwo = {};
const machineDeploymentTemplateType = fetchOneRes.machineDeployments?.[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;
},
created() {
if ( this.showLog ) {
this.connectLog();
}
},
beforeDestroy() {
if ( this.logSocket ) {
this.logSocket.disconnect();
this.logSocket = null;
}
},
data() {
return {
allMachines: [],
allMachineDeployments: [],
allNodes: [],
allNodePools: [],
haveMachines: false,
haveDeployments: false,
haveNodes: false,
haveNodePools: false,
mgmtNodeSchema: this.$store.getters[`management/schemaFor`](MANAGEMENT.NODE),
machineSchema: this.$store.getters[`management/schemaFor`](CAPI.MACHINE),
clusterToken: null,
etcdBackups: null,
logOpen: false,
logSocket: null,
logs: [],
};
},
watch: {
showNodes(neu) {
if (neu) {
this.$store.dispatch('rancher/findAll', { type: NORMAN.NODE });
}
}
},
computed: {
defaultTab() {
if (this.showRegistration && !this.machines?.length) {
return 'registration';
}
if (this.showMachines) {
return 'machine-pools';
}
if (this.showNodes) {
return 'node-pools';
}
return '';
},
fakeMachines() {
// 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
const emptyDeployments = this.allMachineDeployments.filter(x => x.spec.clusterName === this.value.metadata.name && x.spec.replicas === 0);
return emptyDeployments.map(d => ({
poolId: d.id,
mainRowKey: 'isFake',
pool: d,
}));
},
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,
}));
},
showMachines() {
return this.haveMachines && (this.value.isRke2 || !!this.machines.length);
},
showNodes() {
return !this.showMachines && this.haveNodes && !!this.nodes.length;
},
showSnapshots() {
return this.value.isRke2 || this.value.isRke1;
},
machineHeaders() {
return [
STATE,
NAME_COL,
{
name: 'node-name',
labelKey: 'tableHeaders.machineNodeName',
sort: 'status.nodeRef.name',
value: 'status.nodeRef.name',
formatter: 'LinkDetail',
formatterOpts: { reference: 'kubeNodeDetailLocation' }
},
MACHINE_NODE_OS,
ROLES,
AGE,
];
},
mgmtNodeSchemaHeaders() {
return [
STATE, NAME_COL,
{
name: 'node-name',
labelKey: 'tableHeaders.machineNodeName',
sort: 'kubeNodeName',
value: 'kubeNodeName',
formatter: 'LinkDetail',
formatterOpts: { reference: 'kubeNodeDetailLocation' }
},
MANAGEMENT_NODE_OS,
ROLES,
AGE
];
},
rke1Snapshots() {
const mgmtId = this.value.mgmt?.id;
if ( !mgmtId ) {
return [];
}
return (this.etcdBackups || []).filter(x => x.clusterId === mgmtId);
},
rke2Snapshots() {
return this.value.etcdSnapshots;
},
rke1SnapshotHeaders() {
return [
STATE_NORMAN,
{
name: 'name',
labelKey: 'tableHeaders.name',
value: 'nameDisplay',
sort: ['nameSort'],
canBeVariable: true,
},
{
name: 'version',
labelKey: 'tableHeaders.version',
value: 'status.kubernetesVersion',
sort: 'status.kubernetesVersion',
width: 150,
},
{ ...AGE_NORMAN, canBeVariable: true },
{
name: 'manual',
labelKey: 'tableHeaders.manual',
value: 'manual',
formatter: 'Checked',
sort: ['manual'],
align: 'center',
width: 50,
},
];
},
rke2SnapshotHeaders() {
return [
STATE_NORMAN,
{
name: 'name',
labelKey: 'tableHeaders.name',
value: 'nameDisplay',
sort: ['nameSort'],
canBeVariable: true,
},
{
name: 'size',
labelKey: 'tableHeaders.size',
value: 'size',
sort: 'size',
formatter: 'Si',
width: 150,
},
{
...AGE,
value: 'createdAt',
sort: 'createdAt:desc',
canBeVariable: true
},
];
},
showRegistration() {
if ( !this.clusterToken ) {
return false;
}
if ( this.value.isImported ) {
return !this.value.mgmt?.isReady;
}
if ( this.value.isCustom ) {
return true;
}
return false;
},
isClusterReady() {
return this.value.mgmt?.isReady;
},
showLog() {
return this.value.mgmt?.hasLink('log');
},
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));
}
},
mounted() {
window.c = this;
},
methods: {
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);
}
},
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else>
<Banner v-if="$fetchState.error" color="error" :label="$fetchState.error" />
<ResourceTabs v-model="value" :default-tab="defaultTab">
<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"
:groupable="false"
:group-by="value.isCustom ? null : 'poolId'"
group-ref="pool"
:group-sort="['pool.nameDisplay']"
>
<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-html="group.ref.groupByPoolShortLabel" />
<div v-else v-html="t('resourceTable.groupLabel.notInANodePool')">
</div>
<div v-if="group.ref && group.ref.template" 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" class="right mr-45">
<template v-if="value.hasLink('update')">
<button v-tooltip="t('node.list.scaleDown')" :disabled="group.ref.spec.replicas < 2" type="button" class="btn btn-sm role-secondary" @click="group.ref.scalePool(-1)">
<i class="icon icon-sm icon-minus" />
</button>
<button v-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>
</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"
:groupable="false"
:group-by="value.isCustom ? null : 'spec.nodePoolName'"
group-ref="pool"
:group-sort="['pool.nameDisplay']"
>
<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-html="t('resourceTable.groupLabel.nodePool', { name: group.ref.spec.hostnamePrefix, count: group.rows.length}, true)">
</div>
<div v-else v-html="t('resourceTable.groupLabel.notInANodePool')">
</div>
<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" class="right">
<template v-if="group.ref.hasLink('update')">
<button v-tooltip="t('node.list.scaleDown')" :disabled="group.ref.spec.quantity < 2" type="button" class="btn btn-sm role-secondary" @click="group.ref.scalePool(-1)">
<i class="icon icon-sm icon-minus" />
</button>
<button v-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 in logs" :key="line.id">
<td :key="line.id + '-time'" class="time" v-html="format(line.time)" />
<td :key="line.id + '-msg'" class="msg" v-html="line.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">
<CustomCommand v-if="value.isCustom" :cluster-token="clusterToken" :cluster="value" />
<template v-else>
<h4 v-html="t('cluster.import.commandInstructions', null, true)" />
<CopyCode class="m-10 p-10">
{{ clusterToken.command }}
</CopyCode>
<h4 class="mt-10" v-html="t('cluster.import.commandInstructionsInsecure', null, true)" />
<CopyCode class="m-10 p-10">
{{ clusterToken.insecureCommand }}
</CopyCode>
<h4 class="mt-10" v-html="t('cluster.import.clusterRoleBindingInstructions', null, true)" />
<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
:headers="value.isRke1 ? rke1SnapshotHeaders : rke2SnapshotHeaders"
default-sort-by="age"
:table-actions="value.isRke1"
:rows="value.isRke1 ? rke1Snapshots : rke2Snapshots"
:search="false"
>
<template #header-right>
<AsyncButton
mode="snapshot"
class="btn role-primary"
:disabled="!isClusterReady"
@click="takeSnapshot"
/>
</template>
</SortableTable>
</Tab>
</ResourceTabs>
</div>
</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;
}
}
}
}
.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);
}
}
}
</style>