Add pkg/harvester components + shell portings - 1

Signed-off-by: Francesco Torchia <francesco.torchia@suse.com>
This commit is contained in:
Francesco Torchia
2024-10-23 17:00:46 +02:00
parent f8408469f7
commit 4f2688f6ab
265 changed files with 42316 additions and 82 deletions
@@ -0,0 +1,293 @@
<script>
import { LabeledInput } from '@components/Form/LabeledInput';
import LabelValue from '@shell/components/LabelValue';
import { BadgeState } from '@components/BadgeState';
import { Banner } from '@components/Banner';
import { RadioGroup, RadioButton } from '@components/Form/Radio';
import HarvesterDisk from '../../mixins/harvester-disk';
import Tags from '../../components/DiskTags';
import { HCI } from '../../types';
import { LONGHORN_SYSTEM } from './index';
export default {
components: {
LabeledInput,
LabelValue,
BadgeState,
Banner,
RadioGroup,
RadioButton,
Tags,
},
mixins: [
HarvesterDisk,
],
props: {
value: {
type: Object,
default: () => {
return {};
},
},
disks: {
type: Array,
default: () => [],
},
mode: {
type: String,
default: 'edit',
},
},
data() {
return {};
},
computed: {
allowSchedulingOptions() {
return [{
label: this.t('generic.enabled'),
value: true,
}, {
label: this.t('generic.disabled'),
value: false,
}];
},
evictionRequestedOptions() {
return [{
label: this.t('generic.yes'),
value: true,
}, {
label: this.t('generic.no'),
value: false,
}];
},
mountedMessage() {
const state = this.blockDevice?.metadata?.state || {};
if (state?.error) {
return state?.message;
} else {
return '';
}
},
isProvisioned() {
return this.blockDevice?.spec.fileSystem.provisioned;
},
forceFormattedDisabled() {
const lastFormattedAt = this.blockDevice?.status?.deviceStatus?.fileSystem?.LastFormattedAt;
const fileSystem = this.blockDevice?.status?.deviceStatus?.fileSystem.type;
const systems = ['ext4', 'XFS'];
if (lastFormattedAt || this.blockDevice?.childParts?.length > 0) {
return true;
} else if (systems.includes(fileSystem)) {
return false;
} else if (!fileSystem) {
return true;
} else {
return !this.canEditPath;
}
},
canEditPath() {
if (this.mountedMessage) {
return true;
}
if (this.value.isNew && !this.value.originPath) {
return true;
}
return false;
},
isFormatted() {
return !!this.blockDevice?.status?.deviceStatus?.fileSystem?.LastFormattedAt;
},
formattedBannerLabel() {
const system = this.blockDevice?.status?.deviceStatus?.fileSystem?.type;
const label = this.t('harvester.host.disk.lastFormattedAt.info');
if (system) {
return `${ label } ${ this.t('harvester.host.disk.fileSystem.info', { system }) }`;
} else {
return label;
}
},
provisionPhase() {
return this.blockDevice?.provisionPhase || {};
},
blockDevice() {
const inStore = this.$store.getters['currentProduct'].inStore;
const name = this.value?.name;
return this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ name }`) || {};
},
isCorrupted() {
return this.blockDevice?.status?.deviceStatus?.fileSystem?.corrupted;
},
isFormatting() {
return this.blockDevice.isFormatting;
},
},
methods: {
update() {
this.$emit('input', this.value);
},
},
};
</script>
<template>
<div class="disk" @input="update">
<div class="mt-10" />
<Banner
v-if="mountedMessage && isProvisioned"
color="error"
:label="mountedMessage"
/>
<Banner
v-if="isFormatting"
color="info"
:label="t('harvester.host.disk.fileSystem.formatting')"
/>
<Banner
v-else-if="isFormatted && !isCorrupted"
color="info"
:label="formattedBannerLabel"
/>
<div v-if="!value.isNew">
<div class="row">
<div class="col span-12">
<Tags
v-model="value.tags"
:label="t('harvester.host.disk.tags.label')"
:add-label="t('harvester.host.disk.tags.addLabel')"
:mode="mode"
/>
</div>
</div>
<div class="row mt-10">
<div class="col span-12">
<div class="pull-right">
{{ t('harvester.host.disk.conditions') }}:
<BadgeState
v-clean-tooltip="readyCondition.message"
:color="readyCondition.status === 'True' ? 'bg-success' : 'bg-error' "
:icon="readyCondition.status === 'True' ? 'icon-checkmark' : 'icon-warning' "
label="Ready"
class="mr-10 ml-10 state"
/>
<BadgeState
v-clean-tooltip="schedulableCondition.message"
:color="schedulableCondition.status === 'True' ? 'bg-success' : 'bg-error' "
:icon="schedulableCondition.status === 'True' ? 'icon-checkmark' : 'icon-warning' "
label="Schedulable"
class="mr-10 state"
/>
<BadgeState
v-if="provisionPhase.label"
:color="provisionPhase.color"
:icon="provisionPhase.icon"
:label="provisionPhase.label"
class="mr-10 state"
/>
</div>
</div>
</div>
<div v-if="!value.isNew" class="row mt-30">
<div class="col flex span-12">
<LabelValue
:name="t('harvester.host.disk.storageAvailable.label')"
:value="value.storageAvailable"
/>
<LabelValue
:name="t('harvester.host.disk.storageScheduled.label')"
:value="value.storageScheduled"
/>
<LabelValue
:name="t('harvester.host.disk.storageMaximum.label')"
:value="value.storageMaximum"
/>
</div>
</div>
<hr class="mt-10" />
</div>
<div class="row mt-10">
<div class="col span-12">
<LabeledInput
v-model="value.displayName"
:label="t('generic.name')"
:disabled="true"
/>
</div>
</div>
<div v-if="(value.isNew && !isFormatted) || isCorrupted" class="row mt-10">
<div class="col span-6">
<RadioGroup
v-model="value.forceFormatted"
:mode="mode"
name="forceFormatted"
label-key="harvester.host.disk.forceFormatted.label"
:labels="[t('generic.no'),t('harvester.host.disk.forceFormatted.yes')]"
:options="[false, true]"
:disabled="forceFormattedDisabled"
tooltip-key="harvester.host.disk.forceFormatted.toolTip"
>
<template #1="{option, listeners}">
<RadioButton
:label="option.label"
:val="option.value"
:value="value.forceFormatted"
:disabled="forceFormattedDisabled && !value.forceFormatted"
v-on="listeners"
/>
</template>
</RadioGroup>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.close {
top: 10px;
right: 10px;
padding:0;
position: absolute;
}
.disk {
position: relative;
.secret-name {
height: $input-height;
}
&:not(:last-of-type) {
padding-bottom: 10px;
margin-bottom: 30px;
}
}
.flex {
display: flex;
justify-content: space-between;
}
.badge-state {
padding: 2px 5px;
}
</style>
@@ -0,0 +1,216 @@
<script>
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import { LabeledInput } from '@components/Form/LabeledInput';
import UnitInput from '@shell/components/form/UnitInput';
import { RadioGroup } from '@components/Form/Radio';
import { Checkbox } from '@components/Form/Checkbox';
import { HCI } from '../../types';
export const ksmtunedMode = [{
value: 'standard',
label: 'Standard'
}, {
value: 'high',
label: 'High-Perfomanace'
}, {
value: 'customized',
label: 'Customized'
}];
export const ksmtunedRunOption = [{
label: 'Run',
value: 'run'
}, {
label: 'Stop',
value: 'stop'
}, {
label: 'Prune',
value: 'prune'
}];
export default {
name: 'HarvesterKsmtuned',
components: {
Checkbox, LabeledInput, LabeledSelect, RadioGroup, UnitInput
},
props: {
mode: {
type: String,
required: true
},
node: {
type: Object,
required: true,
},
registerBeforeHook: {
type: Function,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.KSTUNED });
this.ksmtuned = hash.find((node) => {
return node.id === this.node.id;
});
this.enableMergeAcrossNodes = !!this.ksmtuned.spec?.mergeAcrossNodes;
this.spec = this.ksmtuned.spec;
},
data() {
return {
ksmtuned: {},
spec: {},
thresCoef: 30,
ksmtunedMode,
ksmtunedRunOption,
enableMergeAcrossNodes: true
};
},
created() {
this.registerBeforeHook(this.saveKsmtuned, 'saveKsmtuned');
},
computed: {
isCustomizedMode() {
return this.spec.mode === 'customized';
},
showKsmt() {
return this.spec.run === 'run';
}
},
methods: {
async saveKsmtuned() {
this.spec.mergeAcrossNodes = this.enableMergeAcrossNodes ? 1 : 0;
this.$set(this.ksmtuned, 'spec', this.spec);
await this.ksmtuned.save().catch((reason) => {
if (reason?.type === 'error') {
this.$store.dispatch('growl/error', {
title: this.t('harvester.notification.title.error'),
message: reason?.message
}, { root: true });
return Promise.reject(new Error('saveKsmtuned error'));
}
});
},
}
};
</script>
<template>
<div>
<LabeledSelect
v-model="spec.run"
:label="t('harvester.host.ksmtuned.run')"
:options="ksmtunedRunOption"
class="mb-20"
:mode="mode"
required
/>
<template v-if="showKsmt">
<UnitInput
v-model="spec.thresCoef"
v-int-number
:label="t('harvester.host.ksmtuned.thresCoef')"
suffix="%"
:delay="0"
required
:mode="mode"
class="mb-20"
/>
<Checkbox v-model="enableMergeAcrossNodes" :mode="mode" class="check mb-20" type="checkbox" :label="t('harvester.host.ksmtuned.enableMergeNodes')" />
<h3>
<t k="harvester.host.ksmtuned.modeLink" :raw="true" />
</h3>
<RadioGroup
v-model="spec.mode"
class="mb-20"
:name="t('harvester.host.ksmtuned.mode')"
:options="ksmtunedMode"
/>
<template v-if="isCustomizedMode">
<div class="row">
<div class="col span-6">
<LabeledInput
v-model.number="spec.ksmtunedParameters.boost"
required
type="number"
:label="t('harvester.host.ksmtuned.parameters.boost')"
:tooltip="t('harvester.host.ksmtuned.parameters.description.boost')"
class="mb-20"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model.number="spec.ksmtunedParameters.decay"
required
type="number"
:label="t('harvester.host.ksmtuned.parameters.decay')"
:tooltip="t('harvester.host.ksmtuned.parameters.description.decay')"
class="mb-20"
:mode="mode"
/>
</div>
</div>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model.number="spec.ksmtunedParameters.minPages"
required
type="number"
:label="t('harvester.host.ksmtuned.parameters.minPages')"
:tooltip="t('harvester.host.ksmtuned.parameters.description.minPages')"
class="mb-20"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model.number="spec.ksmtunedParameters.maxPages"
required
type="number"
:label="t('harvester.host.ksmtuned.parameters.maxPages')"
:tooltip="t('harvester.host.ksmtuned.parameters.description.maxPages')"
class="mb-20"
:mode="mode"
/>
</div>
</div>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model.number="spec.ksmtunedParameters.sleepMsec"
required
type="number"
:label="t('harvester.host.ksmtuned.parameters.sleepMsec')"
:tooltip="t('harvester.host.ksmtuned.parameters.description.sleepMsec')"
class="mb-20"
:mode="mode"
/>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -0,0 +1,370 @@
<script>
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import { LabeledInput } from '@components/Form/LabeledInput';
import { RadioGroup } from '@components/Form/Radio';
import { Checkbox } from '@components/Form/Checkbox';
import { SECRET } from '@shell/config/types';
import ModalWithCard from '@shell/components/ModalWithCard';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { Banner } from '@components/Banner';
import { base64Encode, base64Decode } from '@shell/utils/crypto';
import { exceptionToErrorsArray } from '@shell/utils/error';
const _NEW = '_NEW';
export default {
name: 'HarvesterSeeder',
components: {
Checkbox,
LabeledInput,
LabeledSelect,
RadioGroup,
ModalWithCard,
NameNsDescription,
Banner,
},
props: {
mode: {
type: String,
required: true
},
node: {
type: Object,
required: true,
},
registerAfterHook: {
type: Function,
required: true,
},
inventory: {
type: Object,
required: true,
},
},
data() {
const enableInventory = !!this.inventory?.id;
return {
enableInventory,
value: this.inventory,
secret: {},
errors: [],
newSecretSelected: false,
isOpen: false,
};
},
created() {
this.registerAfterHook(this.saveInventory, 'saveInventory');
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.secret = await this.$store.dispatch(`${ inStore }/create`, {
type: SECRET,
data: {
username: '',
password: '',
},
metadata: {
namespace: '',
name: '',
describe: '',
},
});
},
computed: {
secretOption() {
const inStore = this.$store.getters['currentProduct'].inStore;
const out = this.$store.getters[`${ inStore }/all`](SECRET).filter((s) => {
return s.data?.username && s.data?.password;
}).map( (s) => {
return {
label: s.id,
value: s.id
};
});
// if (!(this.disableCreate || this.mode === _VIEW) && this.isCreatable) {
out.unshift({
label: this.t('harvester.virtualMachine.createSSHKey'),
value: _NEW,
});
// }
return out;
},
selectedSecret: {
get() {
const namespace = this.value.spec?.baseboardSpec?.connection?.authSecretRef?.namespace;
const name = this.value?.spec?.baseboardSpec?.connection?.authSecretRef?.name;
if (namespace && name) {
return `${ namespace }/${ name }`;
} else {
return '';
}
},
set(value) {
if (value === _NEW) {
this.newSecretSelected = true;
} else {
const [namespace, name] = value.split('/');
this.$set(this.value.spec.baseboardSpec.connection.authSecretRef, 'namespace', namespace);
this.$set(this.value.spec.baseboardSpec.connection.authSecretRef, 'name', name);
}
},
},
username: {
get() {
return base64Decode(this.secret?.data?.username);
},
set(value) {
this.$set(this.secret.data, 'username', base64Encode(value));
}
},
password: {
get() {
return base64Decode(this.secret?.data?.password);
},
set(value) {
this.$set(this.secret.data, 'password', base64Encode(value));
}
},
},
methods: {
async saveInventory() {
if (this.enableInventory) {
const errors = [];
if (!this.value.spec.baseboardSpec.connection.host) {
errors.push(this.t('validation.required', { key: this.t('harvester.seeder.inventory.host.label') }, true));
}
if (!this.value.spec.baseboardSpec.connection.port) {
errors.push(this.t('validation.required', { key: this.t('harvester.seeder.inventory.port.label') }, true));
}
if (!this.selectedSecret) {
errors.push(this.t('validation.required', { key: this.t('harvester.seeder.inventory.secret.label') }, true));
}
if (errors.length > 0) {
return Promise.reject(exceptionToErrorsArray(errors));
}
if (!this.value.id) {
this.value.metadata.annotations['metal.harvesterhci.io/local-node-name'] = this.node.id;
}
this.value.metadata.annotations['metal.harvesterhci.io/local-inventory'] = 'true';
return await this.value.save();
} else if (this.value.id) {
return await this.value.remove();
} else {
return Promise.resolve();
}
},
show() {
this.isOpen = true;
},
hide() {
this.isOpen = false;
this.newSecretSelected = false;
},
cancel() {
this.hide();
},
async saveSecret(buttonCb) {
this.errors = [];
if (!this.username) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.username') }, true));
}
if (!this.password) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.password') }, true));
}
if (this.errors.length > 0) {
buttonCb(false);
return;
}
try {
const res = await this.secret.save();
if (res.id) {
this.secretOption.push({
label: res.id,
value: res.id
});
}
this.selectedSecret = res.id;
buttonCb(true);
this.cancel();
} catch (err) {
this.errors = [err.message];
buttonCb(false);
}
}
},
watch: {
newSecretSelected(val) {
if (val) {
this.show();
}
}
}
};
</script>
<template>
<div>
<div v-if="inventory.warningMessages.length > 0">
<Banner
v-for="msg in inventory.warningMessages"
:key="msg.text"
color="error"
:label="msg.text"
/>
</div>
<div class="row">
<div class="col span-6">
<RadioGroup
v-model="enableInventory"
:options="[
{ label: t('generic.enabled'), value: true },
{ label: t('generic.disabled'), value: false }
]"
:mode="mode"
name="enableInventory"
/>
</div>
</div>
<div v-if="enableInventory">
<div class="row mt-10">
<div class="col span-6">
<LabeledInput
v-model="value.spec.baseboardSpec.connection.host"
:label="t('harvester.seeder.inventory.host.label')"
:placeholder="t('harvester.seeder.inventory.host.placeholder')"
:mode="mode"
required
/>
<Checkbox
v-model="value.spec.baseboardSpec.connection.insecureTLS"
class="mt-5"
:mode="mode"
:label="t('harvester.seeder.inventory.insecureTLS.label')"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model.number="value.spec.baseboardSpec.connection.port"
:label="t('harvester.seeder.inventory.port.label')"
:placeholder="t('harvester.seeder.inventory.port.placeholder')"
:mode="mode"
required
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<LabeledSelect
v-model="selectedSecret"
:label="t('harvester.seeder.inventory.secret.label')"
:mode="mode"
:options="secretOption"
required
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<RadioGroup
v-model="value.spec.events.enabled"
name="enabled"
:options="[true, false]"
:label="t('harvester.seeder.inventory.event.label')"
:labels="[t('generic.enabled'), t('generic.disabled')]"
:mode="mode"
/>
</div>
<div
v-if="value.spec.events.enabled"
class="col span-6"
>
<LabeledInput
v-model="value.spec.events.pollingInterval"
:label="t('harvester.seeder.inventory.pollingInterval.label')"
:mode="mode"
/>
</div>
</div>
<ModalWithCard
v-if="isOpen"
width="80%"
:errors="errors"
name="secretModal"
@finish="saveSecret"
@close="cancel"
>
<template #title>
{{ t('harvester.seeder.inventory.secret.create.title') }}
</template>
<template #content>
<NameNsDescription
v-model="secret"
:namespaced="true"
mode="create"
/>
<LabeledInput
v-model="username"
:label="t('harvester.virtualMachine.input.username')"
class="mb-20"
required
/>
<LabeledInput
v-model="password"
type="password"
:label="t('harvester.virtualMachine.input.password')"
class="mb-20"
required
/>
</template>
</ModalWithCard>
</div>
</div>
</template>
@@ -0,0 +1,688 @@
<script>
import { mapGetters } from 'vuex';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import Footer from '@shell/components/form/Footer';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { LabeledInput } from '@components/Form/LabeledInput';
import ArrayListGrouped from '@shell/components/form/ArrayListGrouped';
import ButtonDropdown from '@shell/components/ButtonDropdown';
import CreateEditView from '@shell/mixins/create-edit-view';
import { HCI as HCI_LABELS_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { LONGHORN, SECRET } from '@shell/config/types';
import { HCI } from '../../types';
import { allHash } from '@shell/utils/promise';
import { formatSi } from '@shell/utils/units';
import { findBy } from '@shell/utils/array';
import { clone } from '@shell/utils/object';
import { exceptionToErrorsArray } from '@shell/utils/error';
import KeyValue from '@shell/components/form/KeyValue';
import Loading from '@shell/components/Loading.vue';
import MessageLink from '@shell/components/MessageLink';
import { ADD_ONS } from '@pkg/harvester/config/harvester-map';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '@pkg/harvester/config/harvester';
import { _EDIT } from '@shell/config/query-params';
import { sortBy } from '@shell/utils/sort';
import { Banner } from '@components/Banner';
import HarvesterDisk from './HarvesterDisk';
import HarvesterKsmtuned from './HarvesterKsmtuned';
import HarvesterSeeder from './HarvesterSeeder';
import Tags from '../../components/DiskTags';
export const LONGHORN_SYSTEM = 'longhorn-system';
export default {
name: 'HarvesterEditNode',
components: {
Footer,
Tabbed,
Tab,
LabeledInput,
NameNsDescription,
ArrayListGrouped,
HarvesterDisk,
HarvesterKsmtuned,
ButtonDropdown,
KeyValue,
Banner,
Tags,
Loading,
HarvesterSeeder,
MessageLink,
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
required: true,
}
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = {
longhornNodes: this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.NODES }),
blockDevices: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.BLOCK_DEVICE }),
addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }),
secrets: this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET }),
};
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.INVENTORY)) {
hash.inventories = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.INVENTORY });
}
await allHash(hash);
const blockDevices = this.$store.getters[`${ inStore }/all`](HCI.BLOCK_DEVICE);
const provisionedBlockDevices = blockDevices.filter((d) => {
const provisioned = d?.spec?.fileSystem?.provisioned;
const isCurrentNode = d?.spec?.nodeName === this.value.id;
const isLonghornMounted = findBy(this.longhornDisks, 'name', d.metadata.name);
return provisioned && isCurrentNode && !isLonghornMounted;
})
.map((d) => {
const corrupted = d?.status?.deviceStatus?.fileSystem?.corrupted;
return {
isNew: true,
name: d?.metadata?.name,
originPath: d?.spec?.fileSystem?.mountPoint,
path: d?.spec?.fileSystem?.mountPoint,
blockDevice: d,
displayName: d?.displayName,
forceFormatted: corrupted ? true : d?.spec?.fileSystem?.forceFormatted || false,
};
});
const disks = [...this.longhornDisks, ...provisionedBlockDevices];
this.disks = disks;
this.newDisks = clone(disks);
this.blockDeviceOpts = this.getBlockDeviceOpts();
const addons = this.$store.getters[`${ inStore }/all`](HCI.ADD_ONS);
const seeder = addons.find(addon => addon.id === `harvester-system/${ ADD_ONS.HARVESTER_SEEDER }`);
const seederEnabled = seeder ? seeder?.spec?.enabled : false;
if (seederEnabled) {
const inStore = this.$store.getters['currentProduct'].inStore;
const inventories = this.$store.getters[`${ inStore }/all`](HCI.INVENTORY) || [];
const inventory = inventories.find(inv => inv.id === `harvester-system/${ this.value.id }`);
if (inventory) {
this.inventory = await this.$store.dispatch(`${ inStore }/clone`, { resource: inventory });
} else {
this.inventory = await this.$store.dispatch(`${ inStore }/create`, {
type: HCI.INVENTORY,
metadata: {
name: this.value.id,
namespace: 'harvester-system'
},
});
this.inventory.applyDefaults();
}
}
},
data() {
const customName = this.value.metadata?.annotations?.[HCI_LABELS_ANNOTATIONS.HOST_CUSTOM_NAME] || '';
const consoleUrl = this.value.metadata?.annotations?.[HCI_LABELS_ANNOTATIONS.HOST_CONSOLE_URL] || '';
return {
customName,
consoleUrl,
disks: [],
newDisks: [],
blockDevice: [],
blockDeviceOpts: [],
filteredLabels: clone(this.value.filteredSystemLabels),
inventory: {},
originValue: clone(this.value),
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
removedDisks() {
const out = this.disks.filter((d) => {
return !findBy(this.newDisks, 'name', d.name);
}) || [];
return out;
},
longhornDisks() {
const inStore = this.$store.getters['currentProduct'].inStore;
const longhornNode = this.$store.getters[`${ inStore }/byId`](LONGHORN.NODES, `${ LONGHORN_SYSTEM }/${ this.value.id }`);
const diskStatus = longhornNode?.status?.diskStatus || {};
const diskSpec = longhornNode?.spec?.disks || {};
const formatOptions = {
increment: 1024,
minExponent: 3,
maxExponent: 3,
maxPrecision: 2,
suffix: 'iB',
};
const longhornDisks = Object.keys(diskStatus).map((key) => {
const blockDevice = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ key }`);
return {
...diskStatus[key],
...diskSpec?.[key],
name: key,
isNew: false,
storageReserved: formatSi(diskSpec[key]?.storageReserved, formatOptions),
storageAvailable: formatSi(diskStatus[key]?.storageAvailable, formatOptions),
storageMaximum: formatSi(diskStatus[key]?.storageMaximum, formatOptions),
storageScheduled: formatSi(diskStatus[key]?.storageScheduled, formatOptions),
blockDevice,
displayName: blockDevice?.displayName || key,
forceFormatted: blockDevice?.spec?.fileSystem?.forceFormatted || false,
tags: diskSpec?.[key]?.tags || [],
};
});
return longhornDisks;
},
showFormattedWarning() {
const out = this.newDisks.filter(d => d.forceFormatted && d.isNew) || [];
return out.length > 0;
},
hasKsmtunedSchema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED);
},
hasBlockDevicesSchema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.BLOCK_DEVICE);
},
longhornNode() {
const inStore = this.$store.getters['currentProduct'].inStore;
const longhornNodes = this.$store.getters[`${ inStore }/all`](LONGHORN.NODES);
return longhornNodes.find(node => node.id === `${ LONGHORN_SYSTEM }/${ this.value.id }`);
},
seederEnabled() {
const inStore = this.$store.getters['currentProduct'].inStore;
const addons = this.$store.getters[`${ inStore }/all`](HCI.ADD_ONS);
const seeder = addons.find(addon => addon.id === `harvester-system/${ ADD_ONS.HARVESTER_SEEDER }`);
return seeder ? seeder?.spec?.enabled : false;
},
toEnableSeederAddon() {
const { cluster } = this.$router?.currentRoute?.params || {};
return {
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-namespace-id`,
params: {
resource: `${ HCI.ADD_ONS }`,
namespace: 'harvester-system',
cluster,
id: `${ ADD_ONS.HARVESTER_SEEDER }`
},
query: { mode: _EDIT }
};
},
hasAddonSchema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/schemaFor`](HCI.ADD_ONS);
},
hasSeederAddon() {
const inStore = this.$store.getters['currentProduct'].inStore;
const addons = this.$store.getters[`${ inStore }/all`](HCI.ADD_ONS);
return addons.find(addon => addon.id === `harvester-system/${ ADD_ONS.HARVESTER_SEEDER }`);
},
hasInventorySchema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/schemaFor`](HCI.INVENTORY);
},
},
watch: {
customName(neu) {
this.value.setAnnotation(HCI_LABELS_ANNOTATIONS.HOST_CUSTOM_NAME, neu);
},
consoleUrl(neu) {
this.value.setAnnotation(HCI_LABELS_ANNOTATIONS.HOST_CONSOLE_URL, neu);
},
newDisks() {
this.blockDeviceOpts = this.getBlockDeviceOpts();
},
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.willSave, 'willSave');
}
if (this.registerAfterHook) {
this.registerAfterHook(this.saveDisk);
this.registerAfterHook(this.saveLonghornNode);
}
},
methods: {
addDisk(id) {
const removedDisk = findBy(this.removedDisks, 'blockDevice.id', id);
if (removedDisk) {
return this.newDisks.push(removedDisk);
}
const inStore = this.$store.getters['currentProduct'].inStore;
const disk = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, id);
const mountPoint = disk?.spec?.fileSystem?.mountPoint;
const lastFormattedAt = disk?.status?.deviceStatus?.fileSystem?.LastFormattedAt;
let forceFormatted = true;
const systems = ['ext4', 'XFS'];
if (disk.childParts?.length > 0) {
forceFormatted = true;
} else if (lastFormattedAt) {
forceFormatted = false;
} else if (systems.includes(disk?.status?.deviceStatus?.fileSystem?.type)) {
forceFormatted = false;
}
const name = disk?.metadata?.name;
this.newDisks.push({
name,
path: mountPoint,
allowScheduling: false,
evictionRequested: false,
storageReserved: 0,
isNew: true,
originPath: disk?.spec?.fileSystem?.mountPoint,
blockDevice: disk,
displayName: disk?.displayName,
forceFormatted,
});
},
async saveDisk() {
const inStore = this.$store.getters['currentProduct'].inStore;
const addDisks = this.newDisks.filter(d => d.isNew);
const removeDisks = this.disks.filter(d => !findBy(this.newDisks, 'name', d.name) && d.blockDevice);
if (addDisks.length === 0 && removeDisks.length === 0) {
return Promise.resolve();
} else if (addDisks.length !== 0 && removeDisks.length === 0) {
const updatedDisks = addDisks.filter((d) => {
const blockDevice = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ d.name }`);
const { provisioned, forceFormatted } = blockDevice.spec.fileSystem;
if (provisioned && forceFormatted === d.forceFormatted) {
return false;
} else {
return true;
}
});
if (updatedDisks.length === 0) {
return Promise.resolve();
}
}
try {
await Promise.all(addDisks.map((d) => {
const blockDevice = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ d.name }`);
blockDevice.spec.fileSystem.provisioned = true;
blockDevice.spec.fileSystem.forceFormatted = d.forceFormatted;
return blockDevice.save();
}));
await Promise.all(removeDisks.map((d) => {
const blockDevice = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ d.name }`);
blockDevice.spec.fileSystem.provisioned = false;
return blockDevice.save();
}));
this.$store.dispatch('growl/success', {
title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.host.disk.notification.success', { name: this.value.metadata?.name || '' }),
}, { root: true });
} catch (err) {
return Promise.reject(exceptionToErrorsArray(err));
}
},
canRemove(row) {
return !!row?.value?.blockDevice;
},
onRemove(scope) {
scope.remove();
},
updateHostLabels(labels) {
this.filteredLabels = labels;
},
selectable(opt) {
if ( opt.disabled) {
return false;
}
return true;
},
getBlockDeviceOpts() {
const inStore = this.$store.getters['currentProduct'].inStore;
const blockDevices = this.$store.getters[`${ inStore }/all`](HCI.BLOCK_DEVICE);
const out = blockDevices
.filter((d) => {
const addedToNodeCondition = findBy(d?.status?.conditions || [], 'type', 'AddedToNode');
const isAdded = findBy(this.newDisks, 'name', d.metadata.name);
const isRemoved = findBy(this.removedDisks, 'name', d.metadata.name);
const deviceType = d.status?.deviceStatus?.details?.deviceType;
if (deviceType !== 'disk' || d?.status?.state !== 'Active') {
return false;
}
if ((!findBy(this.disks || [], 'name', d.metadata.name) &&
d?.spec?.nodeName === this.value.id &&
(!addedToNodeCondition || addedToNodeCondition?.status === 'False') &&
!d.spec?.fileSystem?.provisioned &&
!isAdded) ||
isRemoved
) {
return true;
} else {
return false;
}
})
.map((d) => {
const devPath = d.spec?.devPath;
const deviceType = d.status?.deviceStatus?.details?.deviceType;
const sizeBytes = d.status?.deviceStatus?.capacity?.sizeBytes;
const size = formatSi(sizeBytes, { increment: 1024 });
const parentDevice = d.status?.deviceStatus?.parentDevice;
const isChildAdded = this.newDisks.find(newDisk => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath);
const name = d.displayName;
let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`;
if (parentDevice) {
label = `- ${ label }`;
}
return {
label,
value: d.id,
action: this.addDisk,
kind: !parentDevice ? 'group' : '',
disabled: !!isChildAdded,
group: parentDevice || devPath,
isParent: !!parentDevice,
};
});
return sortBy(out, ['group', 'isParent', 'label']);
},
ddButtonAction() {
this.blockDeviceOpts = this.getBlockDeviceOpts();
},
willSave() {
const filteredLabels = this.filteredLabels || {};
this.value.metadata.labels = {
...this.value.metadata.labels,
...filteredLabels,
};
const originLabels = this.value.filteredSystemLabels;
Object.keys(originLabels).map((key) => {
if (!filteredLabels[key]) {
delete this.value.metadata.labels[key];
}
});
},
async saveLonghornNode() {
const inStore = this.$store.getters['currentProduct'].inStore;
const disks = this.longhornNode?.spec?.disks || {};
this.newDisks.map((disk) => {
(disks[disk.name] || {}).tags = disk.tags;
});
let count = 0;
const retrySave = async() => {
try {
await this.longhornNode.save();
} catch (err) {
if ((err.status === 409 || err.status === 403) && count < 3) {
count++;
await this.$store.dispatch(`${ inStore }/find`, {
type: LONGHORN.NODES,
id: this.longhornNode.id,
opt: { force: true },
});
await new Promise(resolve => setTimeout(resolve, '5000'));
await retrySave();
} else {
return Promise.reject(exceptionToErrorsArray(err));
}
}
};
await retrySave();
},
},
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else id="node">
<div class="content">
<NameNsDescription
:value="value"
:namespaced="false"
:mode="mode"
/>
<Tabbed ref="tabbed" class="mt-15" :side-tabs="true">
<Tab name="basics" :weight="100" :label="t('harvester.host.tabs.basics')">
<LabeledInput
v-model="customName"
:label="t('harvester.host.detail.customName')"
class="mb-20"
:mode="mode"
/>
<LabeledInput
v-model="consoleUrl"
:label="t('harvester.host.detail.consoleUrl')"
class="mb-20"
:mode="mode"
/>
</Tab>
<Tab
v-if="hasBlockDevicesSchema"
name="disk"
:weight="80"
:label="t('harvester.host.tabs.storage')"
>
<div
v-if="longhornNode"
class="row mb-20"
>
<div class="col span-12">
<Tags
v-model="longhornNode.spec.tags"
:label="t('harvester.host.tags.label')"
:add-label="t('harvester.host.tags.addLabel')"
:mode="mode"
/>
</div>
</div>
<ArrayListGrouped
v-model="newDisks"
:mode="mode"
:initial-empty-row="false"
>
<template #default="props">
<HarvesterDisk
v-model="props.row.value"
class="mb-20"
:mode="mode"
:disks="disks"
/>
</template>
<template #add>
<ButtonDropdown
v-if="!isView"
:button-label="t('harvester.host.disk.add')"
:dropdown-options="blockDeviceOpts"
size="sm"
:selectable="selectable"
@click-action="e=>addDisk(e.value)"
@dd-button-action="ddButtonAction"
>
<template #option="option">
<template v-if="option.kind === 'group'">
<b>
{{ option.label }}
</b>
</template>
<div v-else>
{{ option.label }}
</div>
</template>
</ButtonDropdown>
</template>
<template #remove-button="scope">
<button
v-if="canRemove(scope.row, scope.i) && !isView"
type="button"
class="btn role-link close btn-sm"
@click="() => onRemove(scope)"
>
<i class="icon icon-x" />
</button>
<span v-else />
</template>
</ArrayListGrouped>
</Tab>
<Tab v-if="hasKsmtunedSchema" name="Ksmtuned" :weight="70" :label="t('harvester.host.tabs.ksmtuned')">
<HarvesterKsmtuned :mode="mode" :node="value" :register-before-hook="registerBeforeHook" />
</Tab>
<Tab
v-if="hasAddonSchema"
name="seeder"
:weight="60"
:label="t('harvester.host.tabs.seeder')"
>
<HarvesterSeeder
v-if="seederEnabled && hasInventorySchema"
:mode="mode"
:node="value"
:register-after-hook="registerAfterHook"
:inventory="inventory"
/>
<div v-else-if="seederEnabled && !hasInventorySchema">
<Banner
color="info"
:label="t('harvester.seeder.banner.noInventory')"
/>
</div>
<div v-else>
<Banner
v-if="hasSeederAddon"
color="info"
>
<MessageLink
:to="toEnableSeederAddon"
prefix-label="harvester.seeder.banner.enable.prefix"
middle-label="harvester.seeder.banner.enable.middle"
suffix-label="harvester.seeder.banner.enable.suffix"
/>
</Banner>
<Banner
v-else
color="warning"
:label="t('harvester.seeder.banner.noAddon')"
/>
</div>
</Tab>
<Tab name="labels" label-key="harvester.host.tabs.labels">
<KeyValue
key="labels"
:value="filteredLabels"
:add-label="t('labels.addLabel')"
:mode="mode"
:title="t('labels.labels.title')"
:read-allowed="false"
:value-can-be-empty="true"
@input="updateHostLabels"
/>
</Tab>
</Tabbed>
<Banner
v-if="showFormattedWarning"
color="warning"
:label="t('harvester.host.disk.forceFormatted.toolTip')"
/>
</div>
<Footer class="footer" :mode="mode" :errors="errors" @save="save" @done="done" />
</div>
</template>
<style lang="scss" scoped>
#node {
display: flex;
flex-grow: 1;
flex-direction: column;
.content {
flex-grow: 1
}
.wrapper{
position: relative;
}
.nicOption {
display: flex;
justify-content: space-between;
}
}
</style>