feat: Introduce VM Import UI flow pages (#642)

* feat(vmimport): First working side nav attempt

Add vmimport entries when the related resource actually exists aka addon was enabled

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): improved version that uses 'store.watch' instead of polling

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): further tuning of dynamic side navi load/unload

Code formatting and commits. Also safeguard if something is wrong with the store

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): separate vmimport side nav entries from dynamic logic

function registerAddonSideNav introduced in utils/dynamic-nav.js

Decouples vmimport side nav entries from the hide/unhide based on addon status logic

Makes it reusable in the UI with other AddOns in the future

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add custom headers for HCI.VMIMPORT

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add custom headers for HCI.VMIMPORT_SOURCE_V

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add custom headers for HCI.VMIMPORT_SOURCE_O

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): array instead string passed to configureType

Caused routing issues for CRUD operations

Labels moved to labelTypes section to follow standards

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): registerAddonSideNav improved and refactored

Clear comments, code refactoring, additional checks and validations

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): show correct status for virtualmachineimport

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): custom list components with ns grouping

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): 404 on refresh and missing menu entry

Restores virtualType definitions to register routes synchronously,
preventing 404 errors during page reload.

Updates dynamic-nav to force-fetch addon data if missing, fixing
hidden menu issues on direct page access.

Restores explicit label keys for virtualTypes to ensure correct
naming in the side navigation.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add edit form for VirtualMachineImport resource

Adds a UI form for VirtualMachineImport to replace manual YAML editing.
The form fetches VmwareSource and OpenstackSource objects for the
source selection dropdown.

It validates the VM name against RFC-1123 rules and filters out
internal storage classes. Users can also configure network mappings
via a dynamic list.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add edit form for VmwareSource

Adds a UI to configure VmwareSource resources including the endpoint
and datacenter fields.

For authentication, users can either select an existing Secret or
enter a username and password directly. The form handles creating
the required Kubernetes Secret in the background when new credentials
are provided.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add edit form for OpenstackSource

Custom edit form for OpenstackSource resource. Creates new secret
or lets users select existing secrets. Support all fields the CRD has.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* chore(vmimport): vmware source default endpoint and datacenter renamed

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add edit form for OvaSource

OvaSource (new in harvester / vm-import-controller v1.7.0).
Imports VMs from an OVA file using via HTTP or HTTPS.

The form supports URL configuration and optional Basic Auth using a
username and password. Users can also provide an optional CA Certificate
for HTTPS verification and configure advanced HTTP timeout settings.

VirtualMachineImport edit page to updated to include OvaSource in
the source dropdown.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* chore(vmimport): align tab names on openstacksource

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): import { TextArea } from '@components/Form/TextArea' not found

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): 'Destination Network' don't list all networks

Online listed the 'mgmt' Network. Adjust to read all Virtual Machine Networks.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): rename side-nav entry to 'Virtual Machine Imports'

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): OvaSource Auth tab throws error selecting existing secret

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): Add missing caCert input field to vmware source

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): use 'LabeledInput' instead of 'TextAreaAutoGrow' for cacert fields

Changing the type allows labels to show up in the UI

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): Move vars into types files and reference them

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): Use 'currentProduct' value instead of hardcoded 'harvester' string

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): shorten 'selectedOption.raw' usage

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): Checks to make splice() usage more robust

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): re-use existing rfc1123 val function

Move rfc1123 validation error message to l10n/en-us.yaml

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): var name typo in vmi edit rfc1123 check

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): vmi use 'FormValidation' and l10n for labels

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): oss use 'FormValidation' and l10n for labels

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): ovas use 'FormValidation' and l10n for labels

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): vms use 'FormValidation' and l10n for labels

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): Display error message at the top of the page

Signed-off-by: Volker Theile <vtheile@suse.com>

---------

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>
Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
This commit is contained in:
Dominik Wombacher
2026-01-21 15:30:55 +08:00
committed by GitHub
co-authored by Volker Theile
parent ee1c3de188
commit 3dcc50980b
13 changed files with 2182 additions and 1 deletions
@@ -0,0 +1,357 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { RadioGroup } from '@components/Form/Radio';
import UnitInput from '@shell/components/form/UnitInput';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { mapGetters } from 'vuex';
export default {
name: 'EditOpenstackSource',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
RadioGroup,
UnitInput
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allSecrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET });
},
data() {
if (!this.value.spec) this.value.spec = {};
if (!this.value.spec.credentials) this.value.spec.credentials = {};
const initialMode = this.value.spec.credentials.name ? 'existing' : 'new';
return {
allSecrets: [],
authMode: initialMode,
newUsername: '',
newPassword: '',
newProjectName: '',
newDomainName: '',
newCaCert: '',
// Rules for fields that exist in the value object (Model)
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.endpoint', rules: ['endpointRequired'] },
{ path: 'spec.region', rules: ['regionRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
authModeOptions() {
return [
{ label: this.t('harvester.addons.vmImport.fields.createSecret'), value: 'new' },
{ label: this.t('harvester.addons.vmImport.fields.useSecret'), value: 'existing' }
];
},
secretOptions() {
const currentNamespace = this.value.metadata.namespace || 'default';
return this.allSecrets
.filter((s) => s.metadata.namespace === currentNamespace)
.map((s) => ({
label: s.nameDisplay,
value: s.metadata.name
}));
},
// Define custom rules for the FormValidation mixin
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
endpointRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.openstack.fields.endpoint') }) : undefined,
regionRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.openstack.fields.region') }) : undefined,
};
},
// Combine mixin validation + conditional manual checks
isFormValid() {
// Check static fields via Mixin
if (!this.fvFormIsValid) {
return false;
}
// Check conditional fields
if (this.authMode === 'new') {
if (!this.newUsername || !this.newPassword) return false;
if (!this.newProjectName || !this.newDomainName) return false;
} else {
if (!this.value.spec.credentials.name) return false;
}
return true;
}
},
methods: {
usernameRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.username') }) : undefined;
},
passwordRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.password') }) : undefined;
},
projectRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.openstack.fields.projectName') }) : undefined;
},
domainRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.openstack.fields.domainName') }) : undefined;
},
secretRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.selectSecret') }) : undefined;
},
async saveSource(buttonCb) {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
if (this.authMode === 'new') {
const secretName = `${ this.value.metadata.name }-creds-${ randomStr(4).toLowerCase() }`;
const namespace = this.value.metadata.namespace || 'default';
const newSecret = await this.$store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
username: btoa(this.newUsername),
password: btoa(this.newPassword),
project_name: btoa(this.newProjectName),
domain_name: btoa(this.newDomainName),
ca_cert: this.newCaCert ? btoa(this.newCaCert) : undefined
};
await newSecret.save();
this.value.spec.credentials = {
name: secretName,
namespace
};
}
await this.save(buttonCb);
} catch (err) {
this.errors = [err];
buttonCb(false);
}
}
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="saveSource"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.vmImport.titles.basic')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.endpoint"
:label="t('harvester.addons.vmImport.openstack.fields.endpoint')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.endpoint')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.endpoint')"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.region"
:label="t('harvester.addons.vmImport.openstack.fields.region')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.region')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.region')"
required
/>
</div>
</div>
</Tab>
<Tab
name="auth"
:label="t('harvester.addons.vmImport.titles.auth')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-12">
<RadioGroup
v-model:value="authMode"
name="authMode"
:options="authModeOptions"
:mode="mode"
/>
</div>
</div>
<div v-if="authMode === 'new'">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newUsername"
:label="t('harvester.addons.vmImport.fields.username')"
:mode="mode"
:rules="[usernameRule]"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newPassword"
type="password"
:label="t('harvester.addons.vmImport.fields.password')"
:mode="mode"
:rules="[passwordRule]"
required
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newProjectName"
:label="t('harvester.addons.vmImport.openstack.fields.projectName')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.projectName')"
:mode="mode"
:rules="[projectRule]"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newDomainName"
:label="t('harvester.addons.vmImport.openstack.fields.domainName')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.domainName')"
:mode="mode"
:rules="[domainRule]"
required
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="newCaCert"
type="multiline"
:label="t('harvester.addons.vmImport.fields.caCert')"
:placeholder="t('harvester.addons.vmImport.placeholders.caCert')"
:min-height="100"
:mode="mode"
/>
</div>
</div>
</div>
<div v-if="authMode === 'existing'">
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.credentials.name"
:options="secretOptions"
:label="t('harvester.addons.vmImport.fields.selectSecret')"
:mode="mode"
:rules="[secretRule]"
required
/>
</div>
</div>
</div>
</Tab>
<Tab
name="advanced"
:label="t('harvester.addons.vmImport.titles.advanced')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-6">
<UnitInput
v-model:value="value.spec.uploadImageRetryCount"
:label="t('harvester.addons.vmImport.openstack.fields.retryCount')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.retryCount')"
suffix="Times"
:mode="mode"
/>
</div>
<div class="col span-6">
<UnitInput
v-model:value="value.spec.uploadImageRetryDelay"
:label="t('harvester.addons.vmImport.openstack.fields.retryDelay')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.retryDelay')"
suffix="Seconds"
:mode="mode"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -0,0 +1,322 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { RadioGroup } from '@components/Form/Radio';
import UnitInput from '@shell/components/form/UnitInput';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { mapGetters } from 'vuex';
export default {
name: 'EditOvaSource',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
RadioGroup,
UnitInput
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allSecrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET });
},
data() {
if (!this.value.spec) this.value.spec = {};
// Auth is optional for OVA (public URLs).
// If credentials.name exists -> Existing.
// If not -> None (default).
let initialMode = 'none';
if (this.value.spec.credentials?.name) {
initialMode = 'existing';
}
return {
allSecrets: [],
authMode: initialMode,
newUsername: '',
newPassword: '',
newCaCert: '', // Key will be "ca.crt"
// Validation Rules for static fields
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.url', rules: ['urlRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
authModeOptions() {
return [
{ label: this.t('harvester.addons.vmImport.fields.none'), value: 'none' },
{ label: this.t('harvester.addons.vmImport.fields.createSecret'), value: 'new' },
{ label: this.t('harvester.addons.vmImport.fields.useSecret'), value: 'existing' }
];
},
secretOptions() {
const currentNamespace = this.value.metadata.namespace || 'default';
return this.allSecrets
.filter((s) => s.metadata.namespace === currentNamespace)
.map((s) => ({
label: s.nameDisplay,
value: s.metadata.name
}));
},
// Define custom rules for the FormValidation mixin
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
urlRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.ova.fields.url') }) : undefined,
};
},
isFormValid() {
if (!this.fvFormIsValid) {
return false;
}
if (this.authMode === 'new') {
// At least a username/password OR a CA cert to be provided.
// If the user selected "Create New", they likely intend to enter something.
if (!this.newUsername && !this.newPassword && !this.newCaCert) return false;
} else if (this.authMode === 'existing') {
if (!this.value.spec.credentials?.name) return false;
}
return true;
}
},
watch: {
authMode(newMode) {
if (newMode === 'existing') {
// Bind to value.spec.credentials.name for existing credential
// Ensure 'credentials' object exists first when selected
if (!this.value.spec.credentials) {
this.value.spec.credentials = {
name: '',
namespace: this.value.metadata.namespace || 'default'
};
}
}
}
},
methods: {
secretRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.selectSecret') }) : undefined;
},
async saveSource(buttonCb) {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
if (this.authMode === 'none') {
// Clear any credential reference
delete this.value.spec.credentials;
} else if (this.authMode === 'new') {
const secretName = `${ this.value.metadata.name }-creds-${ randomStr(4).toLowerCase() }`;
const namespace = this.value.metadata.namespace || 'default';
const newSecret = await this.$store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
// Optional fields logic
username: this.newUsername ? btoa(this.newUsername) : undefined,
password: this.newPassword ? btoa(this.newPassword) : undefined,
// vm-import-controller code specifies "ca.crt" with a dot.
'ca.crt': this.newCaCert ? btoa(this.newCaCert) : undefined
};
await newSecret.save();
this.value.spec.credentials = {
name: secretName,
namespace
};
}
await this.save(buttonCb);
} catch (err) {
this.errors = [err];
buttonCb(false);
}
}
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="saveSource"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.vmImport.titles.basic')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="value.spec.url"
:label="t('harvester.addons.vmImport.ova.fields.url')"
:placeholder="t('harvester.addons.vmImport.ova.placeholders.url')"
tooltip="Supports HTTP and HTTPS protocols."
:mode="mode"
:rules="fvGetAndReportPathRules('spec.url')"
required
/>
</div>
</div>
</Tab>
<Tab
name="auth"
:label="t('harvester.addons.vmImport.titles.auth')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-12">
<RadioGroup
v-model:value="authMode"
name="authMode"
:options="authModeOptions"
:mode="mode"
/>
</div>
</div>
<div v-if="authMode === 'new'">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newUsername"
:label="t('harvester.addons.vmImport.fields.username')"
placeholder="(Optional)"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newPassword"
type="password"
:label="t('harvester.addons.vmImport.fields.password')"
placeholder="(Optional)"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="newCaCert"
type="multiline"
:label="t('harvester.addons.vmImport.fields.caCert')"
:placeholder="t('harvester.addons.vmImport.placeholders.caCert')"
:min-height="100"
:mode="mode"
/>
</div>
</div>
</div>
<div v-if="authMode === 'existing'">
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.credentials.name"
:options="secretOptions"
:label="t('harvester.addons.vmImport.fields.selectSecret')"
:mode="mode"
:rules="[secretRule]"
required
/>
</div>
</div>
</div>
</Tab>
<Tab
name="advanced"
:label="t('harvester.addons.vmImport.titles.advanced')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-6">
<UnitInput
v-model:value="value.spec.httpTimeoutSeconds"
:label="t('harvester.addons.vmImport.ova.fields.httpTimeout')"
placeholder="Default: 600"
suffix="Seconds"
:mode="mode"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -0,0 +1,570 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { Checkbox } from '@components/Form/Checkbox';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { allHash } from '@shell/utils/promise';
import { MANAGEMENT_NETWORK } from '../mixins/harvester-vm';
import { VMIMPORT_SOURCE_PROVIDER, VMIMPORT_SOURCE_KINDS } from '../config/types';
import { HCI } from '../types';
import { isValidDNSLabelName } from '@pkg/utils/regular';
import { mapGetters } from 'vuex';
// Full API types for the fetch dispatch
const VMWARE_SOURCE_TYPE = `${ HCI.MIGRATION }.${ VMIMPORT_SOURCE_KINDS.VMWARE.toLowerCase() }`;
const OPENSTACK_SOURCE_TYPE = `${ HCI.MIGRATION }.${ VMIMPORT_SOURCE_KINDS.OPENSTACK.toLowerCase() }`;
const OVA_SOURCE_TYPE = `${ HCI.MIGRATION }.${ VMIMPORT_SOURCE_KINDS.OVA.toLowerCase() }`;
export default {
name: 'EditVirtualMachineImport',
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
Checkbox
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
// Fetch all dependencies in parallel to speed up the page load
const hash = {
storageClasses: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }),
networks: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
vmwareSources: this.$store.dispatch(`${ inStore }/findAll`, { type: VMWARE_SOURCE_TYPE }),
openstackSources: this.$store.dispatch(`${ inStore }/findAll`, { type: OPENSTACK_SOURCE_TYPE }),
ovaSources: this.$store.dispatch(`${ inStore }/findAll`, { type: OVA_SOURCE_TYPE }).catch(() => []),
};
const res = await allHash(hash);
this.allStorageClasses = res.storageClasses;
this.allNetworks = res.networks;
this.vmwareSources = res.vmwareSources;
this.openstackSources = res.openstackSources;
this.ovaSources = res.ovaSources;
},
data() {
// Ensure the spec object exists to prevent 'undefined' errors during rendering
if (!this.value.spec) this.value.spec = {};
if (!this.value.spec.sourceCluster) this.value.spec.sourceCluster = {};
if (!this.value.spec.networkMapping) this.value.spec.networkMapping = [];
// Detect if in Edit mode by checking the existing kind
// This allows to pre-select the correct Provider Type tab
let initialProvider = '';
const existingKind = this.value.spec.sourceCluster.kind;
if (existingKind === VMIMPORT_SOURCE_KINDS.VMWARE) initialProvider = VMIMPORT_SOURCE_PROVIDER.VMWARE;
else if (existingKind === VMIMPORT_SOURCE_KINDS.OPENSTACK) initialProvider = VMIMPORT_SOURCE_PROVIDER.OPENSTACK;
else if (existingKind === VMIMPORT_SOURCE_KINDS.OVA) initialProvider = VMIMPORT_SOURCE_PROVIDER.OVA;
// Construct the unique key (Kind/Namespace/Name) if we are editing an existing resource
let initialSourceKey = null;
if (this.value.spec.sourceCluster.name) {
initialSourceKey = `${ existingKind }/${ this.value.spec.sourceCluster.namespace }/${ this.value.spec.sourceCluster.name }`;
}
return {
allStorageClasses: [],
allNetworks: [],
vmwareSources: [],
openstackSources: [],
ovaSources: [],
// UI State
sourceProviderType: initialProvider,
selectedSourceKey: initialSourceKey,
// Static Options
providerTypeOptions: [
{ label: 'VMware', value: VMIMPORT_SOURCE_PROVIDER.VMWARE },
{ label: 'OpenStack', value: VMIMPORT_SOURCE_PROVIDER.OPENSTACK },
{ label: 'OVA', value: VMIMPORT_SOURCE_PROVIDER.OVA }
],
diskBusOptions: [
// Allow resetting selection / reset to the default behavior (sending null/empty)
{ label: this.t('harvester.addons.vmImport.options.useDefault'), value: '' },
{ label: 'VirtIO', value: 'virtio' },
{ label: 'SCSI', value: 'scsi' },
{ label: 'SATA', value: 'sata' },
{ label: 'USB', value: 'usb' },
],
interfaceModelOptions: [
// Allow resetting selection / reset to the default behavior (sending null/empty)
{ label: this.t('harvester.addons.vmImport.options.useDefault'), value: '' },
{ 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' },
],
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.virtualMachineName', rules: ['vmNameRequired', 'rfc1123'] },
],
};
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
...mapGetters({ t: 'i18n/t' }),
// Return only the sources that match the selected Provider Type (VMware or OpenStack)
sourceOptions() {
let list = [];
if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.VMWARE) {
list = this.vmwareSources;
} else if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.OPENSTACK) {
list = this.openstackSources;
} else if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.OVA) {
list = this.ovaSources;
}
return list.map((s) => {
// Fallback for API version/kind if missing on the object
let kind = s.kind;
if (!kind) {
if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.VMWARE) kind = VMIMPORT_SOURCE_KINDS.VMWARE;
else if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.OPENSTACK) kind = VMIMPORT_SOURCE_KINDS.OPENSTACK;
else if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.OVA) kind = VMIMPORT_SOURCE_KINDS.OVA;
}
const apiVersion = s.apiVersion || `${ HCI.MIGRATION }/v1beta1`;
return {
label: s.metadata.name,
value: `${ kind }/${ s.metadata.namespace }/${ s.metadata.name }`,
// We attach the raw metadata so we can easily populate the spec later without re-finding the object
raw: {
kind,
apiVersion,
name: s.metadata.name,
namespace: s.metadata.namespace
}
};
});
},
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
vmNameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.vmName') }) : undefined,
rfc1123:
(val) => {
if (val && !isValidDNSLabelName(val)) {
return this.t('harvester.addons.vmImport.errors.rfc1123');
}
return undefined;
}
};
},
// Perform various form validations before allowing to submit
isFormValid() {
// Check VM Name is valid
const nameError = this.fvNameRule(this.value.spec.virtualMachineName);
if (nameError) return false;
// Check mandatory fields in Basics
if (!this.value.spec.virtualMachineName) return false;
if (!this.selectedSourceKey) return false;
// Check Network Mappings
// If any row is missing source or destination, the form is invalid.
const networks = this.value.spec.networkMapping || [];
const hasInvalidRow = networks.some((row) => !row.sourceNetwork || !row.destinationNetwork);
if (hasInvalidRow) return false;
return true;
},
isNetworkTabInvalid() {
const networks = this.value.spec.networkMapping || [];
// Only error if a row exists AND it is missing fields
return networks.some((row) => !row.sourceNetwork || !row.destinationNetwork);
},
// Filter out internal storage classes
// to prevent selecting a class that might cause the import to fail
storageClassOptions() {
return this.allStorageClasses
.filter((sc) => {
const isInternal = sc.parameters?.['harvesterhci.io/isInternalStorageClass'] === 'true';
return !isInternal;
})
.map((sc) => ({
label: sc.nameDisplay,
value: sc.id
}));
},
networkOptions() {
const mgmtOption = {
label: 'Management Network',
value: MANAGEMENT_NETWORK
};
const vlanOptions = this.allNetworks.map((n) => ({
label: n.nameDisplay || n.metadata.name,
value: n.id
}));
return [mgmtOption, ...vlanOptions];
}
},
methods: {
// Clear the selected cluster if the user switches providers (e.g. VMware -> OpenStack)
// Prevents submitting a VMware cluster name while the kind is OpenStack
onProviderTypeChange(newType) {
this.selectedSourceKey = null;
this.value.spec.sourceCluster = {};
},
// Update the sourceCluster object based on the single dropdown selection
updateSource(key) {
this.selectedSourceKey = key;
const selectedOption = this.sourceOptions.find((o) => o.value === key);
if (selectedOption) {
const {
kind, apiVersion, name, namespace
} = selectedOption.raw;
this.value.spec.sourceCluster = {
kind,
apiVersion,
name,
namespace
};
} else {
this.value.spec.sourceCluster = {};
}
},
addNetworkMapping() {
this.value.spec.networkMapping.push({
sourceNetwork: '',
destinationNetwork: '',
networkInterfaceModel: ''
});
},
removeNetworkMapping(index) {
if (!this.value?.spec?.networkMapping) {
return;
}
if (index >= 0 && index < this.value.spec.networkMapping.length) {
this.value.spec.networkMapping.splice(index, 1);
}
},
requiredRule(val) {
if (!val) {
return this.t('validation.required', { key: this.t('generic.value') });
}
return undefined;
},
// Validates that the input follows Kubernetes Naming Rules (RFC 1123).
// If the source VM has uppercase letters or spaces, the user must be warned
// that they cannot import it until they rename it on the source. See:
// https://docs.harvesterhci.io/v1.6/advanced/addons/vmimport/#source-virtual-machine-name-is-not-rfc1123-compliant
fvNameRule(val) {
if (!val) return undefined; // 'Required' check handles empty state separately
// valid RFC 1123
if (!isValidDNSLabelName(val)) {
return this.t('harvester.addons.vmImport.errors.rfc1123');
}
return undefined;
},
updateBeforeSave() {
// If networkMapping exists, filter out the "Management Network" rows
// Let the vm-import-controller set the default network mapping
if (this.value.spec.networkMapping) {
this.value.spec.networkMapping = this.value.spec.networkMapping.filter((row) => {
return row.destinationNetwork !== MANAGEMENT_NETWORK;
});
}
},
// Only handles complex logic that doesn't fit into simple field rules
async saveOverride(buttonCb) {
const errors = [];
this.errors = [];
// Validate Provider Type
if (!this.sourceProviderType) {
errors.push(this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.sourceProvider') }));
}
// Validate Network Tab
if (this.isNetworkTabInvalid) {
errors.push(this.t('harvester.addons.vmImport.errors.networkMappingRequired'));
}
// Return immediately in case of an error, avoid that `this.save()` runs, preventing `updateBeforeSave` from resetting data.
if (errors.length > 0) {
this.errors = errors;
buttonCb(false);
return;
}
// Only proceed if valid
this.save(buttonCb);
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="fvFormIsValid"
@finish="saveOverride"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.vmImport.titles.basic')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="sourceProviderType"
:options="providerTypeOptions"
:label="t('harvester.addons.vmImport.fields.sourceProvider')"
:mode="mode"
:rules="[requiredRule]"
required
@update:value="onProviderTypeChange"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="selectedSourceKey"
:options="sourceOptions"
:label="t('harvester.addons.vmImport.fields.sourceCluster')"
:placeholder="sourceProviderType ? t('harvester.addons.vmImport.placeholders.selectCluster') : t('harvester.addons.vmImport.placeholders.selectProviderFirst')"
:disabled="!sourceProviderType"
:mode="mode"
:rules="fvGetAndReportPathRules('selectedSourceKey')"
required
@update:value="updateSource"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.virtualMachineName"
:label="t('harvester.addons.vmImport.fields.vmName')"
:placeholder="t('harvester.addons.vmImport.placeholders.matchSource')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.virtualMachineName')"
required
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.storageClass"
:options="storageClassOptions"
:label="t('harvester.addons.vmImport.fields.targetStorageClass')"
:mode="mode"
/>
</div>
</div>
</Tab>
<Tab
name="networking"
:label="t('harvester.addons.vmImport.titles.networking')"
:weight="2"
:error="isNetworkTabInvalid"
>
<div
v-for="(row, i) in value.spec.networkMapping"
:key="i"
class="network-row box mb-10"
>
<div class="row">
<div class="col span-4">
<LabeledInput
v-model:value="row.sourceNetwork"
:label="t('harvester.addons.vmImport.fields.sourceNetwork')"
:mode="mode"
:rules="[requiredRule]"
required
/>
</div>
<div class="col span-4">
<LabeledSelect
v-model:value="row.destinationNetwork"
:options="networkOptions"
:label="t('harvester.addons.vmImport.fields.destNetwork')"
:mode="mode"
:rules="[requiredRule]"
required
/>
</div>
<div class="col span-3">
<LabeledSelect
v-model:value="row.networkInterfaceModel"
:options="interfaceModelOptions"
:label="t('harvester.addons.vmImport.fields.interfaceModel')"
:mode="mode"
/>
</div>
<div class="col span-1 remove-btn-container">
<button
type="button"
class="btn role-link"
@click="removeNetworkMapping(i)"
>
{{ t('harvester.addons.vmImport.actions.remove') }}
</button>
</div>
</div>
</div>
<button
type="button"
class="btn role-secondary"
@click="addNetworkMapping"
>
{{ t('harvester.addons.vmImport.actions.addNetwork') }}
</button>
</Tab>
<Tab
name="advanced"
:label="t('harvester.addons.vmImport.titles.advanced')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.folder"
:label="t('harvester.addons.vmImport.fields.folder')"
:placeholder="t('harvester.addons.vmImport.placeholders.folderExample')"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.defaultDiskBusType"
:options="diskBusOptions"
:label="t('harvester.addons.vmImport.fields.diskBus')"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.defaultNetworkInterfaceModel"
:options="interfaceModelOptions"
:label="t('harvester.addons.vmImport.fields.defaultInterface')"
:mode="mode"
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<Checkbox
v-model:value="value.spec.skipPreflightChecks"
:label="t('harvester.addons.vmImport.fields.skipPreflight')"
:mode="mode"
/>
<Checkbox
v-model:value="value.spec.forcePowerOff"
:label="t('harvester.addons.vmImport.fields.forcePowerOff')"
:mode="mode"
class="mt-10"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
<style lang="scss" scoped>
.network-row {
border: 1px solid var(--border);
padding: 10px;
border-radius: var(--border-radius);
background: var(--body-bg);
}
.remove-btn-container {
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,302 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { RadioGroup } from '@components/Form/Radio';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { mapGetters } from 'vuex';
export default {
name: 'EditVmwareSource',
// Declare the event, fixes a console warning
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
RadioGroup,
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allSecrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET });
},
data() {
if (!this.value.spec) this.value.spec = {};
if (!this.value.spec.credentials) this.value.spec.credentials = {};
const initialMode = this.value.spec.credentials.name ? 'existing' : 'new';
return {
allSecrets: [],
authMode: initialMode,
newUsername: '',
newPassword: '',
newCaCert: '',
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.endpoint', rules: ['endpointRequired'] },
{ path: 'spec.dc', rules: ['dcRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
authModeOptions() {
return [
{ label: this.t('harvester.addons.vmImport.fields.createSecret'), value: 'new' },
{ label: this.t('harvester.addons.vmImport.fields.useSecret'), value: 'existing' }
];
},
secretOptions() {
const currentNamespace = this.value.metadata.namespace || 'default';
return this.allSecrets
.filter((s) => s.metadata.namespace === currentNamespace)
.map((s) => ({
label: s.nameDisplay,
value: s.metadata.name
}));
},
// Define custom rules for the FormValidation mixin
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
endpointRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.vmware.fields.endpoint') }) : undefined,
dcRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.vmware.fields.datacenter') }) : undefined,
};
},
isFormValid() {
if (!this.fvFormIsValid) {
return false;
}
if (this.authMode === 'new') {
if (!this.newUsername || !this.newPassword) return false;
} else {
if (!this.value.spec.credentials.name) return false;
}
return true;
}
},
methods: {
usernameRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.username') }) : undefined;
},
passwordRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.password') }) : undefined;
},
secretRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.selectSecret') }) : undefined;
},
async saveSource(buttonCb) {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
if (this.authMode === 'new') {
const secretName = `${ this.value.metadata.name }-creds-${ randomStr(4).toLowerCase() }`;
const namespace = this.value.metadata.namespace || 'default';
// Create the model with the correct Schema ID (SECRET)
const newSecret = await this.$store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace
}
});
// Use '_type' to set the Kubernetes 'type' field.
newSecret['_type'] = 'Opaque';
// base64 encode the data
newSecret['data'] = {
username: btoa(this.newUsername),
password: btoa(this.newPassword),
// Only include CA cert if the user provided one
caCert: this.newCaCert ? btoa(this.newCaCert) : undefined
};
await newSecret.save();
// Link the new secret to the Source
this.value.spec.credentials = {
name: secretName,
namespace
};
}
await this.save(buttonCb);
} catch (err) {
this.errors = [err];
buttonCb(false);
}
}
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="saveSource"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.vmImport.titles.basic')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.endpoint"
:label="t('harvester.addons.vmImport.vmware.fields.endpoint')"
:placeholder="t('harvester.addons.vmImport.vmware.placeholders.endpoint')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.endpoint')"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.dc"
:label="t('harvester.addons.vmImport.vmware.fields.datacenter')"
:placeholder="t('harvester.addons.vmImport.vmware.placeholders.datacenter')"
:tooltip="t('harvester.addons.vmImport.vmware.tooltips.datacenter')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.dc')"
required
/>
</div>
</div>
</Tab>
<Tab
name="auth"
:label="t('harvester.addons.vmImport.titles.auth')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-12">
<RadioGroup
v-model:value="authMode"
name="authMode"
:options="authModeOptions"
:mode="mode"
/>
</div>
</div>
<div v-if="authMode === 'new'">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newUsername"
:label="t('harvester.addons.vmImport.fields.username')"
:mode="mode"
:rules="[usernameRule]"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newPassword"
type="password"
:label="t('harvester.addons.vmImport.fields.password')"
:mode="mode"
:rules="[passwordRule]"
required
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="newCaCert"
type="multiline"
:label="t('harvester.addons.vmImport.fields.caCert')"
:placeholder="t('harvester.addons.vmImport.placeholders.caCert')"
:min-height="100"
:mode="mode"
/>
</div>
</div>
<div class="text-muted">
Note: A new Kubernetes Secret will be created to store these credentials.
</div>
</div>
<div v-if="authMode === 'existing'">
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.credentials.name"
:options="secretOptions"
:label="t('harvester.addons.vmImport.fields.selectSecret')"
:mode="mode"
:rules="[secretRule]"
required
/>
</div>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>