mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-10-03 20:56:06 +08:00
Add pkg/harvester components + shell portings - 1
Signed-off-by: Francesco Torchia <francesco.torchia@suse.com>
This commit is contained in:
+191
@@ -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>
|
||||
+212
@@ -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>
|
||||
+94
@@ -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>
|
||||
+85
@@ -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>
|
||||
+155
@@ -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>
|
||||
+41
@@ -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>
|
||||
+157
@@ -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>
|
||||
+189
@@ -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">—</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>
|
||||
+129
@@ -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>
|
||||
+40
@@ -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);
|
||||
});
|
||||
});
|
||||
+103
@@ -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);
|
||||
});
|
||||
});
|
||||
+117
@@ -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);
|
||||
});
|
||||
});
|
||||
+74
@@ -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);
|
||||
});
|
||||
});
|
||||
+132
@@ -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>
|
||||
Reference in New Issue
Block a user