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,191 @@
<script>
import { mapGetters } from 'vuex';
import { clone } from '@shell/utils/object';
import { randomStr } from '@shell/utils/string';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ModalWithCard from '@shell/components/ModalWithCard';
const _NEW = '_NEW';
export default {
components: {
LabeledInput,
LabeledSelect,
ModalWithCard,
},
props: {
value: {
type: [Array, String],
default: () => {
return [];
}
},
resource: {
type: Object,
default: () => {
return {};
}
},
userOptions: {
type: Array,
default: () => {
return [];
}
},
multiple: {
type: Boolean,
default: false
},
mode: {
type: String,
default: 'create',
},
disabled: {
type: Boolean,
default: false
}
},
data() {
return {
selectedUser: this.value,
newUsername: '',
randomStr: randomStr(5).toLowerCase(),
errors: [],
isOpen: false,
};
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
watch: {
value(neu) {
this.selectedUser = neu;
},
selectedUser(val, old) {
if ( val.includes(_NEW)) {
this.$set(this, 'selectedUser', old);
this.update();
this.show();
}
}
},
methods: {
update() {
this.$emit('update:user', clone(this.selectedUser));
},
addUser(buttonCb) {
const reg = /^[-._0-9a-zA-Z]+$/;
this.errors = [];
if (!reg.test(this.newUsername)) {
const message = this.t('harvester.virtualMachine.accessCredentials.invalidUser');
this.errors.push(message);
buttonCb(false);
return;
}
if (this.userOptions.includes(this.newUsername)) {
const message = this.t('harvester.virtualMachine.accessCredentials.duplicatedUser');
this.errors.push(message);
buttonCb(false);
return;
}
if (this.multiple) {
this.selectedUser.push(this.newUsername);
} else {
this.selectedUser = this.newUsername;
}
this.$emit('update:newUser', this.newUsername);
this.update();
buttonCb(true);
this.cancel();
},
show() {
this.isOpen = true;
},
hide() {
this.isOpen = false;
},
cancel() {
this.hide();
this.newUsername = '';
},
}
};
</script>
<template>
<div class="vm__credentials-users">
<LabeledSelect
v-model="selectedUser"
:options="userOptions"
:label="t('harvester.virtualMachine.accessCredentials.injectSSH.users')"
:taggable="multiple"
:mode="mode"
:multiple="multiple"
:searchable="false"
:tooltip="t('harvester.virtualMachine.accessCredentials.userTips')"
:class="{'hasUsers': selectedUser.length > 0}"
@input="update"
/>
<ModalWithCard
v-if="isOpen"
:name="randomStr"
width="40%"
:errors="errors"
@finish="addUser"
@close="cancel"
>
<template #title>
{{ t('harvester.virtualMachine.accessCredentials.injectSSH.addUser') }}
</template>
<template #content>
<LabeledInput
v-model="newUsername"
:label="t('harvester.virtualMachine.input.username')"
class="mb-20"
required
@keydown.native.enter.prevent="()=>{}"
/>
</template>
</ModalWithCard>
</div>
</template>
<style lang="scss">
.vm__credentials-users {
.hasUsers.labeled-select.edit.taggable {
.labeled-tooltip {
.icon.status-icon {
top: 30px;
}
}
}
}
</style>
@@ -0,0 +1,212 @@
<script>
import { mapGetters } from 'vuex';
import { Banner } from '@components/Banner';
import InfoBox from '@shell/components/InfoBox';
import { randomStr } from '@shell/utils/string';
import { removeObject } from '@shell/utils/array';
import { ACCESS_CREDENTIALS } from '../../../config/harvester-map';
import { _EDIT } from '@shell/config/query-params';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
const _NEW = '_NEW';
export default {
components: {
Banner,
InfoBox
},
props: {
value: {
type: Array,
default: () => {
return [];
}
},
resource: {
type: Object,
default: () => {
return {};
}
},
mode: {
type: String,
default: 'create',
},
disableCreate: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
isQemuInstalled: {
type: Boolean,
default: false
}
},
data() {
return {
ACCESS_CREDENTIALS,
toRemove: [],
toSave: [],
rows: [],
newUserOptions: [],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
isEdit() {
return this.mode === _EDIT;
},
historyUsersOptions() {
const out = JSON.parse(this.resource?.spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_USERS] || '[]');
out.unshift({
label: this.t('harvester.virtualMachine.accessCredentials.injectSSH.addUser'),
value: _NEW,
});
return out;
},
userOptions() {
return Array.from(new Set([...this.historyUsersOptions, ...this.newUserOptions]));
},
},
watch: {
value: {
handler(neu) {
this.rows = [...neu];
},
deep: true,
immediate: true,
},
},
methods: {
addCredentials(type) {
const neu = {
source: type,
users: [],
sshkeys: [],
username: '',
newPassword: '',
secretName: this.generateSecretName(this.resource.metadata.name),
};
this.rows.push(neu);
this.update();
},
remove(row) {
removeObject(this.rows, row);
this.update();
},
update() {
this.$emit('input', this.rows);
},
updateNewUser(newUser) {
this.newUserOptions.push(newUser);
},
generateSecretName(name) {
return name ? `${ name }-${ randomStr(5).toLowerCase() }` : undefined;
},
componentFor(type) {
switch (type) {
case ACCESS_CREDENTIALS.RESET_PWD:
return require(`./type/basicAuth.vue`).default;
case ACCESS_CREDENTIALS.INJECT_SSH:
return require(`./type/sshkey.vue`).default;
}
},
headerFor(type) {
return {
userPassword: this.$store.getters['i18n/t']('harvester.virtualMachine.accessCredentials.resetPwd.label'),
sshPublicKey: this.$store.getters['i18n/t']('harvester.virtualMachine.accessCredentials.injectSSH.label'),
}[type];
},
}
};
</script>
<template>
<div>
<Banner class="mb-20" color="info" :label="t('harvester.virtualMachine.accessCredentials.tips')" />
<div v-for="(row, i) in rows" :key="row.id">
<InfoBox class="volume-source">
<button v-if="isEdit" type="button" class="role-link btn btn-sm remove-vol" @click="remove(row)">
<i class="icon icon-x" />
</button>
<h3>
<span>
{{ headerFor(row.source) }}
</span>
</h3>
<div>
<component
:is="componentFor(row.source)"
v-model="rows[i]"
:rows="rows"
:resource="resource"
:user-options="userOptions"
:mode="mode"
:idx="i"
@update="update"
@update:newUser="updateNewUser"
/>
</div>
</InfoBox>
</div>
<div v-if="isEdit && isQemuInstalled" class="mt-20">
<button
type="button"
class="btn btn-sm bg-primary mr-15 mb-10"
@click="addCredentials(ACCESS_CREDENTIALS.RESET_PWD)"
>
{{ t('harvester.virtualMachine.accessCredentials.resetPwd.label') }}
</button>
<button
type="button"
class="btn btn-sm bg-primary mr-15 mb-10"
@click="addCredentials(ACCESS_CREDENTIALS.INJECT_SSH)"
>
{{ t('harvester.virtualMachine.accessCredentials.injectSSH.label') }}
</button>
</div>
</div>
</template>
<style lang='scss' scoped>
.volume-source {
position: relative;
}
.remove-vol {
position: absolute;
top: 10px;
right: 10px;
padding: 0px;
}
</style>
@@ -0,0 +1,94 @@
<script>
import Password from '@shell/components/form/Password';
import AccessCredentialsUsers from '../AccessCredentialsUsers';
export default {
name: 'HarvesterEditVolume',
components: { Password, AccessCredentialsUsers },
props: {
mode: {
type: String,
default: 'create'
},
resource: {
type: Object,
default: () => {
return {};
}
},
userOptions: {
type: Array,
default: () => {
return [];
}
},
value: {
type: Object,
default: () => {
return {};
}
},
},
data() {
return {
defaultPwdValue: {
username: '',
newPassword: ''
}
};
},
methods: {
update() {
this.$emit('update');
},
updateUser(neu) {
this.$set(this.value, 'username', neu);
this.update();
},
updateNewUser(neu) {
this.$emit('update:newUser', neu);
}
},
};
</script>
<template>
<div class="row" @input="update">
<div class="col span-6">
<AccessCredentialsUsers
v-model="value.username"
:resource="resource"
:user-options="userOptions"
:multiple="false"
:mode="mode"
@update:user="updateUser"
@update:newUser="updateNewUser"
/>
</div>
<div class="col span-6">
<Password
ref="password"
v-model="value.newPassword"
:mode="mode"
:disabled="mode !== 'edit'"
:label="t('harvester.virtualMachine.input.password')"
required
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.action {
display: flex;
flex-direction: row-reverse;
}
</style>
@@ -0,0 +1,85 @@
<script>
import SSHKey from '../../VirtualMachineSSHKey';
import AccessCredentialsUsers from '../AccessCredentialsUsers';
export default {
name: 'HarvesterEditVolume',
components: { SSHKey, AccessCredentialsUsers },
props: {
mode: {
type: String,
default: 'create'
},
resource: {
type: Object,
default: () => {
return {};
}
},
userOptions: {
type: Array,
default: () => {
return [];
}
},
value: {
type: Object,
default: () => {
return {};
}
},
},
methods: {
update() {
this.$emit('update');
},
updateUser(neu) {
this.$set(this.value, 'users', neu);
this.update();
},
updateSSH(neu) {
this.$set(this.value, 'sshkeys', neu);
this.update();
},
updateNewUser(neu) {
this.$emit('update:newUser', neu);
}
},
};
</script>
<template>
<div>
<div class="columns row">
<div class="col span-6">
<AccessCredentialsUsers
v-model="value.users"
:resource="resource"
:user-options="userOptions"
:mode="mode"
:multiple="true"
@update:user="updateUser"
@update:newUser="updateNewUser"
/>
</div>
<div class="col span-6">
<SSHKey
v-model="value.sshkeys"
class="mb-20"
:namespace="resource.metadata.namespace"
:mode="mode"
:searchable="false"
@update:sshKey="updateSSH"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,155 @@
<script>
import { mapGetters } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
import { _VIEW } from '@shell/config/query-params';
import { CONFIG_MAP } from '@shell/config/types';
const _NEW = '_NEW';
const _NONE = '_NONE';
export default {
components: { YamlEditor, LabeledSelect },
props: {
mode: {
type: String,
default: 'create'
},
value: {
type: String,
default: ''
},
type: {
type: String,
default: ''
},
options: {
type: Array,
default: () => {
return [];
}
},
configId: {
type: String,
default: ''
},
viewCode: {
type: Boolean,
default: false
}
},
data() {
return {
id: '',
yamlScript: this.value,
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
editorMode() {
return this.isView || this.viewCode ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
},
isView() {
return this.mode === _VIEW;
},
},
watch: {
value(neu) {
this.yamlScript = neu;
},
configId(neu) {
this.id = this.configId;
},
id(neu, old) {
const cloudInit = this.$store.getters['harvester/byId'](CONFIG_MAP, neu)?.data?.cloudInit || '';
this.$emit('updateTemplateId', this.type, neu);
if (!neu) {
// should not reset yaml when nothing is selected
return;
} else if (neu === _NEW) {
this.$emit('show', this.type);
this.id = old;
return;
} else if (neu === _NONE ) {
this.yamlScript = '';
} else {
this.yamlScript = cloudInit;
}
this.$refs['yaml'].updateValue(cloudInit);
},
yamlScript(neu) {
this.$emit('update', neu, this.type);
}
},
methods: {
refresh() {
this.$refs.yaml.refresh();
},
updateValue() {
this.$refs['yaml'].updateValue(this.value);
}
}
};
</script>
<template>
<div class="mb-20">
<h3>{{ t(`harvester.virtualMachine.cloudConfig.${type}.title`) }}</h3>
<p class="text-muted mb-20">
<t :k="`harvester.virtualMachine.cloudConfig.${type}.tip`" :raw="true" />
</p>
<LabeledSelect
v-if="!isView"
v-model="id"
class="mb-20"
:options="options"
:disabled="viewCode"
:label-key="`harvester.virtualMachine.cloudConfig.${type}.label`"
/>
<div class="resource-yaml">
<YamlEditor
ref="yaml"
v-model="yamlScript"
class="yaml-editor"
:editor-mode="editorMode"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
$yaml-height: 200px;
::v-deep .resource-yaml {
flex: 1;
display: flex;
flex-direction: column;
& .yaml-editor{
flex: 1;
min-height: $yaml-height;
& .code-mirror .CodeMirror {
min-height: $yaml-height;
}
}
}
</style>
@@ -0,0 +1,287 @@
<script>
import { mapGetters } from 'vuex';
import { LabeledInput } from '@components/Form/LabeledInput';
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
import ModalWithCard from '@shell/components/ModalWithCard';
import { CONFIG_MAP } from '@shell/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { _VIEW } from '@shell/config/query-params';
import DataTemplate from './DataTemplate';
const _NEW = '_NEW';
export default {
components: {
DataTemplate, YamlEditor, LabeledInput, ModalWithCard
},
props: {
mode: {
type: String,
default: 'create'
},
namespace: {
type: String,
default: ''
},
userScript: {
type: String,
default: ''
},
networkScript: {
type: String,
default: ''
},
viewCode: {
type: Boolean,
default: false
}
},
data() {
return {
errors: [],
templateType: '',
cloudTemplate: '',
cloudTemplateName: '',
configUserId: '',
configNetworkId: '',
optionUser: [],
optionNetwork: [],
isOpen: false
};
},
async fetch() {
const configs = await this.$store.dispatch('harvester/findAll', { type: CONFIG_MAP });
const optionUser = [];
const optionNetwork = [];
for (const config of configs) {
if (config.metadata?.labels?.[HCI_ANNOTATIONS.CLOUD_INIT] === 'user') {
optionUser.push({
label: config?.id,
value: config?.id
});
}
if (config.metadata?.labels?.[HCI_ANNOTATIONS.CLOUD_INIT] === 'network') {
optionNetwork.push({
label: config?.id,
value: config?.id
});
}
}
optionUser.unshift({
label: this.t('harvester.virtualMachine.cloudConfig.createNew'),
value: _NEW,
});
optionNetwork.unshift({
label: this.t('harvester.virtualMachine.cloudConfig.createNew'),
value: _NEW,
});
this.optionUser = optionUser;
this.optionNetwork = optionNetwork;
},
computed: {
...mapGetters({ t: 'i18n/t' }),
createTypeLable() {
const label = `harvester.virtualMachine.cloudConfig.${ this.templateType }.label`;
return this.t(label);
},
editorMode() {
return this.isView ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
},
isView() {
return this.mode === _VIEW;
},
},
methods: {
updateValue() {
this.$refs['userTemplate'].updateValue();
this.$refs['networkTemplate'].updateValue();
},
update(value, type) {
if (type === 'user') {
this.$emit('updateUserData', value);
}
if (type === 'network') {
this.$emit('updateNetworkData', value);
}
},
updateTemplateId(type, id) {
this.$emit('updateDataTemplateId', type, id);
},
show(templateType) {
this.templateType = templateType;
this.isOpen = true;
},
async save(buttonCb) {
this.errors = [];
if (!this.cloudTemplateName) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.name') }, true));
buttonCb(false);
return;
}
if (!this.cloudTemplate) {
const keyLabel = this.templateType === 'user' ? 'harvester.cloudTemplate.userData' : 'harvester.cloudTemplate.networkData';
this.errors.push(this.t('validation.required', { key: this.t(keyLabel) }, true));
buttonCb(false);
return;
}
const templateValue = await this.$store.dispatch('harvester/create', {
type: CONFIG_MAP,
metadata: {
labels: { [HCI_ANNOTATIONS.CLOUD_INIT]: this.templateType },
name: this.cloudTemplateName,
namespace: this.namespace
},
data: { cloudInit: this.cloudTemplate },
});
try {
const res = await templateValue.save();
if (res.id) {
this.templateType === 'user' ? this.configUserId = res.id : this.configNetworkId = res.id;
this.$fetch();
}
buttonCb(true);
this.cancel();
} catch (err) {
this.errors = [err.message];
buttonCb(false);
}
},
cancel() {
this.cloudTemplate = '';
this.cloudTemplateName = '';
this.$set(this, 'errors', []);
this.isOpen = false;
},
refresh() {
this.$refs['userTemplate'].refresh();
this.$refs['networkTemplate'].refresh();
},
}
};
</script>
<template>
<div>
<h2 v-if="!isView">
{{ t('harvester.virtualMachine.cloudConfig.title') }}
</h2>
<div class="mb-20">
<DataTemplate
ref="userTemplate"
v-model="userScript"
type="user"
:view-code="viewCode"
:mode="mode"
:config-id="configUserId"
:options="optionUser"
@show="show"
@update="update"
@updateTemplateId="updateTemplateId"
/>
</div>
<div class="mb-20">
<DataTemplate
ref="networkTemplate"
v-model="networkScript"
type="network"
:view-code="viewCode"
:mode="mode"
:config-id="configNetworkId"
:options="optionNetwork"
@show="show"
@update="update"
/>
</div>
<ModalWithCard
v-if="isOpen"
name="createCloudTemplate"
width="40%"
:errors="errors"
@finish="save"
@close="cancel"
>
<template #title>
{{ t('harvester.virtualMachine.cloudConfig.createTemplateTitle', { name: createTypeLable }) }}
</template>
<template #content>
<LabeledInput
v-model="cloudTemplateName"
:label="t('harvester.virtualMachine.input.name')"
class="mb-20"
required
@keydown.native.enter.prevent="()=>{}"
/>
<div class="yaml">
<div class="resource-yaml">
<YamlEditor
ref="createTemplate"
v-model="cloudTemplate"
class="yaml-editor"
:editor-mode="editorMode"
/>
</div>
</div>
</template>
</ModalWithCard>
</div>
</template>
<style lang="scss" scoped>
$yaml-height: 350px;
.yaml {
height: $yaml-height;
overflow: auto;
}
::v-deep .resource-yaml {
flex: 1;
display: flex;
flex-direction: column;
& .yaml-editor{
flex: 1;
min-height: $yaml-height;
& .code-mirror .CodeMirror {
min-height: $yaml-height;
}
}
}
</style>
@@ -0,0 +1,113 @@
<script>
import UnitInput from '@shell/components/form/UnitInput';
import InputOrDisplay from '@shell/components/InputOrDisplay';
export default {
name: 'HarvesterEditCpuMemory',
components: { UnitInput, InputOrDisplay },
props: {
cpu: {
type: Number,
default: null
},
memory: {
type: String,
default: null
},
mode: {
type: String,
default: 'create',
},
disabled: {
type: Boolean,
default: false
}
},
data() {
return {
localCpu: this.cpu,
localMemory: this.memory
};
},
computed: {
cupDisplay() {
return `${ this.localCpu } C`;
},
memoryDisplay() {
return `${ this.localMemory }`;
}
},
watch: {
cpu(neu) {
this.localCpu = neu;
},
memory(neu) {
if (neu && !neu.includes('null')) {
this.localMemory = neu;
}
}
},
methods: {
change() {
let memory = '';
if (String(this.localMemory).includes('Gi')) {
memory = this.localMemory;
} else {
memory = `${ this.localMemory }Gi`;
}
if (memory.includes('null')) {
memory = null;
}
this.$emit('updateCpuMemory', this.localCpu, memory);
},
}
};
</script>
<template>
<div class="row">
<div class="col span-6">
<InputOrDisplay name="CPU" :value="cupDisplay" :mode="mode" class="mb-10">
<UnitInput
v-model="localCpu"
v-int-number
label="CPU"
suffix="C"
:delay="0"
required
:disabled="disabled"
:mode="mode"
class="mb-20"
@input="change"
/>
</InputOrDisplay>
</div>
<div class="col span-6">
<InputOrDisplay :name="t('harvester.virtualMachine.input.memory')" :value="memoryDisplay" :mode="mode" class="mb-10">
<UnitInput
v-model="localMemory"
v-int-number
:label="t('harvester.virtualMachine.input.memory')"
:mode="mode"
:input-exponent="3"
:delay="0"
:increment="1024"
:output-modifier="true"
:disabled="disabled"
required
class="mb-20"
@input="change"
/>
</InputOrDisplay>
</div>
</div>
</template>
@@ -0,0 +1,41 @@
import { mount } from '@vue/test-utils';
import HarvesterEditNetwork from '../base.vue';
import { _EDIT } from '@shell/config/query-params';
describe('component: HarvesterEditNetwork', () => {
// eslint-disable-next-line jest/no-commented-out-tests
// it('should display all the inputs', () => {
// const wrapper = mount(HarvesterEditNetwork, { propsData: { mode: _EDIT } });
// const inputWraps = wrapper.findAll('[data-testid^=input-hen-]');
// expect(inputWraps).toHaveLength(5);
// });
it.each([
'name',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditNetwork, { propsData: { mode: _EDIT } });
const input = wrapper.find(`[data-testid="input-hen-${ field }"]`).find('input');
const newValue = 123;
input.setValue(newValue);
expect(wrapper.emitted('update')).toHaveLength(1);
});
it.each([
'model',
'networkName',
'type',
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditNetwork, { propsData: { mode: _EDIT } });
const select = wrapper.find(`[data-testid="input-hen-${ field }"]`);
select.find('button').trigger('click');
await wrapper.trigger('keydown.down');
await wrapper.trigger('keydown.enter');
expect(wrapper.emitted('update')).toHaveLength(1);
});
});
@@ -0,0 +1,306 @@
<script>
import InputOrDisplay from '@shell/components/InputOrDisplay';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { clone } from '@shell/utils/object';
import { _CREATE, _VIEW } from '@shell/config/query-params';
import { MANAGEMENT_NETWORK } from '../../../mixins/harvester-vm';
const MODEL = [{
label: 'virtio',
value: 'virtio'
}, {
label: 'e1000',
value: 'e1000'
}, {
label: 'e1000e',
value: 'e1000e'
}, {
label: 'ne2k_pci',
value: 'ne2k_pci'
}, {
label: 'pcnet',
value: 'pcnet'
}, {
label: 'rtl8139',
value: 'rtl8139'
}];
export default {
name: 'HarvesterEditNetwork',
components: {
LabeledInput, LabeledSelect, InputOrDisplay
},
props: {
value: {
type: Object,
default: () => {
return {};
}
},
networkOption: {
type: Array,
default: () => {
return [];
}
},
rows: {
type: Array,
default: () => {
return [];
}
},
mode: {
type: String,
default: 'create'
},
isSingle: {
type: Boolean,
default: true
}
},
data() {
const isMasquerade = this.value.isPod;
if (isMasquerade) {
this.value.networkName = MANAGEMENT_NETWORK;
}
return {
isMasquerade,
hasManagementNetwork: false,
showAdvanced: false,
};
},
computed: {
isDisabled() {
return this.isMasquerade && !this.value.newCreateId && !this.isCreate;
},
isView() {
return this.mode === _VIEW;
},
isCreate() {
return this.mode === _CREATE;
},
modelOption() {
return MODEL;
},
allNetworkOption() {
const vlanNetwork = clone(this.networkOption);
const managementNetwork = [{
label: MANAGEMENT_NETWORK,
value: MANAGEMENT_NETWORK
}];
if (this.value.newCreateId && this.hasManagementNetwork) {
return vlanNetwork;
} else if (this.value.newCreateId && !this.hasManagementNetwork) {
return [...managementNetwork, ...vlanNetwork];
} else if (this.isMasquerade) {
return managementNetwork;
} else {
return vlanNetwork;
}
},
typeOption() {
const masquerade = [{
label: 'masquerade',
value: 'masquerade'
}, {
label: 'bridge',
value: 'bridge'
}];
const other = [{
label: 'bridge',
value: 'bridge'
}];
return this.isMasquerade ? masquerade : other;
}
},
watch: {
rows: {
handler(neu) {
const hasManagementNetwork = !!neu.some(N => N.isPod);
this.$set(this, 'hasManagementNetwork', hasManagementNetwork);
},
immediate: true,
deep: true
},
isSingle(neu) {
if (!neu) {
this.$set(this.value, 'macAddress', '');
this.update();
}
}
},
methods: {
/**
* Patch k8s value based on type of network
*/
updateNetworkName(neu) {
if (neu === MANAGEMENT_NETWORK) {
this.value.isPod = true;
this.value.macAddress = '';
} else {
this.value.isPod = false;
}
this.$set(this, 'isMasquerade', this.value.isPod);
if (this.value.isPod) {
this.value.type = 'masquerade';
} else {
this.value.type = 'bridge';
}
this.update();
},
update() {
this.$emit('update');
},
toggleAdvanced() {
this.showAdvanced = !this.showAdvanced;
},
}
};
</script>
<template>
<div>
<div class="row mb-20">
<div
data-testid="input-hen-name"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.name')"
:value="value.name"
:mode="mode"
>
<LabeledInput
v-model="value.name"
:label="t('harvester.fields.name')"
required
:mode="mode"
:disabled="isDisabled"
@input="update"
/>
</InputOrDisplay>
</div>
<div
data-testid="input-hen-model"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.model')"
:value="value.model"
:mode="mode"
>
<LabeledSelect
v-model="value.model"
:label="t('harvester.fields.model')"
:disabled="isDisabled"
:options="modelOption"
:mode="mode"
required
@input="update"
/>
</InputOrDisplay>
</div>
</div>
<div class="row" :class="{'mb-20': !isMasquerade}">
<div
data-testid="input-hen-networkName"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.network')"
:value="value.networkName"
:mode="mode"
>
<LabeledSelect
v-model="value.networkName"
:label="t('harvester.fields.network')"
:options="allNetworkOption"
:mode="mode"
required
:disabled="isDisabled"
@input="updateNetworkName"
/>
</InputOrDisplay>
</div>
<div
data-testid="input-hen-type"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.type')"
:value="value.type"
:mode="mode"
>
<LabeledSelect
v-model="value.type"
:label="t('harvester.fields.type')"
:options="typeOption"
:mode="mode"
required
@input="update"
/>
</InputOrDisplay>
</div>
</div>
<div v-if="!isMasquerade && isSingle">
<div class="row mb-20">
<a role="button" @click="toggleAdvanced">
{{ showAdvanced ? t('harvester.generic.hideMore') : t('harvester.generic.showMore') }}
</a>
</div>
<div class="row">
<div
v-if="showAdvanced"
data-testid="input-hen-macAddress"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.macAddress')"
:value="value.macAddress"
:mode="mode"
>
<LabeledInput
v-model="value.macAddress"
label-key="harvester.fields.macAddress"
:mode="mode"
:tooltip="t('harvester.virtualMachine.volume.macTip')"
@input="update"
/>
</InputOrDisplay>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,165 @@
<script>
import InfoBox from '@shell/components/InfoBox';
import Base from './base';
import { NETWORK_ATTACHMENT } from '@shell/config/types';
import { HCI as HCI_ANNOTATIONS } from '../../../config/labels-annotations';
import { sortBy } from '@shell/utils/sort';
import { clone } from '@shell/utils/object';
import { randomStr } from '@shell/utils/string';
import { removeObject } from '@shell/utils/array';
import { _VIEW } from '@shell/config/query-params';
export default {
components: { InfoBox, Base },
props: {
mode: {
type: String,
default: 'create'
},
value: {
type: Array,
default: () => {
return [];
}
},
isSingle: {
type: Boolean,
default: true
}
},
data() {
return {
rows: this.addKeyId(clone(this.value)),
nameIdx: 1
};
},
computed: {
isView() {
return this.mode === _VIEW;
},
networkOption() {
const choices = this.$store.getters['harvester/all'](NETWORK_ATTACHMENT).filter((row) => {
return !row.metadata?.annotations?.[HCI_ANNOTATIONS.STORAGE_NETWORK];
});
const out = sortBy(
choices.map((N) => {
const label = N.isNotReady ? `${ N.id } (${ this.t('generic.notReady') })` : N.id;
return {
label,
value: N.id,
disabled: N.isNotReady,
};
}),
'label'
);
return out;
},
},
watch: {
value(neu) {
this.rows = neu;
},
},
methods: {
add(type) {
const name = this.generateName();
const neu = {
name,
networkName: '',
model: 'virtio',
type: 'bridge',
newCreateId: randomStr(10),
rowKeyId: randomStr(10)
};
this.rows.push(neu);
this.update();
},
remove(vol) {
removeObject(this.rows, vol);
this.update();
},
addKeyId(row) {
return row.map((R) => {
return {
...R,
rowKeyId: randomStr(10)
};
});
},
generateName() {
let name = '';
let hasUsed = true;
while (hasUsed) {
name = `nic-${ this.nameIdx }`;
hasUsed = this.rows.find( O => O.name === name);
this.nameIdx++;
}
return name;
},
update() {
this.$emit('input', this.rows);
}
}
};
</script>
<template>
<div>
<InfoBox v-for="(row, i) in rows" :key="i" class="infoBox">
<button v-if="!isView" type="button" class="role-link remove-vol" @click="remove(row)">
<i class="icon icon-x" />
</button>
<h3> {{ t('harvester.virtualMachine.network.title') }} </h3>
<Base
:key="rows[i].rowKeyId"
v-model="rows[i]"
:rows="rows"
:mode="mode"
:is-single="isSingle"
:network-option="networkOption"
@update="update"
/>
</InfoBox>
<button v-if="!isView" type="button" class="btn btn-sm bg-primary" @click="add">
{{ t('harvester.virtualMachine.network.addNetwork') }}
</button>
</div>
</template>
<style lang='scss' scoped>
.infoBox{
position: relative;
}
.remove-vol {
position: absolute;
top: 10px;
right: 16px;
padding:0px;
max-height: 28px;
min-height: 28px;
}
</style>
@@ -0,0 +1,157 @@
<script>
import { mapGetters } from 'vuex';
export default {
props: {
/**
* deviceId/vendorId is unique per type of device - there may be multiple pciDevice CRD objects for a given device
* {
* [deviceId/vendorId]: {
* nodes: array of devicecrd.status.nodeName's for given device,
* deviceCRDs: array of all instances (pciDevice CRD) of given device
* }
* }
*/
enabledDevices: {
type: Array,
required: true
},
/**
* {
* [node name]: [devices]
* }
*/
devicesByNode: {
type: Object,
required: true
},
/**
* {
* [deviceCRD.status.resourceName]: {
* count: number of this device in use by other vms
* usedBy: [names of all vms using this device]
* }
* }
*/
devicesInUse: {
type: Object,
default: () => {}
}
},
computed: {
...mapGetters({ t: 'i18n/t' }),
allNodeNames() {
return Object.keys(this.devicesByNode);
},
allDeviceIds() {
return Object.keys(this.uniqueDevices);
}
},
methods: {
nodeNameFromId(id) {
return this.devicesByNode[id]?.name;
},
nodeHasDevice(nodeName, deviceCRD) {
return deviceCRD.status.nodeName === nodeName;
},
noneAvailable(deviceCRD) {
const name = deviceCRD.metadata?.name;
return !!this.devicesInUse[name];
},
deviceTooltip(deviceCRD) {
return `${ deviceCRD?.status?.resourceName }<br/>${ deviceCRD?.status?.description }`;
}
}
};
</script>
<template>
<div class="compat-matrix">
<div class="device-col node-names">
<div class="blank-corner">
<div class="text-right">
{{ t('harvester.pci.matrixDeviceClaimName') }}
</div>
<div>{{ t('harvester.pci.matrixHostName') }}</div>
</div>
<div v-for="nodeName in allNodeNames" :key="nodeName" class="node-label">
<span> {{ nodeName }}</span>
</div>
</div>
<div v-for="deviceCRD in enabledDevices" :key="deviceCRD.metadata.name" class="device-col">
<div v-clean-tooltip="deviceTooltip(deviceCRD)" class="compat-cell device-label" :class="{'text-muted': noneAvailable(deviceCRD)}">
{{ deviceCRD.metadata.name }}
</div>
<div
v-for="nodeName in allNodeNames"
:key="nodeName"
class="compat-cell"
:class="{'has-device': nodeHasDevice(nodeName, deviceCRD)}"
/>
</div>
</div>
</template>
<style lang='scss'>
.compat-matrix {
display: flex;
}
.device-col {
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
&>*{
border-bottom: 1px solid var(--border);
}
}
.compat-cell {
flex-basis: 1em;
padding: 0px 10px 0px 10px;
&.has-device {
background-color: var(--info-banner-bg);
}
}
.node-label, .device-label {
display: flex;
align-items: center;
color: var(--input-label);
}
.node-label{
padding: 0 10px;
justify-content: center;
}
.node-label, .device-label, .compat-cell, .blank-corner {
flex-basis: calc(2em + 10px);
}
.blank-corner {
background: linear-gradient(
to top right,
#fff 0%,
#fff calc(50% - 1px),
var(--body-text) 50%,
#fff calc(50% + 1px),
#fff 100%
);
DIV.text-right {
padding-left: 80px;
}
}
</style>
@@ -0,0 +1,189 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import FilterBySriov from '../../../components/FilterBySriov';
import { HCI } from '../../../types';
import { STATE, SIMPLE_NAME } from '@shell/config/table-headers';
import { defaultTableSortGenerationFn } from '@shell/components/ResourceTable.vue';
import { allHash } from '@shell/utils/promise';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'ListPciDevices',
components: { ResourceTable, FilterBySriov },
props: {
schema: {
type: Object,
required: true,
},
devices: {
type: Array,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const _hash = {
pciclaims: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.PCI_CLAIM }),
sriovs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SR_IOV }),
};
await allHash(_hash);
},
data() {
const isSingleProduct = this.$store.getters['isSingleProduct'];
const headers = [
{ ...STATE },
SIMPLE_NAME,
{
name: 'description',
labelKey: 'tableHeaders.description',
value: 'status.description',
sort: ['status.description']
},
{
name: 'node',
labelKey: 'tableHeaders.node',
value: 'status.nodeName',
sort: ['status.nodeName']
},
{
name: 'address',
label: 'Address',
value: 'status.address',
sort: ['status.address']
},
{
name: 'vendorid',
label: 'Vendor ID',
value: 'status.vendorId',
sort: ['status.vendorId', 'status.deviceId']
},
{
name: 'deviceid',
label: 'Device ID',
value: 'status.deviceId',
sort: ['status.deviceId', 'status.vendorId']
},
];
if (!isSingleProduct) {
headers.push( {
name: 'claimed',
label: 'Claimed By',
value: 'passthroughClaim.userName',
sort: ['passthroughClaim.userName'],
});
}
return {
headers,
rows: [],
parentSriov: null,
filterRows: []
};
},
watch: {
devices: {
handler(v) {
this.rows = v;
this.filterRows = this.rows;
},
immediate: true,
},
},
computed: {
parentSriovOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const allSriovs = this.$store.getters[`${ inStore }/all`](HCI.SR_IOV) || [];
return allSriovs.map((sriov) => {
return sriov.id;
});
},
parentSriovLabel() {
return HCI_ANNOTATIONS.PARENT_SRIOV;
}
},
methods: {
enableGroup(rows = []) {
const row = rows[0];
if (row) {
row.enablePassthroughBulk(rows);
}
},
disableGroup(rows = []) {
rows.forEach((row) => {
if (row.passthroughClaim) {
row.disablePassthrough();
}
});
},
groupIsAllEnabled(rows = []) {
return !rows.find(device => !device.passthroughClaim);
},
changeRows(filterRows, parentSriov) {
this.$set(this, 'filterRows', filterRows);
this.$set(this, 'parentSriov', parentSriov);
},
sortGenerationFn() {
let base = defaultTableSortGenerationFn(this.schema, this.$store);
if (this.parentSriov) {
base += this.parentSriov;
}
return base;
},
}
};
</script>
<template>
<ResourceTable
:headers="headers"
:schema="schema"
:rows="filterRows"
:use-query-params-for-simple-filtering="true"
:sort-generation-fn="sortGenerationFn"
:rows-per-page="10"
>
<template #group-by="{group}">
<div :ref="group.key" v-trim-whitespace class="group-tab">
<button v-if="groupIsAllEnabled(group.rows)" type="button" class="btn btn-sm role-secondary mr-5" @click="e=>{disableGroup(group.rows); e.target.blur()}">
{{ t('harvester.pci.disableGroup') }}
</button>
<button v-else type="button" class="btn btn-sm role-secondary mr-5" @click="e=>{enableGroup(group.rows); e.target.blur()}">
{{ t('harvester.pci.enableGroup') }}
</button>
<span v-clean-html="group.key" />
</div>
</template>
<template #cell:claimed="{row}">
<span v-if="row.isEnabled">{{ row.claimedBy }}</span>
<span v-else class="text-muted">&mdash;</span>
</template>
<template #more-header-middle>
<FilterBySriov
ref="filterByParentSRIOV"
:parent-sriov-options="parentSriovOptions"
:parent-sriov-label="parentSriovLabel"
:label="t('harvester.sriov.parentSriov')"
:rows="rows"
@change-rows="changeRows"
/>
</template>
</ResourceTable>
</template>
@@ -0,0 +1,269 @@
<script>
import { _EDIT } from '@shell/config/query-params';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../../../types';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import Banner from '@components/Banner/Banner.vue';
import CompatibilityMatrix from './CompatibilityMatrix';
import DeviceList from './DeviceList';
import remove from 'lodash/remove';
import { get, set } from '@shell/utils/object';
export default {
name: 'VirtualMachinePCIDevices',
components: {
LabeledSelect,
DeviceList,
CompatibilityMatrix,
Banner
},
props: {
mode: {
type: String,
default: _EDIT
},
// spec.template.spec
value: {
type: Object,
default: () => {}
},
vm: {
type: Object,
default: () => {}
}
},
async fetch() {
const hash = {
// claims fetched here so synchronous pciDevice model property works
pciDevices: this.$store.dispatch('harvester/findAll', { type: HCI.PCI_DEVICE }),
claims: this.$store.dispatch('harvester/findAll', { type: HCI.PCI_CLAIM }),
vms: this.$store.dispatch(`harvester/findAll`, { type: HCI.VM })
};
const res = await allHash(hash);
for (const key in res) {
this[key] = res[key];
}
const selectedDevices = [];
const oldFormatDevices = [];
(this.value?.domain?.devices?.hostDevices || []).forEach(({ name, deviceName }) => {
const checkName = (deviceName || '').split('/')?.[1];
if (checkName && name.includes(checkName)) {
oldFormatDevices.push(name);
} else if (this.enabledDevices.find(device => device?.metadata?.name === name)) {
selectedDevices.push(name);
}
});
if (oldFormatDevices.length > 0) {
this.oldFormatDevices = oldFormatDevices;
} else {
this.selectedDevices = selectedDevices;
}
},
data() {
return {
pciDevices: [],
claims: [],
vms: [],
selectedDevices: [],
pciDeviceSchema: this.$store.getters['harvester/schemaFor'](HCI.PCI_DEVICE),
showMatrix: false,
oldFormatDevices: [],
};
},
watch: {
selectedDevices(neu) {
const formatted = neu.map((selectedDevice) => {
const deviceCRD = this.enabledDevices.find(device => device.metadata.name === selectedDevice);
const deviceName = deviceCRD?.status?.resourceName;
return {
deviceName,
name: deviceCRD?.metadata.name,
};
});
set(this.value.domain.devices, 'hostDevices', formatted);
}
},
computed: {
// user can only select devices for whcih pci passthrough is enabled/claimed by them - determined by finding the associated passthrough CRD
enabledDevices() {
return this.pciDevices.filter((device) => {
return device.isEnabled && device.claimedByMe;
}) || [];
},
// find devices in use by other VMs and sum the number of each device already in use
devicesInUse() {
const inUse = this.vms.reduce((inUse, vm) => {
// dont count devices in this vm as 'disabled' if they're just being used in the vm currently being edited
if (vm.metadata.name === this.vm?.metadata?.name) {
return inUse;
}
const devices = get(vm, 'spec.template.spec.domain.devices.hostDevices') || [];
devices.forEach((device) => {
inUse[device.name] = { usedBy: [vm.metadata.name] };
});
return inUse;
}, {});
return inUse;
},
devicesByNode() {
const out = {};
this.enabledDevices.forEach((deviceCRD) => {
const nodeName = deviceCRD.status?.nodeName;
if (!out[nodeName]) {
out[nodeName] = [deviceCRD];
} else {
out[nodeName].push(deviceCRD);
}
});
return out;
},
// determine which nodes contain all devices selected
compatibleNodes() {
const out = [...Object.keys(this.devicesByNode)];
this.selectedDevices.forEach((deviceUid) => {
remove(out, (nodeName) => {
const device = this.enabledDevices.find(deviceCRD => deviceCRD.metadata.name === deviceUid);
return device.status.nodeName !== nodeName;
});
});
return out;
},
// format an array of available devices for the dropdown
deviceOpts() {
const filteredOptions = this.enabledDevices.filter((deviceCRD) => {
if (this.selectedDevices.length > 0) {
const selectedDevice = this.enabledDevices.find(device => device.metadata.name === this.selectedDevices[0]);
return !this.devicesInUse[deviceCRD?.metadata.name] && deviceCRD.status.nodeName === selectedDevice.status.nodeName;
}
return !this.devicesInUse[deviceCRD?.metadata.name];
});
return filteredOptions.map((deviceCRD) => {
return {
value: deviceCRD?.metadata.name,
label: deviceCRD?.metadata.name,
displayLabel: deviceCRD?.status?.resourceName,
};
});
},
oldFormatDevicesHTML() {
return this.oldFormatDevices.map((device) => {
return `<li>${ device }</li>`;
}).join('');
},
},
methods: {
nodeNameFromUid(uid) {
for (const deviceUid in this.uniqueDevices) {
const nodes = this.uniqueDevices[deviceUid].nodes;
const thisNode = nodes.find(node => node.systemUUID === uid);
if (thisNode) {
return thisNode.name;
}
}
},
}
};
</script>
<template>
<div>
<div
v-if="oldFormatDevices.length > 0"
class="row"
>
<div class="col span-12">
<Banner color="warning">
<p v-clean-html="t('harvester.pci.oldFormatDevices.help', {oldFormatDevicesHTML}, true)" />
</Banner>
</div>
</div>
<div v-else>
<div class="row">
<div class="col span-12">
<Banner color="info">
<t k="harvester.pci.howToUseDevice" />
</Banner>
<Banner v-if="selectedDevices.length > 0" color="info">
<t k="harvester.pci.deviceInTheSameHost" />
</Banner>
</div>
</div>
<template v-if="enabledDevices.length">
<div class="row">
<div class="col span-6">
<LabeledSelect
v-model="selectedDevices"
label="Available PCI Devices"
searchable
multiple
taggable
:options="deviceOpts"
:mode="mode"
>
<template #option="option">
<span>{{ option.value }} <span class="text-label">({{ option.displayLabel }})</span></span>
</template>
</LabeledSelect>
</div>
</div>
<div v-if="compatibleNodes.length && selectedDevices.length" class="row">
<div class="col span-12 text-muted">
Compatible hosts:
<!-- eslint-disable-next-line vue/no-parsing-error -->
<span v-for="(node, idx) in compatibleNodes" :key="node">{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
</div>
</div>
<div v-else-if="selectedDevices.length" class="text-error">
{{ t('harvester.pci.impossibleSelection') }}
</div>
<button type="button" class="btn btn-sm role-link pl-0" @click="e=>{showMatrix = !showMatrix; e.target.blur()}">
{{ showMatrix ? t('harvester.pci.hideCompatibility') : t('harvester.pci.showCompatibility') }}
</button>
<div v-if="showMatrix" class="row mt-20">
<div class="col span-12">
<CompatibilityMatrix :enabled-devices="enabledDevices" :devices-by-node="devicesByNode" :devices-in-use="devicesInUse" />
</div>
</div>
</template>
<div class="row mt-20">
<div class="col span-12">
<DeviceList :schema="pciDeviceSchema" :devices="pciDevices" @submit.prevent />
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,50 @@
<script>
import UnitInput from '@shell/components/form/UnitInput';
export default {
name: 'HarvesterReserved',
components: { UnitInput },
props: {
reservedMemory: {
type: String,
default: null
},
mode: {
type: String,
default: 'create',
},
},
data() {
return { memory: this.reservedMemory };
},
watch: {
reservedMemory(memory) {
this.memory = memory;
},
},
methods: {
change() {
const { memory } = this;
this.$emit('updateReserved', { memory });
},
}
};
</script>
<template>
<UnitInput
v-model="memory"
v-int-number
:label="t('harvester.virtualMachine.input.reservedMemory')"
:mode="mode"
:input-exponent="2"
:increment="1024"
:output-modifier="true"
@input="change"
/>
</template>
@@ -0,0 +1,285 @@
<script>
import { mapGetters } from 'vuex';
import { randomStr } from '@shell/utils/string';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ModalWithCard from '@shell/components/ModalWithCard';
import { HCI } from '../../types';
import { clone } from '@shell/utils/object';
import { _VIEW } from '@shell/config/query-params';
import { NAMESPACE } from '@shell/config/types';
const _NEW = '_NEW';
export default {
components: {
LabeledInput,
ModalWithCard,
LabeledSelect,
},
props: {
value: {
type: Array,
default: () => {
return [];
}
},
mode: {
type: String,
default: 'create',
},
disableCreate: {
type: Boolean,
default: false
},
namespace: {
type: String,
default: ''
},
createNamespace: {
type: Boolean,
default: false,
},
searchable: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false
}
},
data() {
return {
checkedSsh: this.value,
publicKey: '',
sshName: '',
randomStr: randomStr(5).toLowerCase(),
errors: [],
isAll: false,
checkAll: false,
isOpen: false,
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
schema() {
return this.$store.getters['harvester/schemaFor']( HCI.SSH );
},
isCreatable() {
if ( this.schema && !this.schema?.collectionMethods.find((x) => ['blocked-post', 'post'].includes(x.toLowerCase())) ) {
return false;
}
return true ;
},
sshOption() {
const out = this.$store.getters['harvester/all'](HCI.SSH).map( (O) => {
return {
label: O.id,
value: O.id
};
});
if (!(this.disableCreate || this.mode === _VIEW) && this.isCreatable) {
out.unshift({
label: this.t('harvester.virtualMachine.createSSHKey'),
value: _NEW,
});
}
return out;
},
},
watch: {
publicKey(neu) {
const trimNeu = neu.trim();
const splitSSH = trimNeu.split(/\s+/);
if (splitSSH.length === 3 && !this.sshName) {
const keyComment = splitSSH[2];
this.randomStr = randomStr(10).toLowerCase();
this.sshName = keyComment.includes('@') ? keyComment.split('@')[0] : keyComment;
}
},
value(neu) {
this.checkedSsh = neu;
},
checkedSsh(val, old) {
if ( val.includes(_NEW)) {
this.$set(this, 'checkedSsh', old);
this.update();
this.show();
}
}
},
methods: {
show() {
this.isOpen = true;
},
hide() {
this.isOpen = false;
},
async createNamespaceIfNeeded() {
if (!this.createNamespace || this.disableCreate) {
return;
}
const namespaces = await this.$store.dispatch('harvester/findAll', { type: NAMESPACE });
const exists = namespaces?.find((n) => n.name === this.namespace);
if (!exists) {
const ns = await this.$store.dispatch('harvester/createNamespace', { name: this.namespace }, { root: true });
ns.applyDefaults();
await ns.save();
}
},
async createSSHKey() {
const sshValue = await this.$store.dispatch('harvester/create', {
metadata: {
name: this.sshName,
namespace: this.namespace
},
spec: { publicKey: this.publicKey },
type: HCI.SSH
});
const res = await sshValue.save();
if (res.id) {
this.checkedSsh.push(`${ this.namespace }/${ this.sshName }`);
}
},
async save(buttonCb) {
this.errors = [];
if (!this.sshName) {
const fieldName = this.t('harvester.virtualMachine.input.name');
const message = this.t('validation.required', { key: fieldName });
this.errors.push(message);
}
if (!this.publicKey) {
const fieldName = this.t('harvester.virtualMachine.input.sshKeyValue');
const message = this.t('validation.required', { key: fieldName });
this.errors.push(message);
}
if (this.sshName.length > 63) {
const message = this.t('harvester.validation.custom.tooLongName', { max: 63 });
this.errors.push(message);
}
if (this.errors.length > 0) {
buttonCb(false);
return;
}
try {
await this.createNamespaceIfNeeded();
await this.createSSHKey();
buttonCb(true);
this.cancel();
} catch (err) {
this.errors = [err.message];
buttonCb(false);
}
},
cancel() {
this.hide();
this.resetFields();
},
resetFields() {
this.sshName = '';
this.publicKey = '';
this.errors = [];
},
update() {
this.$emit('update:sshKey', clone(this.checkedSsh));
},
}
};
</script>
<template>
<div>
<LabeledSelect
v-model="checkedSsh"
:label="t('harvester.virtualMachine.input.sshKey')"
:taggable="true"
:mode="mode"
:multiple="true"
:searchable="searchable"
:disabled="disabled"
:options="sshOption"
@input="update"
/>
<ModalWithCard
v-if="isOpen"
:name="randomStr"
width="40%"
:errors="errors"
@finish="save"
@close="cancel"
>
<template #title>
{{ t('harvester.virtualMachine.sshTitle') }}
</template>
<template #content>
<LabeledInput
v-model="sshName"
:label="t('harvester.virtualMachine.input.name')"
class="mb-20"
required
@keydown.native.enter.prevent="()=>{}"
/>
<LabeledInput
v-model="publicKey"
:label="t('harvester.virtualMachine.input.sshKeyValue')"
:min-height="160"
class="mb-20"
type="multiline"
required
/>
</template>
</ModalWithCard>
</div>
</template>
@@ -0,0 +1,129 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import FilterBySriov from '../../../components/FilterBySriov';
import { HCI } from '../../../types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { STATE, SIMPLE_NAME } from '@shell/config/table-headers';
import { defaultTableSortGenerationFn } from '@shell/components/ResourceTable.vue';
import { allHash } from '@shell/utils/promise';
export default {
name: 'VGpuDeviceList',
components: { ResourceTable, FilterBySriov },
props: {
schema: {
type: Object,
required: true,
},
devices: {
type: Array,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = { sriovgpus: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SR_IOVGPU_DEVICE }) };
await allHash(hash);
},
data() {
const headers = [
{ ...STATE },
SIMPLE_NAME,
{
name: 'vGPUTypeName',
label: 'vGPU Type',
value: 'spec.vGPUTypeName',
sort: ['spec.vGPUTypeName']
},
{
name: 'node',
labelKey: 'tableHeaders.node',
value: 'spec.nodeName',
sort: ['spec.nodeName']
},
{
name: 'address',
label: 'Address',
value: 'spec.address',
sort: ['spec.address']
},
];
return {
headers,
rows: [],
parentSriov: null,
filterRows: []
};
},
watch: {
devices: {
handler(v) {
this.rows = v;
this.filterRows = this.rows;
},
immediate: true,
}
},
computed: {
parentSriovOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const allSriovs = this.$store.getters[`${ inStore }/all`](HCI.SR_IOVGPU_DEVICE) || [];
return allSriovs.map((sriov) => {
return sriov.id;
});
},
parentSriovLabel() {
return HCI_ANNOTATIONS.PARENT_SRIOV_GPU;
}
},
methods: {
changeRows(filterRows, parentSriov) {
this.$set(this, 'filterRows', filterRows);
this.$set(this, 'parentSriov', parentSriov);
},
sortGenerationFn() {
let base = defaultTableSortGenerationFn(this.schema, this.$store);
if (this.parentSriov) {
base += this.parentSriov;
}
return base;
},
}
};
</script>
<template>
<ResourceTable
:headers="headers"
:schema="schema"
:rows="filterRows"
:use-query-params-for-simple-filtering="true"
:sort-generation-fn="sortGenerationFn"
:rows-per-page="10"
>
<template #more-header-middle>
<FilterBySriov
ref="filterByParentSRIOV"
:parent-sriov-options="parentSriovOptions"
:parent-sriov-label="parentSriovLabel"
:label="t('harvester.sriovgpu.parentSriov')"
:rows="rows"
@change-rows="changeRows"
/>
</template>
</ResourceTable>
</template>
@@ -0,0 +1,203 @@
<script>
import { _EDIT } from '@shell/config/query-params';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../../../types';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import Banner from '@components/Banner/Banner.vue';
import VGpuDeviceList from './VGpuDeviceList';
import remove from 'lodash/remove';
import { get, set } from '@shell/utils/object';
export default {
name: 'VirtualMachinedevices',
components: {
Banner,
LabeledSelect,
VGpuDeviceList
},
props: {
mode: {
type: String,
default: _EDIT
},
value: {
type: Object,
default: () => {}
},
vm: {
type: Object,
default: () => {}
}
},
async fetch() {
const hash = {
devices: this.$store.dispatch('harvester/findAll', { type: HCI.VGPU_DEVICE }),
vms: this.$store.dispatch(`harvester/findAll`, { type: HCI.VM })
};
const res = await allHash(hash);
for (const key in res) {
this[key] = res[key];
}
(this.value?.domain?.devices?.gpus || []).forEach(({ name }) => {
if (this.enabledDevices.find(device => device?.metadata?.name === name)) {
this.selectedDevices.push(name);
}
});
},
data() {
return {
deviceSchema: this.$store.getters['harvester/schemaFor'](HCI.VGPU_DEVICE),
devices: [],
vms: [],
selectedDevices: [],
};
},
watch: {
selectedDevices(neu) {
const formatted = neu.map((selectedDevice) => {
const deviceCRD = this.enabledDevices.find(device => device.metadata.name === selectedDevice);
const deviceName = `nvidia.com/${ deviceCRD?.status?.configureVGPUTypeName?.replace(/\s+/g, '_') }`;
return {
deviceName,
name: deviceCRD?.metadata.name,
};
});
set(this.value.domain.devices, 'gpus', formatted);
}
},
computed: {
enabledDevices() {
return this.devices.filter((device) => {
return device.isEnabled;
}) || [];
},
devicesInUse() {
const inUse = this.vms.reduce((inUse, vm) => {
if (vm.metadata.name === this.vm?.metadata?.name) {
return inUse;
}
const devices = get(vm, 'spec.template.spec.domain.devices.hostDevices') || [];
devices.forEach((device) => {
inUse[device.name] = { usedBy: [vm.metadata.name] };
});
return inUse;
}, {});
return inUse;
},
devicesByNode() {
const out = {};
this.enabledDevices.forEach((deviceCRD) => {
const nodeName = deviceCRD.spec?.nodeName;
if (!out[nodeName]) {
out[nodeName] = [deviceCRD];
} else {
out[nodeName].push(deviceCRD);
}
});
return out;
},
compatibleNodes() {
const out = [...Object.keys(this.devicesByNode)];
this.selectedDevices.forEach((deviceUid) => {
remove(out, (nodeName) => {
const device = this.enabledDevices.find(deviceCRD => deviceCRD.metadata.name === deviceUid);
return device.spec.nodeName !== nodeName;
});
});
return out;
},
deviceOpts() {
const filteredOptions = this.enabledDevices.filter((deviceCRD) => {
if (this.selectedDevices.length > 0) {
const selectedDevice = this.enabledDevices.find(device => device.metadata.name === this.selectedDevices[0]);
return !this.devicesInUse[deviceCRD?.metadata.name] && deviceCRD.spec.nodeName === selectedDevice.spec.nodeName;
}
return !this.devicesInUse[deviceCRD?.metadata.name];
});
return filteredOptions.map((deviceCRD) => {
return {
value: deviceCRD?.metadata.name,
label: deviceCRD?.metadata.name,
};
});
},
},
};
</script>
<template>
<div>
<div class="row">
<div class="col span-12">
<Banner color="info">
<t k="harvester.vgpu.howToUseDevice" />
</Banner>
<Banner v-if="selectedDevices.length > 0" color="info">
<t k="harvester.vgpu.deviceInTheSameHost" />
</Banner>
</div>
</div>
<template v-if="enabledDevices.length">
<div class="row">
<div class="col span-6">
<LabeledSelect
v-model="selectedDevices"
label="Available vGPU Devices"
searchable
multiple
taggable
:options="deviceOpts"
:mode="mode"
>
<template #option="option">
<span>{{ option.value }}</span>
</template>
</LabeledSelect>
</div>
</div>
<div v-if="compatibleNodes.length && selectedDevices.length" class="row">
<div class="col span-12 text-muted">
Compatible hosts:
<!-- eslint-disable-next-line vue/no-parsing-error -->
<span v-for="(node, idx) in compatibleNodes" :key="node">{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
</div>
</div>
<div v-else-if="selectedDevices.length" class="text-error">
{{ t('harvester.vgpu.impossibleSelection') }}
</div>
</template>
<div class="row mt-20">
<div class="col span-12">
<VGpuDeviceList :schema="deviceSchema" :devices="devices" @submit.prevent />
</div>
</div>
</div>
</template>
@@ -0,0 +1,498 @@
<script>
import draggable from 'vuedraggable';
import InfoBox from '@shell/components/InfoBox';
import { Banner } from '@components/Banner';
import BadgeStateFormatter from '@shell/components/formatter/BadgeStateFormatter';
import UnitInput from '@shell/components/form/UnitInput';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ModalWithCard from '@shell/components/ModalWithCard';
import { PVC, STORAGE_CLASS } from '@shell/config/types';
import { HCI } from '../../../types';
import { clone } from '@shell/utils/object';
import { removeObject } from '@shell/utils/array';
import { randomStr } from '@shell/utils/string';
import { SOURCE_TYPE } from '../../../config/harvester-map';
import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
export default {
components: {
Banner, BadgeStateFormatter, draggable, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard
},
props: {
vm: {
type: Object,
default: () => {
return {};
}
},
mode: {
type: String,
default: _CREATE
},
value: {
type: Array,
default: () => {
return [];
}
},
namespace: {
type: String,
default: null
},
existingVolumeDisabled: {
type: Boolean,
default: false
},
validateRequired: {
type: Boolean,
default: false
},
customVolumeMode: {
type: String,
default: 'Block'
},
customAccessMode: {
type: String,
default: 'ReadWriteMany'
},
resourceType: {
type: String,
default: ''
}
},
async fetch() {
await this.$store.dispatch('harvester/findAll', { type: PVC });
},
data() {
return {
SOURCE_TYPE,
rows: clone(this.value),
nameIdx: 1,
vol: null,
isOpen: false,
};
},
computed: {
dev() {
try {
return this.$store.getters['prefs/get'](PLUGIN_DEVELOPER);
} catch {
return this.$store.getters['prefs/get'](DEV);
}
},
isVirtualType() {
return this.resourceType === HCI.VM;
},
isView() {
return this.mode === _VIEW;
},
isEdit() {
return this.mode === _EDIT;
},
isCreate() {
return this.mode === _CREATE;
},
showVolumeTip() {
const imageName = this.getImageDisplayName(this.rows[0]?.image);
if (this.rows.length === 1 && this.rows[0].type === 'cd-rom' && /.iso$/i.test(imageName)) {
return true;
}
return false;
},
pvcs() {
return this.$store.getters['harvester/all'](PVC) || [];
},
},
watch: {
value: {
handler(neu) {
const rows = neu.map((V) => {
if (!this.isCreate && V.source !== SOURCE_TYPE.CONTAINER && !V.newCreateId) {
V.to = {
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-namespace-id`,
params: {
resource: HCI.VOLUME,
namespace: this.namespace,
id: V.realName
},
query: { mode: _EDIT }
};
V.pvc = this.pvcs.find((pvc) => pvc.metadata.name === V.realName);
}
return V;
});
this.$set(this, 'rows', rows);
},
deep: true,
immediate: true,
},
},
methods: {
addVolume(type) {
const name = this.generateName();
const neu = {
id: randomStr(5),
name,
source: type,
size: '10Gi',
type: 'disk',
accessMode: this.customAccessMode,
volumeMode: this.customVolumeMode,
volumeName: '',
bus: 'virtio',
newCreateId: randomStr(10), // judge whether it is a disk that has been created
};
if (type === SOURCE_TYPE.NEW) {
const inStore = this.$store.getters['currentProduct'].inStore;
const defaultStorage = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS).find( (O) => O.isDefault);
neu.storageClassName = defaultStorage?.metadata?.name || 'longhorn';
}
this.rows.push(neu);
this.update();
},
generateName() {
let name = '';
let hasName = true;
while (hasName) {
name = `disk-${ this.nameIdx }`;
hasName = this.rows.find((O) => O.name === name);
this.nameIdx++;
}
return name;
},
removeVolume(vol) {
this.vol = vol;
if (!vol.newCreateId && this.isEdit && this.isVirtualType) {
this.isOpen = true;
} else {
removeObject(this.rows, vol);
this.update();
}
},
unplugVolume(volume) {
this.vm.unplugVolume(volume.name);
},
componentFor(type) {
switch (type) {
case SOURCE_TYPE.NEW:
return require(`./type/volume.vue`).default;
case SOURCE_TYPE.IMAGE:
return require(`./type/vmImage.vue`).default;
case SOURCE_TYPE.ATTACH_VOLUME:
return require(`./type/existing.vue`).default;
case SOURCE_TYPE.CONTAINER:
return require(`./type/container.vue`).default;
}
},
headerFor(type) {
return {
[SOURCE_TYPE.NEW]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.volume'),
[SOURCE_TYPE.IMAGE]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.vmImage'),
[SOURCE_TYPE.ATTACH_VOLUME]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.existingVolume'),
[SOURCE_TYPE.CONTAINER]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.container'),
}[type];
},
update() {
this.$emit('input', this.rows);
},
deleteVolume() {
removeObject(this.rows, this.vol);
this.update();
this.cancel();
},
cancel() {
this.isOpen = false;
},
changeSort(idx, type) {
// true: down, false: up
this.rows.splice(type ? idx : idx - 1, 1, ...this.rows.splice(type ? idx + 1 : idx, 1, this.rows[type ? idx : idx - 1]));
this.update();
},
getImageDisplayName(id) {
return this.$store.getters['harvester/all'](HCI.IMAGE).find((image) => image.id === id)?.spec?.displayName;
}
},
};
</script>
<template>
<div>
<Banner
v-if="!isView"
color="info"
label-key="harvester.virtualMachine.volume.dragTip"
/>
<draggable
v-model="rows"
:disabled="isView"
@end="update"
>
<transition-group>
<div
v-for="(volume, i) in rows"
:key="volume.id"
>
<InfoBox class="box">
<button
v-if="!isView"
type="button"
class="role-link btn btn-sm remove"
@click="removeVolume(volume)"
>
<i class="icon icon-x" />
</button>
<button
v-if="volume.hotpluggable && isView"
type="button"
class="role-link btn remove"
@click="unplugVolume(volume)"
>
{{ t('harvester.virtualMachine.unplug.detachVolume') }}
</button>
<h3>
<span
v-if="volume.to && isVirtualType"
class="title"
>
<router-link :to="volume.to">
{{ t('harvester.virtualMachine.volume.edit') }} {{ headerFor(volume.source) }}
</router-link>
<BadgeStateFormatter
v-if="volume.pvc"
class="ml-10 state"
:arbitrary="true"
:row="volume.pvc"
:value="volume.pvc.state"
/>
<a
v-if="dev && !!volume.pvc && !!volume.pvc.resourceExternalLink"
v-clean-tooltip="t(volume.pvc.resourceExternalLink.tipsKey || 'generic.resourceExternalLinkTips')"
class="ml-5 resource-external"
rel="nofollow noopener noreferrer"
target="_blank"
:href="volume.pvc.resourceExternalLink.url"
>
<i class="icon icon-external-link" />
</a>
</span>
<span v-else>
{{ headerFor(volume.source) }}
</span>
</h3>
<div>
<component
:is="componentFor(volume.source)"
v-model="rows[i]"
:rows="rows"
:namespace="namespace"
:is-create="isCreate"
:is-edit="isEdit"
:is-view="isView"
:is-virtual-type="isVirtualType"
:mode="mode"
:idx="i"
:validate-required="validateRequired"
@update="update"
/>
</div>
<div class="bootOrder">
<div
v-if="!isView"
class="mr-15"
>
<button
:disabled="i === 0"
class="btn btn-sm role-primary"
@click.prevent="changeSort(i, false)"
>
<i class="icon icon-lg icon-chevron-up"></i>
</button>
<button
:disabled="i === rows.length -1"
class="btn btn-sm role-primary"
@click.prevent="changeSort(i, true)"
>
<i class="icon icon-lg icon-chevron-down"></i>
</button>
</div>
<div class="text-muted">
bootOrder: {{ i + 1 }}
</div>
</div>
<Banner
v-if="volume.volumeStatus && !isCreate"
class="mt-15 volume-status"
color="warning"
:label="volume.volumeStatus"
/>
</InfoBox>
</div>
</transition-group>
</draggable>
<Banner
v-if="showVolumeTip"
color="warning"
:label="t('harvester.virtualMachine.volume.volumeTip')"
/>
<div v-if="!isView">
<button
type="button"
class="btn btn-sm bg-primary mr-15 mb-10"
:disabled="rows.length === 0"
@click="addVolume(SOURCE_TYPE.NEW)"
>
{{ t('harvester.virtualMachine.volume.addVolume') }}
</button>
<button
v-if="!existingVolumeDisabled"
type="button"
class="btn btn-sm bg-primary mr-15 mb-10"
@click="addVolume(SOURCE_TYPE.ATTACH_VOLUME)"
>
{{ t('harvester.virtualMachine.volume.addExistingVolume') }}
</button>
<button
type="button"
class="btn btn-sm bg-primary mr-15 mb-10"
@click="addVolume(SOURCE_TYPE.IMAGE)"
>
{{ t('harvester.virtualMachine.volume.addVmImage') }}
</button>
<button
type="button"
class="btn btn-sm bg-primary mb-10"
@click="addVolume(SOURCE_TYPE.CONTAINER)"
>
{{ t('harvester.virtualMachine.volume.addContainer') }}
</button>
</div>
<ModalWithCard
v-if="isOpen"
name="deleteTip"
:width="400"
>
<template #title>
{{ t('harvester.virtualMachine.volume.unmount.title') }}
</template>
<template #content>
<span>{{ t('harvester.virtualMachine.volume.unmount.message') }}</span>
</template>
<template #footer>
<div class="buttons">
<button
class="btn role-secondary mr-20"
@click.prevent="cancel"
>
{{ t('generic.no') }}
</button>
<button
class="btn bg-primary mr-20"
@click.prevent="deleteVolume"
>
{{ t('generic.yes') }}
</button>
</div>
</template>
</ModalWithCard>
</div>
</template>
<style lang='scss' scoped>
.box {
position: relative;
}
.title {
display: flex;
.state {
font-size: 16px;
}
}
.remove {
position: absolute;
top: 10px;
right: 10px;
padding: 0px;
}
.bootOrder {
display: flex;
align-items: center;
}
.buttons {
width: 100%;
display: flex;
justify-content: flex-end;
}
.volume-status:first-letter {
text-transform: uppercase;
}
.resource-external {
display: flex;
justify-content: center;
align-items: center;
}
</style>
@@ -0,0 +1,40 @@
import { mount } from '@vue/test-utils';
import HarvesterEditContainer from '../container.vue';
import { _EDIT } from '@shell/config/query-params';
describe('component: HarvesterEditContainer', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
const inputWraps = wrapper.findAll('[data-testid^=input-hec-]');
expect(inputWraps).toHaveLength(4);
});
it.each([
'name',
'container',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
const input = wrapper.find(`[data-testid="input-hec-${ field }"]`).find('input');
const newValue = 123;
input.setValue(newValue);
expect(wrapper.emitted('update')).toHaveLength(1);
});
it.each([
'type',
'bus',
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
const select = wrapper.find(`[data-testid="input-hec-${ field }"]`);
select.find('button').trigger('click');
await wrapper.trigger('keydown.down');
await wrapper.trigger('keydown.enter');
expect(wrapper.emitted('update')).toHaveLength(1);
});
});
@@ -0,0 +1,103 @@
import { mount } from '@vue/test-utils';
import HarvesterEditExisting from '../existing.vue';
import { _EDIT } from '@shell/config/query-params';
describe('component: HarvesterEditExisting', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
mode: _EDIT, value: {}, rows: []
},
mocks: {
$store: {
getters: {
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
});
const inputWraps = wrapper.findAll('[data-testid^=input-hee-]');
expect(inputWraps).toHaveLength(5);
});
it.each([
'name',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
mode: _EDIT, value: {}, rows: []
},
mocks: {
$store: {
getters: {
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
});
const input = wrapper.find(`[data-testid="input-hee-${ field }"]`).find('input');
const newValue = 123;
input.setValue(newValue);
expect(wrapper.emitted('update')).toHaveLength(1);
});
// TODO: #6179: Restore this test when the input is allowed to edit
// eslint-disable-next-line jest/no-disabled-tests
it.skip.each([
'size',
])('should emit an update on %p input and blur', (field) => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
mode: _EDIT, value: {}, rows: []
},
mocks: {
$store: {
getters: {
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
});
const input = wrapper.find(`[data-testid="input-hee-${ field }"]`).find('input');
const newValue = 123;
input.setValue(newValue);
input.trigger('blur');
expect(wrapper.emitted('input')).toHaveLength(1);
});
it.each([
'type',
'volumeName',
// 'bus', // disabled
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
mode: _EDIT, value: {}, rows: []
},
mocks: {
$store: {
getters: {
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
});
const select = wrapper.find(`[data-testid="input-hee-${ field }"]`);
select.find('button').trigger('click');
await wrapper.trigger('keydown.down');
await wrapper.trigger('keydown.enter');
expect(wrapper.emitted('update')).toHaveLength(1);
});
});
@@ -0,0 +1,117 @@
import { mount } from '@vue/test-utils';
import HarvesterEditVMImage from '../vmImage.vue';
import { _CREATE } from '@shell/config/query-params';
describe('component: HarvesterEditVMImage', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
});
const inputWraps = wrapper.findAll('[data-testid^=input-hevi-]');
expect(inputWraps).toHaveLength(5);
});
it.each([
'name',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
});
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
const newValue = 123;
input.setValue(newValue);
expect(wrapper.emitted('update')).toHaveLength(1);
});
it.each([
'size',
])('should emit an update on %p input blur', (field) => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
});
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
const newValue = 123;
input.setValue(newValue);
input.trigger('blur');
expect(wrapper.emitted('update')).toHaveLength(1);
});
it.each([
'type',
'image',
'bus',
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
});
const select = wrapper.find(`[data-testid="input-hevi-${ field }"]`);
select.find('button').trigger('click');
await wrapper.trigger('keydown.down');
await wrapper.trigger('keydown.enter');
expect(wrapper.emitted('update')).toHaveLength(1);
});
});
@@ -0,0 +1,74 @@
import { mount } from '@vue/test-utils';
import HarvesterEditVolume from '../volume.vue';
import { _EDIT } from '@shell/config/query-params';
describe('component: HarvesterEditVolume', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditVolume, {
propsData: { validateRequired: true },
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
});
const inputWraps = wrapper.findAll('[data-testid^=input-hev-]');
expect(inputWraps).toHaveLength(4);
});
it.each([
'name',
'size',
])('should emit an update on %p input blur', (field) => {
const wrapper = mount(HarvesterEditVolume, {
propsData: { mode: _EDIT, validateRequired: true },
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
});
const input = wrapper.find(`[data-testid="input-hev-${ field }"]`).find('input');
const newValue = 123;
input.setValue(newValue);
input.trigger('blur');
expect(wrapper.emitted('update')).toHaveLength(1);
});
it.each([
'type',
'bus'
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditVolume, {
propsData: { mode: _EDIT, validateRequired: true },
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
});
const select = wrapper.find(`[data-testid="input-hev-${ field }"]`);
select.find('button').trigger('click');
await wrapper.trigger('keydown.down');
await wrapper.trigger('keydown.enter');
expect(wrapper.emitted('update')).toHaveLength(1);
});
});
@@ -0,0 +1,132 @@
<script>
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
export default {
name: 'HarvesterEditContainer',
components: {
LabeledInput, LabeledSelect, InputOrDisplay
},
props: {
mode: {
type: String,
default: 'create'
},
value: {
type: Object,
required: true
},
},
data() {
return {
VOLUME_TYPE,
InterfaceOption
};
},
watch: {
'value.type'(neu) {
if (neu === 'cd-rom') {
this.$set(this.value, 'bus', 'sata');
this.update();
}
},
},
methods: {
update() {
this.$emit('update');
}
}
};
</script>
<template>
<div>
<div class="row mb-20">
<div
data-testid="input-hec-name"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.name')"
:value="value.name"
:mode="mode"
>
<LabeledInput
v-model="value.name"
:label="t('harvester.fields.name')"
required
:mode="mode"
@input="update"
/>
</InputOrDisplay>
</div>
<div
data-testid="input-hec-type"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.type')"
:value="value.type"
:mode="mode"
>
<LabeledSelect
v-model="value.type"
:label="t('harvester.fields.type')"
:options="VOLUME_TYPE"
:mode="mode"
required
@input="update"
/>
</InputOrDisplay>
</div>
</div>
<div class="row mb-20">
<div
data-testid="input-hec-container"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.virtualMachine.volume.dockerImage')"
:value="value.container"
:mode="mode"
>
<LabeledInput
v-model="value.container"
:label="t('harvester.virtualMachine.volume.dockerImage')"
:mode="mode"
required
@input="update"
/>
</InputOrDisplay>
</div>
<div
data-testid="input-hec-bus"
class="col span-3"
>
<InputOrDisplay
:name="t('harvester.virtualMachine.volume.bus')"
:value="value.bus"
:mode="mode"
>
<LabeledSelect
v-model="value.bus"
:label="t('harvester.virtualMachine.volume.bus')"
:options="InterfaceOption"
:mode="mode"
@input="update"
/>
</InputOrDisplay>
</div>
</div>
</div>
</template>
@@ -0,0 +1,298 @@
<script>
import UnitInput from '@shell/components/form/UnitInput';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay';
import { sortBy } from '@shell/utils/sort';
import { PVC } from '@shell/config/types';
import { HCI } from '../../../../types';
import { _CREATE } from '@shell/config/query-params';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'HarvesterEditExisting',
components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay
},
props: {
mode: {
type: String,
default: _CREATE
},
value: {
type: Object,
required: true
},
isEdit: {
type: Boolean,
default: false
},
namespace: {
type: String,
default: null
},
idx: {
type: Number,
default: 0
},
rows: {
type: Array,
required: true
},
},
data() {
if (this.value.realName) {
this.value.volumeName = this.value.realName;
}
return {
VOLUME_TYPE,
InterfaceOption,
loading: false,
};
},
computed: {
isDisabled() {
return !this.value.newCreateId && this.isEdit;
},
allPVCs() {
const allPVCs = this.$store.getters['harvester/all'](PVC);
return allPVCs.filter((P) => {
return this.namespace ? this.namespace === P.metadata.namespace : true;
}) || [];
},
image() {
const imageResource = this.$store.getters['harvester/all'](HCI.IMAGE).find(I => I.id === this.pvcResource?.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID]);
if (!imageResource) {
return;
}
return `${ imageResource.metadata.namespace }/${ imageResource.spec.displayName }`;
},
pvcResource() {
return this.allPVCs.find( P => P.metadata.name === this.value.volumeName );
},
volumeOption() {
return sortBy(
this.allPVCs
.filter( (pvc) => {
let isAvailable = true;
let isBeingUsed = false;
this.rows.forEach( (O) => {
if (O.volumeName === pvc.metadata.name) {
isAvailable = false;
}
});
if (this.idx === 0 && !pvc.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID]) {
return false;
}
if (pvc.attachVM && isAvailable && pvc.attachVM?.id === this.vm?.id && this.isEdit) {
isBeingUsed = false;
} else if (pvc.attachVM) {
isBeingUsed = true;
}
return isAvailable && !isBeingUsed && pvc.isAvailable;
})
.map((pvc) => {
return {
label: pvc.metadata.name,
value: pvc.metadata.name
};
}),
'label'
);
},
},
watch: {
'value.volumeName'(neu) {
const pvcResource = this.allPVCs.find( P => P.metadata.name === neu);
if (!pvcResource) {
return;
}
this.value.accessModes = pvcResource.spec.accessModes[0];
this.value.size = pvcResource.spec.resources.requests.storage;
this.value.storageClassName = pvcResource.spec.storageClassName;
this.value.volumeMode = pvcResource.spec.volumeMode;
},
'value.type'(neu) {
if (neu === 'cd-rom') {
this.$set(this.value, 'bus', 'sata');
this.update();
}
},
pvcResource: {
handler(pvc) {
if (!this.value.volumeName && pvc?.metadata?.name) {
this.value.volumeName = pvc.metadata.name;
}
},
deep: true,
immediate: true
},
},
methods: {
update() {
this.$emit('update');
},
}
};
</script>
<template>
<div>
<div class="row mb-20">
<div
data-testid="input-hee-name"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.name')"
:value="value.name"
:mode="mode"
>
<LabeledInput
v-model="value.name"
:label="t('harvester.fields.name')"
:mode="mode"
required
@input="update"
/>
</InputOrDisplay>
</div>
<div
data-testid="input-hee-type"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.type')"
:value="value.type"
:mode="mode"
>
<LabeledSelect
v-model="value.type"
:label="t('harvester.fields.type')"
:mode="mode"
:options="VOLUME_TYPE"
required
@input="update"
/>
</InputOrDisplay>
</div>
</div>
<div class="row mb-20">
<div
data-testid="input-hee-volumeName"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.volume')"
:value="value.volumeName"
:mode="mode"
>
<LabeledSelect
v-model="value.volumeName"
:disabled="isDisabled"
:label="t('harvester.fields.volume')"
:mode="mode"
:options="volumeOption"
required
@input="update"
/>
</InputOrDisplay>
</div>
<div
data-testid="input-hee-size"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.size')"
:value="value.size"
:mode="mode"
>
<UnitInput
v-model="value.size"
:output-modifier="true"
:increment="1024"
:input-exponent="3"
:label="t('harvester.fields.size')"
:mode="mode"
:disabled="true"
@input="update"
/>
</InputOrDisplay>
</div>
</div>
<div class="row mb-20">
<div
v-if="!!image"
data-testid="input-hee-image"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.image')"
:value="image"
:mode="mode"
>
<LabeledInput
v-model="image"
:label="t('harvester.fields.image')"
:mode="mode"
:disabled="true"
@input="update"
/>
</InputOrDisplay>
</div>
<div
data-testid="input-hee-bus"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.virtualMachine.volume.bus')"
:value="value.bus"
:mode="mode"
>
<LabeledSelect
v-model="value.bus"
:label="t('harvester.virtualMachine.volume.bus')"
:mode="mode"
:options="InterfaceOption"
:disabled="true"
required
@input="update"
/>
</InputOrDisplay>
</div>
</div>
</div>
</template>
@@ -0,0 +1,282 @@
<script>
import { findBy } from '@shell/utils/array';
import UnitInput from '@shell/components/form/UnitInput';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay';
import { PVC } from '@shell/config/types';
import { HCI } from '../../../../types';
import { formatSi, parseSi } from '@shell/utils/units';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
export default {
name: 'HarvesterEditVMImage',
components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay
},
props: {
value: {
type: Object,
default: () => {
return {};
}
},
namespace: {
type: String,
default: null
},
mode: {
type: String,
default: 'create'
},
idx: {
type: Number,
required: true
},
isCreate: {
type: Boolean,
default: true
},
isEdit: {
type: Boolean,
default: false
},
validateRequired: {
type: Boolean,
required: true
},
isVirtualType: {
type: Boolean,
default: true
}
},
data() {
return {
VOLUME_TYPE,
InterfaceOption,
loading: false,
images: [],
};
},
fetch() {
this.images = this.$store.getters['harvester/all'](HCI.IMAGE);
},
computed: {
imagesOption() {
return this.images.filter(c => c.isReady).sort((a, b) => a.creationTimestamp > b.creationTimestamp ? -1 : 1).map( (I) => {
return {
label: `${ I.metadata.namespace }/${ I.spec.displayName }`,
value: I.id
};
});
},
imageName() {
const image = this.imagesOption.find(I => I.value === this.value.image);
return image ? image.label : '-';
},
pvcsResource() {
const allPVCs = this.$store.getters['harvester/all'](PVC) || [];
return allPVCs.find((P) => {
return this.namespace ? P.id === `${ this.namespace }/${ this.value.volumeName }` : true;
});
}
},
watch: {
'value.type'(neu) {
if (neu === 'cd-rom') {
this.$set(this.value, 'bus', 'sata');
this.update();
}
},
'value.image'(neu) {
this.checkImageExists(neu);
},
imagesOption() {
this.checkImageExists(this.value.image);
},
pvcsResource: {
handler(pvc) {
if (pvc?.spec?.resources?.requests?.storage && this.isVirtualType) {
const parseValue = parseSi(pvc.spec.resources.requests.storage);
const formatSize = formatSi(parseValue, {
increment: 1024,
addSuffix: false,
maxExponent: 3,
minExponent: 3,
});
this.value.size = `${ formatSize }Gi`;
}
},
deep: true,
immediate: true
},
},
methods: {
update() {
this.$emit('update');
},
onImageChange() {
const imageResource = this.$store.getters['harvester/all'](HCI.IMAGE)?.find( I => this.value.image === I.id);
if (this.idx === 0) {
if (/iso$/i.test(imageResource?.imageSuffix)) {
this.$set(this.value, 'type', 'cd-rom');
this.$set(this.value, 'bus', 'sata');
} else {
this.$set(this.value, 'type', 'disk');
this.$set(this.value, 'bus', 'virtio');
}
}
this.update();
},
onOpen() {
this.images = this.$store.getters['harvester/all'](HCI.IMAGE);
},
checkImageExists(imageId) {
if (!!imageId && this.imagesOption.length > 0 && !findBy(this.imagesOption, 'value', imageId)) {
this.$store.dispatch('growl/error', {
title: this.$store.getters['i18n/t']('harvester.vmTemplate.tips.notExistImage.title', { name: imageId }),
message: this.$store.getters['i18n/t']('harvester.vmTemplate.tips.notExistImage.message')
}, { root: true });
this.$set(this.value, 'image', '');
}
}
}
};
</script>
<template>
<div>
<div class="row mb-20">
<div
data-testid="input-hevi-name"
class="col span-6"
>
<InputOrDisplay :name="t('harvester.fields.name')" :value="value.name" :mode="mode">
<LabeledInput
v-model="value.name"
:label="t('harvester.fields.name')"
required
:mode="mode"
@input="update"
/>
</InputOrDisplay>
</div>
<div
data-testid="input-hevi-type"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.type')"
:value="value.type"
:mode="mode"
>
<LabeledSelect
v-model="value.type"
:label="t('harvester.fields.type')"
:options="VOLUME_TYPE"
:mode="mode"
@input="update"
/>
</InputOrDisplay>
</div>
</div>
<div class="row mb-20">
<div
data-testid="input-hevi-image"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.image')"
:value="imageName"
:mode="mode"
>
<LabeledSelect
v-model="value.image"
:disabled="idx === 0 && !isCreate && !value.newCreateId && isVirtualType"
:label="t('harvester.fields.image')"
:options="imagesOption"
:mode="mode"
:searchable="true"
:required="validateRequired"
@input="onImageChange"
/>
</InputOrDisplay>
</div>
<div
data-testid="input-hevi-size"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.size')"
:value="value.size"
:mode="mode"
>
<UnitInput
v-model="value.size"
v-int-number
:output-modifier="true"
:increment="1024"
:input-exponent="3"
:label="t('harvester.fields.size')"
:mode="mode"
:required="validateRequired"
suffix="GiB"
@input="update"
/>
</InputOrDisplay>
</div>
</div>
<div class="row mb-20">
<div
data-testid="input-hevi-bus"
class="col span-3"
>
<InputOrDisplay
:name="t('harvester.virtualMachine.volume.bus')"
:value="value.bus"
:mode="mode"
>
<LabeledSelect
v-model="value.bus"
:label="t('harvester.virtualMachine.volume.bus')"
:mode="mode"
:options="InterfaceOption"
@input="update"
/>
</InputOrDisplay>
</div>
</div>
</div>
</template>
@@ -0,0 +1,230 @@
<script>
import Loading from '@shell/components/Loading';
import UnitInput from '@shell/components/form/UnitInput';
import InputOrDisplay from '@shell/components/InputOrDisplay';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { PVC, STORAGE_CLASS } from '@shell/config/types';
import { formatSi, parseSi } from '@shell/utils/units';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
export default {
name: 'HarvesterEditVolume',
components: {
InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput,
},
props: {
mode: {
type: String,
default: 'create'
},
isEdit: {
type: Boolean,
default: false
},
namespace: {
type: String,
default: null
},
value: {
type: Object,
default: () => {
return {};
}
},
validateRequired: {
type: Boolean,
required: true
},
isVirtualType: {
type: Boolean,
default: true
}
},
data() {
return {
VOLUME_TYPE,
InterfaceOption,
loading: false,
};
},
computed: {
pvcsResource() {
const allPVCs = this.$store.getters['harvester/all'](PVC) || [];
return allPVCs.find(P => P.id === `${ this.namespace }/${ this.value.volumeName }`);
},
isDisabled() {
return !this.value.newCreateId && this.isEdit && this.isVirtualType;
},
storageClassOptions() {
const storages = this.$store.getters[`harvester/all`](STORAGE_CLASS) || [];
const out = storages.filter(s => !s.parameters?.backingImage).map((s) => {
const label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
return {
label,
value: s.name,
};
}) || [];
return out;
},
},
watch: {
'value.type'(neu) {
if (neu === 'cd-rom') {
this.$set(this.value, 'bus', 'sata');
this.update();
}
},
pvcsResource: {
handler(pvc) {
if (pvc?.spec?.resources?.requests?.storage) {
const parseValue = parseSi(pvc.spec.resources.requests.storage);
const formatSize = formatSi(parseValue, {
increment: 1024,
addSuffix: false,
maxExponent: 3,
minExponent: 3,
});
this.value.size = `${ formatSize }Gi`;
}
},
deep: true,
immediate: true
},
},
methods: {
update() {
this.$emit('update');
},
},
};
</script>
<template>
<div>
<Loading mode="relative" :loading="loading" />
<div class="row mb-20">
<div
class="col span-6"
data-testid="input-hev-name"
>
<InputOrDisplay
:name="t('harvester.fields.name')"
:value="value.name"
:mode="mode"
>
<LabeledInput
v-model="value.name"
:label="t('harvester.fields.name')"
:mode="mode"
required
@input="update"
/>
</InputOrDisplay>
</div>
<div
class="col span-6"
data-testid="input-hev-type"
>
<InputOrDisplay
:name="t('harvester.fields.type')"
:value="value.type"
:mode="mode"
>
<LabeledSelect
v-model="value.type"
:label="t('harvester.fields.type')"
:options="VOLUME_TYPE"
required
:mode="mode"
@input="update"
/>
</InputOrDisplay>
</div>
</div>
<div class="row mb-20">
<div
data-testid="input-hav-storage"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.storage.storageClass.label')"
:value="value.storageClassName"
:mode="mode"
>
<LabeledSelect
v-model="value.storageClassName"
:options="storageClassOptions"
:label="t('harvester.storage.storageClass.label')"
:mode="mode"
:disabled="isDisabled"
:required="validateRequired"
@input="update"
/>
</InputOrDisplay>
</div>
<div
class="col span-6"
data-testid="input-hev-size"
>
<InputOrDisplay
:name="t('harvester.fields.size')"
:value="value.size"
:mode="mode"
>
<UnitInput
v-model="value.size"
v-int-number
:output-modifier="true"
:increment="1024"
:input-exponent="3"
:mode="mode"
:required="validateRequired"
:label="t('harvester.fields.size')"
@input="update"
/>
</InputOrDisplay>
</div>
</div>
<div class="row mb-20">
<div
data-testid="input-hev-bus"
class="col span-3"
>
<InputOrDisplay :name="t('harvester.virtualMachine.volume.bus')" :value="value.bus" :mode="mode">
<LabeledSelect
v-model="value.bus"
:label="t('harvester.virtualMachine.volume.bus')"
:mode="mode"
:options="InterfaceOption"
required
@input="update"
/>
</InputOrDisplay>
</div>
</div>
</div>
</template>
@@ -0,0 +1,836 @@
<script>
import { isEqual } from 'lodash';
import { mapGetters } from 'vuex';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { Checkbox } from '@components/Form/Checkbox';
import CruResource from '@shell/components/CruResource';
import { RadioGroup } from '@components/Form/Radio';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import UnitInput from '@shell/components/form/UnitInput';
import Labels from '@shell/components/form/Labels';
import Reserved from './VirtualMachineReserved';
import SSHKey from './VirtualMachineSSHKey';
import Volume from './VirtualMachineVolume';
import Network from './VirtualMachineNetwork';
import CpuMemory from './VirtualMachineCpuMemory';
import CloudConfig from './VirtualMachineCloudConfig';
import NodeScheduling from '@shell/components/form/NodeScheduling';
import PodAffinity from '@shell/components/form/PodAffinity';
import AccessCredentials from './VirtualMachineAccessCredentials';
import PciDevices from './VirtualMachinePciDevices/index';
import VirtualMachineVGpuDevices from './VirtualMachineVGpuDevices/index';
import RestartVMDialog from '../../dialog/RestartVMDialog';
import KeyValue from '@shell/components/form/KeyValue';
import { clear } from '@shell/utils/array';
import { clone } from '@shell/utils/object';
import { HCI } from '../../types';
import { RunStrategys } from '../../config/harvester-map';
import { saferDump } from '@shell/utils/create-yaml';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { BEFORE_SAVE_HOOKS, AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
import VM_MIXIN from '../../mixins/harvester-vm';
import CreateEditView from '@shell/mixins/create-edit-view';
import { parseVolumeClaimTemplates } from '@pkg/utils/vm';
export default {
name: 'HarvesterEditVM',
components: {
Tab,
Tabbed,
Checkbox,
RadioGroup,
CruResource,
LabeledInput,
LabeledSelect,
NameNsDescription,
Volume,
SSHKey,
Network,
CpuMemory,
CloudConfig,
NodeScheduling,
PodAffinity,
AccessCredentials,
Reserved,
Labels,
PciDevices,
RestartVMDialog,
UnitInput,
VirtualMachineVGpuDevices,
KeyValue,
},
mixins: [CreateEditView, VM_MIXIN],
props: {
value: {
type: Object,
required: true,
},
},
data() {
const cloneVM = clone(this.value);
const isRestartImmediately = this.value.actualState === 'Running';
const hostname = this.value.spec.template.spec.hostname || '';
return {
cloneVM,
count: 2,
templateId: '',
templateVersionId: '',
namePrefix: '',
isSingle: true,
useTemplate: false,
isOpen: false,
hostname,
isRestartImmediately,
RunStrategys,
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
machineTypeOptions() {
return [{
label: 'None',
value: ''
}, {
label: 'q35',
value: 'q35'
}];
},
templateOptions() {
return this.templates.map( (T) => {
const isReady = this.hasAvailableVersion(T.id);
return {
label: T.id,
value: T.id,
disabled: !isReady
};
});
},
versionOptions() {
const defaultVersion = this.curTemplateResource?.defaultVersion;
return this.versions.filter( (O) => O.templateId === this.templateId).map( (T) => {
const version = T.version;
const label = defaultVersion === version ? `${ version } (${ this.t('generic.default') })` : version;
const value = T.id;
return {
label,
value,
disabled: !T.isReady
};
});
},
curTemplateResource() {
return this.templates.find( (O) => O.id === this.templateId);
},
nameLabel() {
return this.isSingle ? 'harvester.virtualMachine.instance.single.nameLabel' : 'harvester.virtualMachine.instance.multiple.nameLabel';
},
hostnameLabel() {
return this.isSingle ? 'harvester.virtualMachine.instance.single.host.label' : 'harvester.virtualMachine.instance.multiple.host.label';
},
hostPlaceholder() {
return this.isSingle ? this.t('harvester.virtualMachine.instance.single.host.placeholder') : this.t('harvester.virtualMachine.instance.multiple.host.placeholder');
},
secretNamePrefix() {
return this.value?.metadata?.name;
},
isQemuInstalled() {
return this.value.isQemuInstalled;
},
hasRestartAction() {
return this.value.hasAction('restart');
},
hasStartAction() {
return this.value.hasAction('start');
},
},
watch: {
templateId: {
async handler(id, old) {
if (!id) {
return;
}
if (id !== old && !this.templateVersionId) {
const templates = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_TEMPLATE });
this.templateVersionId = templates.find( (O) => O.id === id)?.spec?.defaultVersionId;
}
},
immediate: false
},
templateVersionId: {
async handler(id) {
if (!id) {
return;
}
const versions = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_VERSION });
const curVersion = versions.find( (V) => V.id === id);
const cloneVersionVM = clone(curVersion.spec.vm);
delete cloneVersionVM.spec?.template?.spec?.accessCredentials;
delete cloneVersionVM.spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_NAMES];
delete cloneVersionVM.spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_USERS];
const claimTemplate = parseVolumeClaimTemplates(cloneVersionVM);
const deleteDataSource = claimTemplate.map((volume) => {
if (volume?.spec?.dataSource) {
delete volume.spec.dataSource;
}
return volume;
});
cloneVersionVM.metadata.annotations[HCI_ANNOTATIONS.VOLUME_CLAIM_TEMPLATE] = JSON.stringify(deleteDataSource);
this.getInitConfig({
value: cloneVersionVM, existUserData: true, fromTemplate: true
});
this.$set(this, 'hasCreateVolumes', []); // When using the template, all volume names need to be newly created
}
},
useTemplate(neu) {
if (neu === false) {
this.templateId = '';
this.templateVersionId = '';
this.value.applyDefaults();
this.getInitConfig({ value: this.value, init: this.isCreate });
}
},
},
created() {
this.registerAfterHook(async() => {
await this.restartVM();
const id = `${ this.value.metadata.namespace }/${ this.value.metadata.name }`;
const res = this.$store.getters['harvester/byId'](HCI.VM, id);
try {
await this.saveSecret(res);
await this.saveAccessCredentials(res);
} catch (e) {
this.errors.push(...exceptionToErrorsArray(e));
}
if (!this.errors.length && this.isSingle) {
this.done();
}
});
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
mounted() {
this.imageId = this.$route.query?.image || this.imageId;
const diskRows = this.getDiskRows(this.value);
this.$set(this, 'diskRows', diskRows);
const templateId = this.$route.query.templateId;
const templateVersionId = this.$route.query.versionId;
if (templateId && templateVersionId) {
this.templateId = templateId;
this.templateVersionId = templateVersionId;
this.useTemplate = true;
}
},
methods: {
saveVM(buttonCb) {
clear(this.errors);
if (this.isSingle) {
this.saveSingle(buttonCb);
} else {
this.saveMultiple(buttonCb);
}
},
async saveSingle(buttonCb) {
this.parseVM();
this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name;
await this._save(this.value, buttonCb);
if (!this.errors.length) {
buttonCb(true);
} else {
buttonCb(false);
}
},
async saveMultiple(buttonCb) {
this.namePrefix = this.value.metadata.name || '';
const join = this.namePrefix.endsWith('-') ? '' : '-';
const baseHostname = this.hostname ? this.hostname : this.namePrefix;
if (this.count < 1) {
this.errors = [this.t('harvester.virtualMachine.instance.multiple.countTip')];
buttonCb(false);
return;
}
if (!this.value.metadata.name) {
this.errors.push(this.t('validation.required', { key: this.t('generic.name') }, true));
buttonCb(false);
return;
}
const cloneValue = clone(this.value);
const cloneSpec = clone(this.spec);
for (let i = 1; i <= this.count; i++) {
this.$set(this.value, 'spec', cloneValue.spec);
this.$set(this, 'spec', cloneSpec);
const suffix = i < 10 ? `0${ i }` : i;
this.value.cleanForNew();
this.value.metadata.name = `${ this.namePrefix }${ join }${ suffix }`;
this.$set(this.value.spec.template.spec, 'hostname', `${ baseHostname }${ join }${ suffix }`);
this.secretName = '';
await this.parseVM();
const basicValue = await this.$store.dispatch('harvester/clone', { resource: this.value });
await this._save(basicValue, buttonCb);
if (i === this.count && !this.errors.length) {
buttonCb(true);
this.done();
} else if (i === this.count) {
this.value.metadata.name = this.namePrefix;
buttonCb(false);
}
}
},
async _save(value, buttonCb) {
try {
await this.applyHooks(BEFORE_SAVE_HOOKS);
await value.save();
await this.applyHooks(AFTER_SAVE_HOOKS);
} catch (e) {
this.errors.push(...exceptionToErrorsArray(e));
buttonCb(false);
}
},
restartVM() {
if (this.mode !== 'edit') {
return;
}
if (!this.value.isRunning) {
return;
}
const cloneDeepNewVM = clone(this.value);
delete cloneDeepNewVM?.metadata;
delete this.cloneVM?.metadata;
delete this.cloneVM?.__clone;
const oldVM = JSON.parse(JSON.stringify(this.cloneVM));
const newVM = JSON.parse(JSON.stringify(cloneDeepNewVM));
if (isEqual(oldVM, newVM)) {
return;
}
return new Promise((resolve) => {
this.isOpen = true;
this.$refs.restartDialog.resolve = resolve;
});
},
updateBeforeSave() {
if (this.isSingle) {
if (!this.value.spec.template.spec.hostname) {
this.$set(this.value.spec.template.spec, 'hostname', this.value.metadata.name);
}
}
const errors = this.getAccessCredentialsValidation();
if (errors.length > 0) {
return Promise.reject(errors);
} else {
return Promise.resolve();
}
},
validateCount(count) {
if (count > 10) {
this.$set(this, 'count', 10);
}
},
updateTemplateId() {
this.templateVersionId = '';
},
onTabChanged({ tab }) {
if (tab.name === 'advanced') {
this.$refs.yamlEditor?.refresh();
}
},
hasAvailableVersion(templateId) {
let hasAvailableVersion = false;
this.versions.filter( (O) => O.templateId === templateId).find( (T) => {
if (T.isReady) {
hasAvailableVersion = true;
}
});
return hasAvailableVersion;
},
generateYaml() {
this.parseVM();
const out = saferDump(this.value);
return out;
},
},
};
</script>
<template>
<CruResource
v-if="spec"
id="vm"
:done-route="doneRoute"
:resource="value"
:mode="mode"
:can-yaml="isSingle ? true : false"
:errors="errors"
:generate-yaml="generateYaml"
:apply-hooks="applyHooks"
@finish="saveVM"
>
<RadioGroup
v-if="isCreate"
v-model="isSingle"
class="mb-20 vm-radio-group"
name="createInstanceMode"
:options="[true,false]"
:labels="[t('harvester.virtualMachine.instance.single.label'), t('harvester.virtualMachine.instance.multiple.label')]"
/>
<NameNsDescription
v-model="value"
:mode="mode"
:has-extra="!isSingle"
:name-label="nameLabel"
:namespaced="true"
:name-placeholder="isSingle ? 'nameNsDescription.name.placeholder' : 'harvester.virtualMachine.instance.multiple.nameNsDescription'"
:extra-columns="isSingle ? [] :['type']"
>
<template v-slot:type>
<LabeledInput
v-if="!isSingle"
v-model.number="count"
v-int-number
:min="2"
type="number"
:label="t('harvester.virtualMachine.instance.multiple.count')"
required
@input="validateCount"
/>
</template>
</NameNsDescription>
<Checkbox
v-if="isCreate"
v-model="useTemplate"
class="check mb-20"
type="checkbox"
label-key="harvester.virtualMachine.useTemplate.label"
/>
<div
v-if="useTemplate"
class="row mb-20"
>
<div class="col span-6">
<LabeledSelect
v-model="templateId"
label-key="harvester.virtualMachine.useTemplate.template.label"
:options="templateOptions"
@input="updateTemplateId"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model="templateVersionId"
label-key="harvester.virtualMachine.useTemplate.version.label"
:options="versionOptions"
/>
</div>
</div>
<Tabbed
:side-tabs="true"
@changed="onTabChanged"
>
<Tab
name="basics"
:label="t('harvester.virtualMachine.detail.tabs.basics')"
>
<CpuMemory
:cpu="cpu"
:memory="memory"
:mode="mode"
@updateCpuMemory="updateCpuMemory"
/>
<SSHKey
v-model="sshKey"
class="mb-20"
:create-namespace="true"
:namespace="value.metadata.namespace"
:mode="mode"
:disabled="isWindows"
@update:sshKey="updateSSHKey"
@register-after-hook="registerAfterHook"
/>
</Tab>
<Tab
name="Volume"
:label="t('harvester.tab.volume')"
:weight="-1"
>
<Volume
v-model="diskRows"
:mode="mode"
:custom-volume-mode="customVolumeMode"
:namespace="value.metadata.namespace"
:resource-type="value.type"
:vm="value"
:validate-required="true"
/>
</Tab>
<Tab
name="Network"
:label="t('harvester.tab.network')"
:weight="-2"
>
<Network
v-model="networkRows"
:mode="mode"
:is-single="isSingle"
/>
</Tab>
<Tab
name="nodeScheduling"
:label="t('workload.container.titles.nodeScheduling')"
:weight="-3"
>
<template #default="{active}">
<NodeScheduling
:key="active"
:mode="mode"
:value="spec.template.spec"
:nodes="nodesIdOptions"
/>
</template>
</Tab>
<Tab
:label="t('harvester.tab.vmScheduling')"
name="vmScheduling"
:weight="-4"
>
<template #default="{active}">
<PodAffinity
:key="active"
:mode="mode"
:value="spec.template.spec"
:nodes="nodes"
:all-namespaces-option-available="true"
:namespaces="filteredNamespaces"
:overwrite-labels="affinityLabels"
/>
</template>
</Tab>
<Tab
v-if="enabledPCI"
:label="t('harvester.tab.pciDevices')"
name="pciDevices"
:weight="-5"
>
<PciDevices
:mode="mode"
:value="spec.template.spec"
:vm="value"
/>
</Tab>
<Tab
v-if="enabledSriovgpu"
:label="t('harvester.tab.vGpuDevices')"
name="vGpuDevices"
:weight="-6"
>
<VirtualMachineVGpuDevices
:mode="mode"
:value="spec.template.spec"
:vm="value"
/>
</Tab>
<Tab
v-if="isEdit"
:label="t('harvester.tab.accessCredentials')"
name="accessCredentials"
:weight="-7"
>
<AccessCredentials
v-model="accessCredentials"
:mode="mode"
:resource="value"
:is-qemu-installed="isQemuInstalled"
/>
</Tab>
<Tab
name="advanced"
:label="t('harvester.tab.advanced')"
:weight="-8"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model="runStrategy"
label-key="harvester.virtualMachine.runStrategy"
:options="RunStrategys"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model="osType"
label-key="harvester.virtualMachine.osType"
:options="OS"
:disabled="!isCreate"
/>
</div>
</div>
<div class="row mb-20">
<a
v-if="showAdvanced"
v-t="'harvester.generic.showMore'"
role="button"
@click="toggleAdvanced"
/>
<a
v-else
v-t="'harvester.generic.showMore'"
role="button"
@click="toggleAdvanced"
/>
</div>
<div
v-if="showAdvanced"
class="mb-20"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model="hostname"
:label-key="hostnameLabel"
:placeholder="hostPlaceholder"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model="machineType"
label-key="harvester.virtualMachine.input.MachineType"
:options="machineTypeOptions"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<Reserved
:reserved-memory="reservedMemory"
:mode="mode"
@updateReserved="updateReserved"
/>
</div>
<div class="col span-6">
<UnitInput
v-model="terminationGracePeriodSeconds"
:suffix="terminationGracePeriodSeconds == 1 ? 'Second' : 'Seconds'"
:label="t('harvester.virtualMachine.terminationGracePeriodSeconds.label')"
:mode="mode"
positive
@input="updateTerminationGracePeriodSeconds"
/>
</div>
</div>
</div>
<CloudConfig
ref="yamlEditor"
:user-script="userScript"
:mode="mode"
:view-code="isWindows"
:namespace="value.metadata.namespace"
:network-script="networkScript"
@updateUserData="updateUserData"
@updateNetworkData="updateNetworkData"
@updateDataTemplateId="updateDataTemplateId"
/>
<Checkbox
v-model="installUSBTablet"
class="check mt-20"
type="checkbox"
tooltip-key="harvester.virtualMachine.usbTip"
label-key="harvester.virtualMachine.enableUsb"
:mode="mode"
/>
<Checkbox
v-model="installAgent"
class="check"
type="checkbox"
:disabled="isWindows"
label-key="harvester.virtualMachine.installAgent"
:mode="mode"
@input="updateAgent"
/>
<Checkbox
v-model="tpmEnabled"
class="check"
type="checkbox"
label-key="harvester.virtualMachine.advancedOptions.tpm"
:mode="mode"
/>
<Checkbox
v-model="efiEnabled"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.efiEnabled')"
:mode="mode"
/>
<Checkbox
v-if="efiEnabled"
v-model="secureBoot"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.secureBoot')"
:mode="mode"
/>
</Tab>
<Tab
name="instanceLabel"
:label="t('harvester.tab.instanceLabel')"
:weight="-99"
>
<Labels
:default-container-class="'labels-and-annotations-container'"
:value="value"
:mode="mode"
:display-side-by-side="false"
:show-annotations="false"
:show-label-title="false"
>
<template #labels="{toggler}">
<KeyValue
key="labels"
:value="value.instanceLabels"
:protected-keys="value.systemLabels || []"
:toggle-filter="toggler"
:add-label="t('labels.addLabel')"
:mode="mode"
:read-allowed="false"
:value-can-be-empty="true"
@input="value.setInstanceLabels($event)"
/>
</template>
</Labels>
</Tab>
</Tabbed>
<RestartVMDialog
v-if="isOpen"
:vm="value"
/>
</CruResource>
</template>
<style lang="scss" scoped>
#vm {
::v-deep .vm-radio-group .radio-group {
display: flex;
.radio-container {
margin-right: 30px;
}
}
.restart {
display: flex;
justify-content: flex-end;
}
.banner-right {
width: auto;
display: flex;
justify-items: center;
}
}
</style>