mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-10-04 21:26:08 +08:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07f2b02a9d | ||
|
|
35b6949cdc | ||
|
|
c577f589e7 | ||
|
|
b1387820ed | ||
|
|
45f72749f5 | ||
|
|
a65b3f443e | ||
|
|
21d415b5f3 | ||
|
|
7493fe2dfc | ||
|
|
e71d12f67f | ||
|
|
d3b77e2e41 | ||
|
|
73126b953c | ||
|
|
937afd9c9a | ||
|
|
3658caf067 | ||
|
|
a067751cba | ||
|
|
94566b2b54 | ||
|
|
d47bf1d712 | ||
|
|
495e80267d | ||
|
|
479c298f4a | ||
|
|
3fdde25d17 |
@@ -43,12 +43,10 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- setup-release-tag
|
- setup-release-tag
|
||||||
- check-version
|
- check-version
|
||||||
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@3b26a36bad555e5e2b8634b24823be29732f287c # master
|
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@9bf23dff6650116673346447bcf0b088e0edb27f # creators-pkg-v3.0.12
|
||||||
permissions:
|
permissions:
|
||||||
actions: write
|
|
||||||
contents: write
|
contents: write
|
||||||
deployments: write
|
pull-requests: write
|
||||||
pages: write
|
|
||||||
with:
|
with:
|
||||||
target_branch: gh-pages
|
target_branch: gh-pages
|
||||||
tagged_release: '${{ needs.setup-release-tag.outputs.release_tag }}'
|
tagged_release: '${{ needs.setup-release-tag.outputs.release_tag }}'
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "harvester-ui-extension",
|
"name": "harvester-ui-extension",
|
||||||
"version": "1.9.0-dev",
|
"version": "1.9.0-rc2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"merge": "2.1.1",
|
"merge": "2.1.1",
|
||||||
"node-forge": "1.4.0",
|
"node-forge": "1.4.0",
|
||||||
"nth-check": "2.1.1",
|
"nth-check": "2.1.1",
|
||||||
"qs": "6.15.2",
|
"qs": "6.15.3",
|
||||||
"roarr": "7.21.6",
|
"roarr": "7.21.6",
|
||||||
"semver": "7.8.5",
|
"semver": "7.8.5",
|
||||||
"@vue/cli-service/html-webpack-plugin": "^5.0.0"
|
"@vue/cli-service/html-webpack-plugin": "^5.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
networkEntries: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
storageEntries: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mappings-cell">
|
||||||
|
<div
|
||||||
|
v-for="(entry, idx) in networkEntries"
|
||||||
|
:key="'net-' + idx"
|
||||||
|
class="mapping-entry"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="icon icon-network"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ entry }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(entry, idx) in storageEntries"
|
||||||
|
:key="'stor-' + idx"
|
||||||
|
class="mapping-entry"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="icon icon-datastore"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ entry }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mappings-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, toRefs } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { randomStr } from '@shell/utils/string';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||||
|
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
|
||||||
|
import { isInternalStorageClass } from '../../utils/storage-class';
|
||||||
|
import MappingColumn from './MappingColumn.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
provider: { type: Object, default: null },
|
||||||
|
selectedVms: { type: Array, default: () => [] },
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
useAllProviderData: { type: Boolean, default: false },
|
||||||
|
existingNetworkMap: { type: Object, default: null },
|
||||||
|
existingStorageMap: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['ready']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const vms = ref([]);
|
||||||
|
const harvesterNetworks = ref([]);
|
||||||
|
const storageClasses = ref([]);
|
||||||
|
const allNetworkMaps = ref([]);
|
||||||
|
const allStorageMaps = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
const { networkEntries, storageEntries } = toRefs(props.stepData);
|
||||||
|
|
||||||
|
const NAMESPACE = FORKLIFT_NAMESPACE;
|
||||||
|
|
||||||
|
const harvesterNetworkOptions = computed(() => {
|
||||||
|
const options = [
|
||||||
|
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking'), value: 'pod' },
|
||||||
|
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.ignored'), value: 'ignored' },
|
||||||
|
];
|
||||||
|
|
||||||
|
harvesterNetworks.value.forEach((net) => {
|
||||||
|
const ns = net.metadata?.namespace || '';
|
||||||
|
const name = net.metadata?.name || net.id;
|
||||||
|
const fullName = ns ? `${ ns }/${ name }` : name;
|
||||||
|
|
||||||
|
options.push({ label: fullName, value: fullName });
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const storageClassOptions = computed(() => {
|
||||||
|
const options = storageClasses.value.filter((s) => !s.parameters?.backingImage).map((sc) => {
|
||||||
|
const value = sc.name;
|
||||||
|
let label = sc.isDefault ? `${ value } (${ t('generic.default') })` : value;
|
||||||
|
let disabled = false;
|
||||||
|
|
||||||
|
if (isInternalStorageClass(value)) {
|
||||||
|
label += ` (${ t('harvester.storage.internal.label') })`;
|
||||||
|
disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!options.find((o) => o.value === 'harvester-longhorn')) {
|
||||||
|
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyNetworkMapTargets = (mapSpec) => {
|
||||||
|
if (!mapSpec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
networkEntries.value.forEach((entry) => {
|
||||||
|
const match = mapSpec.find(
|
||||||
|
(m) => m.source?.id === entry.id || m.source?.name === entry.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match?.destination) {
|
||||||
|
const dest = match.destination;
|
||||||
|
|
||||||
|
if (dest.type === 'pod') {
|
||||||
|
entry.target = 'pod';
|
||||||
|
} else if (dest.type === 'ignored') {
|
||||||
|
entry.target = 'ignored';
|
||||||
|
} else if (dest.type === 'multus' && dest.name) {
|
||||||
|
entry.target = dest.namespace ? `${ dest.namespace }/${ dest.name }` : dest.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStorageMapTargets = (mapSpec) => {
|
||||||
|
if (!mapSpec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storageEntries.value.forEach((entry) => {
|
||||||
|
const match = mapSpec.find(
|
||||||
|
(m) => m.source?.id === entry.id || m.source?.name === entry.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match?.destination?.storageClass) {
|
||||||
|
entry.target = match.destination.storageClass;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDefaultNetworkMap = () => {
|
||||||
|
const defaultMap = allNetworkMaps.value.find(
|
||||||
|
(nm) => nm.metadata.name === `${ props.providerName }-network-map-default`
|
||||||
|
);
|
||||||
|
|
||||||
|
applyNetworkMapTargets(defaultMap?.spec?.map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDefaultStorageMap = () => {
|
||||||
|
const defaultMap = allStorageMaps.value.find(
|
||||||
|
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
|
||||||
|
);
|
||||||
|
|
||||||
|
applyStorageMapTargets(defaultMap?.spec?.map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
|
||||||
|
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
|
||||||
|
|
||||||
|
const canSave = computed(() => {
|
||||||
|
if (props.useAllProviderData) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
|
||||||
|
allNetworksMapped.value && allStorageMapped.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(canSave, (val) => {
|
||||||
|
emit('ready', val);
|
||||||
|
});
|
||||||
|
|
||||||
|
// After restore, check if ready
|
||||||
|
if (canSave.value) {
|
||||||
|
emit('ready', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildNetworkEntries = () => {
|
||||||
|
const networkMap = {};
|
||||||
|
|
||||||
|
vms.value.forEach((vm) => {
|
||||||
|
const vmName = vm.name || vm.id;
|
||||||
|
|
||||||
|
if (vm.networks && vm.networks.length > 0) {
|
||||||
|
vm.networks.forEach((net) => {
|
||||||
|
const netKey = net.id || net.name || 'default';
|
||||||
|
|
||||||
|
if (!networkMap[netKey]) {
|
||||||
|
networkMap[netKey] = {
|
||||||
|
name: net.name || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
id: net.id || '',
|
||||||
|
vlanId: net.vlanId || '',
|
||||||
|
target: '',
|
||||||
|
usedBy: [],
|
||||||
|
_key: `net-${ netKey }`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!networkMap[netKey].usedBy.includes(vmName)) {
|
||||||
|
networkMap[netKey].usedBy.push(vmName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
networkEntries.value = Object.values(networkMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStorageEntries = () => {
|
||||||
|
const datastoreMap = {};
|
||||||
|
|
||||||
|
vms.value.forEach((vm) => {
|
||||||
|
const vmName = vm.name || vm.id;
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
vm.disks.forEach((disk) => {
|
||||||
|
const ds = disk.datastore;
|
||||||
|
|
||||||
|
if (ds && ds.id) {
|
||||||
|
if (!datastoreMap[ds.id]) {
|
||||||
|
datastoreMap[ds.id] = {
|
||||||
|
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
id: ds.id,
|
||||||
|
type: ds.type || '',
|
||||||
|
capacity: 0,
|
||||||
|
target: '',
|
||||||
|
usedBy: [],
|
||||||
|
_key: `stor-${ ds.id }`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
datastoreMap[ds.id].capacity += disk.capacity || 0;
|
||||||
|
|
||||||
|
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
|
||||||
|
datastoreMap[ds.id].usedBy.push(vmName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
storageEntries.value = Object.values(datastoreMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNetworkEntriesFromProvider = (networksData) => {
|
||||||
|
networkEntries.value = (Array.isArray(networksData) ? networksData : []).map((net) => ({
|
||||||
|
name: net.name || net.id,
|
||||||
|
id: net.id || '',
|
||||||
|
vlanId: net.vlanId || '',
|
||||||
|
target: '',
|
||||||
|
usedBy: [],
|
||||||
|
_key: `net-${ net.id || net.name }`,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStorageEntriesFromProvider = (datastoresData) => {
|
||||||
|
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
|
||||||
|
name: ds.name || ds.id,
|
||||||
|
id: ds.id || '',
|
||||||
|
type: ds.type || '',
|
||||||
|
capacity: ds.capacity || 0,
|
||||||
|
target: '',
|
||||||
|
usedBy: [],
|
||||||
|
_key: `stor-${ ds.id || ds.name }`,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatStorageDetail = (entry) => {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (entry.type) {
|
||||||
|
parts.push(entry.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.capacity) {
|
||||||
|
const gb = entry.capacity / (1024 * 1024 * 1024);
|
||||||
|
|
||||||
|
if (gb >= 1024) {
|
||||||
|
parts.push(t('harvester.addons.vmMigration.generic.memoryTb', { value: (gb / 1024).toFixed(1) }));
|
||||||
|
} else {
|
||||||
|
parts.push(t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(gb) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' • ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNetworkMapSpec = (providerRef) => ({
|
||||||
|
map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
|
||||||
|
provider: providerRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildStorageMapSpec = (providerRef) => ({
|
||||||
|
map: buildStorageMapEntries(storageEntries.value),
|
||||||
|
provider: providerRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveAndReturn = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
const providerRef = {
|
||||||
|
source: {
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: props.providerName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: 'host',
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update existing maps in place (edit mode)
|
||||||
|
if (props.existingNetworkMap && props.existingStorageMap) {
|
||||||
|
props.existingNetworkMap.spec = buildNetworkMapSpec(providerRef);
|
||||||
|
await props.existingNetworkMap.save();
|
||||||
|
|
||||||
|
props.existingStorageMap.spec = buildStorageMapSpec(providerRef);
|
||||||
|
await props.existingStorageMap.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
networkMapName: props.existingNetworkMap.metadata.name,
|
||||||
|
storageMapName: props.existingStorageMap.metadata.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerOwnerRef = props.provider ? [{
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: props.provider.metadata.name,
|
||||||
|
uid: props.provider.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
}] : [];
|
||||||
|
|
||||||
|
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
metadata: {
|
||||||
|
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
ownerReferences: providerOwnerRef,
|
||||||
|
},
|
||||||
|
spec: buildNetworkMapSpec(providerRef),
|
||||||
|
});
|
||||||
|
|
||||||
|
await networkMap.save();
|
||||||
|
|
||||||
|
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
metadata: {
|
||||||
|
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
ownerReferences: providerOwnerRef,
|
||||||
|
},
|
||||||
|
spec: buildStorageMapSpec(providerRef),
|
||||||
|
});
|
||||||
|
|
||||||
|
await storageMap.save();
|
||||||
|
|
||||||
|
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ saveMappings: saveAndReturn });
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
harvesterNetworks.value = await store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT });
|
||||||
|
} catch (e) {
|
||||||
|
harvesterNetworks.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storageClasses.value = await store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
|
||||||
|
} catch (e) {
|
||||||
|
storageClasses.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
allNetworkMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
|
||||||
|
} catch (e) {
|
||||||
|
allNetworkMaps.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
allStorageMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
|
||||||
|
} catch (e) {
|
||||||
|
allStorageMaps.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (networkEntries.value.length === 0 || storageEntries.value.length === 0) {
|
||||||
|
try {
|
||||||
|
const providerUid = props.provider?.metadata?.uid;
|
||||||
|
const providerType = props.provider?.spec?.type || 'vsphere';
|
||||||
|
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
|
||||||
|
`v1/harvester/providers/${ providerType }/${ providerUid }`
|
||||||
|
);
|
||||||
|
|
||||||
|
const [networksData, datastoresData] = await Promise.all([
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores?detail=1` }).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (props.useAllProviderData) {
|
||||||
|
if (networkEntries.value.length === 0) {
|
||||||
|
buildNetworkEntriesFromProvider(networksData);
|
||||||
|
}
|
||||||
|
if (storageEntries.value.length === 0) {
|
||||||
|
buildStorageEntriesFromProvider(datastoresData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vms.value = props.selectedVms;
|
||||||
|
|
||||||
|
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
|
||||||
|
map[n.id] = n.name;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
|
||||||
|
map[d.id] = { name: d.name, type: d.type || '' };
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
vms.value = vms.value.map((vm) => {
|
||||||
|
const resolved = { ...vm };
|
||||||
|
|
||||||
|
if (resolved.networks) {
|
||||||
|
resolved.networks = resolved.networks.map((n) => ({
|
||||||
|
...n,
|
||||||
|
name: n.name || networkNameMap[n.id] || n.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.disks) {
|
||||||
|
resolved.disks = resolved.disks.map((d) => {
|
||||||
|
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
datastore: d.datastore ? {
|
||||||
|
...d.datastore,
|
||||||
|
name: d.datastore.name || dsInfo?.name || d.datastore.id,
|
||||||
|
type: dsInfo?.type || '',
|
||||||
|
} : d.datastore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (networkEntries.value.length === 0) {
|
||||||
|
buildNetworkEntries();
|
||||||
|
}
|
||||||
|
if (storageEntries.value.length === 0) {
|
||||||
|
buildStorageEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedResolveDetails')];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
|
||||||
|
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
|
||||||
|
|
||||||
|
if (!hasExistingNetworkTargets) {
|
||||||
|
if (props.existingNetworkMap?.spec?.map) {
|
||||||
|
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
|
||||||
|
} else {
|
||||||
|
applyDefaultNetworkMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasExistingStorageTargets) {
|
||||||
|
if (props.existingStorageMap?.spec?.map) {
|
||||||
|
applyStorageMapTargets(props.existingStorageMap.spec.map);
|
||||||
|
} else {
|
||||||
|
applyDefaultStorageMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="configure-mappings"
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized line-height-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
|
||||||
|
</p>
|
||||||
|
<div class="mappings-columns">
|
||||||
|
<MappingColumn
|
||||||
|
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
|
||||||
|
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
|
||||||
|
:entries="networkEntries"
|
||||||
|
:options="harvesterNetworkOptions"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
|
||||||
|
:show-used-by="!useAllProviderData"
|
||||||
|
:clearable="useAllProviderData"
|
||||||
|
>
|
||||||
|
<template #source-detail="{ entry }">
|
||||||
|
<span class="source-detail text-deemphasized">{{ t('harvester.addons.vmMigration.generic.vlan', { id: entry.vlanId || '0' }) }}</span>
|
||||||
|
</template>
|
||||||
|
</MappingColumn>
|
||||||
|
|
||||||
|
<MappingColumn
|
||||||
|
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
|
||||||
|
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
|
||||||
|
:entries="storageEntries"
|
||||||
|
:options="storageClassOptions"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
|
||||||
|
:show-used-by="!useAllProviderData"
|
||||||
|
:clearable="useAllProviderData"
|
||||||
|
>
|
||||||
|
<template #source-detail="{ entry }">
|
||||||
|
<span
|
||||||
|
v-if="entry.type || entry.capacity"
|
||||||
|
class="source-detail text-deemphasized"
|
||||||
|
>{{ formatStorageDetail(entry) }}</span>
|
||||||
|
</template>
|
||||||
|
</MappingColumn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mappings-columns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
|
||||||
|
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
|
||||||
|
gap: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configure-mappings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-detail {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, toRefs } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import { Checkbox } from '@components/Form/Checkbox';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import AsyncButton from '@shell/components/AsyncButton';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { SECRET } from '@shell/config/types';
|
||||||
|
import { randomStr } from '@shell/utils/string';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||||
|
import { decodeSecretValue } from '../../utils/forklift';
|
||||||
|
|
||||||
|
const CREATE_NEW = '__create_new__';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
createOnly: { type: Boolean, default: false },
|
||||||
|
editMode: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const allProviders = ref([]);
|
||||||
|
const allSecrets = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
const testBtnRef = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const testing = ref(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedProvider,
|
||||||
|
providerName,
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
skipTlsVerify,
|
||||||
|
testPassed,
|
||||||
|
testResult,
|
||||||
|
testError,
|
||||||
|
createdProvider,
|
||||||
|
createdSecret,
|
||||||
|
} = toRefs(props.stepData);
|
||||||
|
|
||||||
|
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
|
||||||
|
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
|
||||||
|
|
||||||
|
const providerOptions = computed(() => {
|
||||||
|
const options = [
|
||||||
|
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
|
||||||
|
];
|
||||||
|
|
||||||
|
allProviders.value.forEach((p) => {
|
||||||
|
if (!p.spec?.url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretRef = p.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef) {
|
||||||
|
const secret = allSecrets.value.find(
|
||||||
|
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secret?.data?.user || !secret?.data?.password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
label: p.metadata.name,
|
||||||
|
value: p.metadata.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the provider selection changes, populate or clear the fields
|
||||||
|
watch(selectedProvider, (val) => {
|
||||||
|
testPassed.value = false;
|
||||||
|
testResult.value = null;
|
||||||
|
testError.value = null;
|
||||||
|
|
||||||
|
if (val === CREATE_NEW) {
|
||||||
|
providerName.value = '';
|
||||||
|
url.value = '';
|
||||||
|
username.value = '';
|
||||||
|
password.value = '';
|
||||||
|
skipTlsVerify.value = false;
|
||||||
|
createdProvider.value = null;
|
||||||
|
createdSecret.value = null;
|
||||||
|
} else {
|
||||||
|
const provider = allProviders.value.find(
|
||||||
|
(p) => p.metadata.name === val && p.metadata.namespace === FORKLIFT_NAMESPACE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (provider) {
|
||||||
|
providerName.value = provider.metadata.name;
|
||||||
|
url.value = provider.spec?.url || '';
|
||||||
|
|
||||||
|
const secretRef = provider.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef) {
|
||||||
|
const secret = allSecrets.value.find(
|
||||||
|
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secret?.data) {
|
||||||
|
username.value = decodeSecretValue(secret.data.user);
|
||||||
|
password.value = decodeSecretValue(secret.data.password);
|
||||||
|
skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createdProvider.value = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset test state when any field changes (only for create new)
|
||||||
|
watch([providerName, url, username, password], () => {
|
||||||
|
if (!isExistingProvider.value) {
|
||||||
|
testPassed.value = false;
|
||||||
|
testResult.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit ready/complete when testPassed changes
|
||||||
|
watch(testPassed, (val) => {
|
||||||
|
emit('ready', val);
|
||||||
|
if (val) {
|
||||||
|
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Emit form-valid when form validity changes
|
||||||
|
watch(isFormValid, (val) => {
|
||||||
|
emit('form-valid', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(testing, (val) => {
|
||||||
|
emit('testing', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const pollProviderReady = async(name) => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
const namespace = FORKLIFT_NAMESPACE;
|
||||||
|
const maxAttempts = 15;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
const refreshed = await store.dispatch(`${ inStore }/find`, {
|
||||||
|
type: HCI.FORKLIFT_PROVIDER,
|
||||||
|
id: `${ namespace }/${ name }`,
|
||||||
|
opt: { force: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const conditions = refreshed?.status?.conditions || [];
|
||||||
|
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
|
||||||
|
const readyCondition = conditions.find((c) => c.type === 'Ready');
|
||||||
|
|
||||||
|
if (connectionCondition) {
|
||||||
|
if (connectionCondition.status === 'True') {
|
||||||
|
return { connected: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connected: false, errorMsg: connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readyCondition) {
|
||||||
|
if (readyCondition.status === 'True') {
|
||||||
|
return { connected: true };
|
||||||
|
} else if (readyCondition.status === 'False') {
|
||||||
|
return { connected: false, errorMsg: readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connected: false, errorMsg: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePollResult = ({ connected, errorMsg }, buttonCb) => {
|
||||||
|
if (connected) {
|
||||||
|
testPassed.value = true;
|
||||||
|
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(true);
|
||||||
|
} else {
|
||||||
|
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupCreatedResources = async() => {
|
||||||
|
if (createdProvider.value) {
|
||||||
|
try {
|
||||||
|
await createdProvider.value.remove();
|
||||||
|
} catch (e) {}
|
||||||
|
createdProvider.value = null;
|
||||||
|
createdSecret.value = null;
|
||||||
|
} else if (createdSecret.value) {
|
||||||
|
try {
|
||||||
|
await createdSecret.value.remove();
|
||||||
|
} catch (e) {}
|
||||||
|
createdSecret.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConnection = async(buttonCb) => {
|
||||||
|
testResult.value = null;
|
||||||
|
testError.value = null;
|
||||||
|
testing.value = true;
|
||||||
|
|
||||||
|
if (!providerName.value || !url.value || !username.value || !password.value) {
|
||||||
|
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
// Edit mode: update existing provider URL + secret, then poll
|
||||||
|
if (props.editMode && createdProvider.value) {
|
||||||
|
try {
|
||||||
|
createdProvider.value.spec.url = url.value;
|
||||||
|
await createdProvider.value.save();
|
||||||
|
|
||||||
|
const secretRef = createdProvider.value.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef && createdSecret.value) {
|
||||||
|
createdSecret.value.data = {
|
||||||
|
user: btoa(username.value),
|
||||||
|
password: btoa(password.value),
|
||||||
|
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
|
||||||
|
url: btoa(url.value),
|
||||||
|
};
|
||||||
|
await createdSecret.value.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
|
||||||
|
} catch (err) {
|
||||||
|
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
|
||||||
|
if (isExistingProvider.value) {
|
||||||
|
try {
|
||||||
|
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
|
||||||
|
} catch (err) {
|
||||||
|
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For new providers, create provider + secret then poll
|
||||||
|
try {
|
||||||
|
await cleanupCreatedResources();
|
||||||
|
|
||||||
|
const namespace = FORKLIFT_NAMESPACE;
|
||||||
|
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
|
||||||
|
|
||||||
|
const provider = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_PROVIDER,
|
||||||
|
metadata: {
|
||||||
|
name: providerName.value,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
type: 'vsphere',
|
||||||
|
url: url.value,
|
||||||
|
secret: {
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.save();
|
||||||
|
createdProvider.value = provider;
|
||||||
|
|
||||||
|
const newSecret = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: SECRET,
|
||||||
|
metadata: {
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
labels: { 'ui.forklift/created-for-resource-type': 'forklift.konveyor.io.provider' },
|
||||||
|
ownerReferences: [
|
||||||
|
{
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: provider.metadata.name,
|
||||||
|
uid: provider.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newSecret['_type'] = 'Opaque';
|
||||||
|
newSecret['data'] = {
|
||||||
|
user: btoa(username.value),
|
||||||
|
password: btoa(password.value),
|
||||||
|
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
|
||||||
|
url: btoa(url.value),
|
||||||
|
};
|
||||||
|
|
||||||
|
await newSecret.save();
|
||||||
|
createdSecret.value = newSecret;
|
||||||
|
|
||||||
|
const result = await pollProviderReady(providerName.value);
|
||||||
|
|
||||||
|
if (!result.connected) {
|
||||||
|
await cleanupCreatedResources();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePollResult(result, buttonCb);
|
||||||
|
} catch (err) {
|
||||||
|
await cleanupCreatedResources();
|
||||||
|
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||||
|
|
||||||
|
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
|
||||||
|
type: SECRET,
|
||||||
|
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadProviders')];
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
const clickTestButton = () => {
|
||||||
|
testBtnRef.value?.$el?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ testConnection, clickTestButton });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="configure-provider-step"
|
||||||
|
>
|
||||||
|
<p class="text-deemphasized line-height-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="configure-provider-step-content">
|
||||||
|
<h3 class="table-title m-0">
|
||||||
|
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Banner
|
||||||
|
class="requirements-banner m-0"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
|
<div class="requirements-banner-content">
|
||||||
|
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
|
||||||
|
<br>
|
||||||
|
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
|
||||||
|
<div class="configure-provider-step-form">
|
||||||
|
<div v-if="!createOnly && !editMode">
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="selectedProvider"
|
||||||
|
required
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
|
||||||
|
:options="providerOptions"
|
||||||
|
:reduce="(opt) => opt.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isExistingProvider || editMode"
|
||||||
|
>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="providerName"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.name')"
|
||||||
|
:disabled="editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="url"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized mt-5">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="username"
|
||||||
|
:label="t('harvester.addons.vmMigration.fields.username')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
:label="t('harvester.addons.vmMigration.fields.password')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
v-model:value="skipTlsVerify"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized ml-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<AsyncButton
|
||||||
|
ref="testBtnRef"
|
||||||
|
mode="test"
|
||||||
|
:disabled="!isFormValid"
|
||||||
|
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
|
||||||
|
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
|
||||||
|
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
|
||||||
|
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
|
||||||
|
:action-color="'role-secondary'"
|
||||||
|
:waiting-color="'role-disabled'"
|
||||||
|
@click="testConnection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Banner
|
||||||
|
v-if="testResult"
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
{{ testResult }}
|
||||||
|
</Banner>
|
||||||
|
<Banner
|
||||||
|
v-if="testError"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{{ testError }}
|
||||||
|
</Banner>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{{ err }}
|
||||||
|
</Banner>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.requirements-banner {
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
.requirements-banner-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configure-provider-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configure-provider-step-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.configure-provider-step-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.checkbox-label) {
|
||||||
|
color: var(--body-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { RcItemCard } from '@components/RcItemCard';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, required: true },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
entries: { type: Array, default: () => [] },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
placeholder: { type: String, default: '' },
|
||||||
|
showUsedBy: { type: Boolean, default: false },
|
||||||
|
clearable: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectOptions = computed(() => {
|
||||||
|
if (!props.clearable) {
|
||||||
|
return props.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
|
||||||
|
value: null,
|
||||||
|
kind: 'highlighted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'divider',
|
||||||
|
disabled: true,
|
||||||
|
kind: 'divider',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${ props.placeholder }:`,
|
||||||
|
disabled: true,
|
||||||
|
kind: 'title',
|
||||||
|
},
|
||||||
|
...props.options,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mapping-column">
|
||||||
|
<div>
|
||||||
|
<h3 class="mapping-section-title">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
v-if="description"
|
||||||
|
class="text-deemphasized line-height-20"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RcItemCard
|
||||||
|
v-for="entry in entries"
|
||||||
|
:id="entry._key"
|
||||||
|
:key="entry._key"
|
||||||
|
:variant="'small'"
|
||||||
|
:header="{}"
|
||||||
|
class="bg-light-gray"
|
||||||
|
>
|
||||||
|
<template #item-card-content>
|
||||||
|
<div class="card-content-column">
|
||||||
|
<div class="card-content-row">
|
||||||
|
<div class="mapping-source">
|
||||||
|
<span class="source-name">{{ entry.name }}</span>
|
||||||
|
<slot
|
||||||
|
name="source-detail"
|
||||||
|
:entry="entry"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
|
||||||
|
<i
|
||||||
|
class="icon icon-right-arrow-alt"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-target">
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="entry.target"
|
||||||
|
:options="selectOptions"
|
||||||
|
:placeholder="placeholder+'...'"
|
||||||
|
:searchable="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
|
||||||
|
<span class="used-by">
|
||||||
|
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</RcItemCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mapping-column {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
:deep(.item-card-header) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-section-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-source {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 2;
|
||||||
|
min-width: 100px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
.source-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-detail {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.used-by {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-arrow {
|
||||||
|
font-size: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-target {
|
||||||
|
flex: 3;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-light-gray {
|
||||||
|
background-color: var(--category-active) !important;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import { RcItemCard } from '@components/RcItemCard';
|
||||||
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import MappingsCell from '../MappingsCell';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||||
|
import {
|
||||||
|
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
|
||||||
|
} from '../../utils/forklift';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
provider: { type: Object, default: null },
|
||||||
|
selectedVms: { type: Array, default: () => [] },
|
||||||
|
mappingEntries: { type: Object, default: null },
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['ready']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const vms = ref([]);
|
||||||
|
const networkMappings = ref([]);
|
||||||
|
const storageMappings = ref([]);
|
||||||
|
const planName = ref('');
|
||||||
|
const errors = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
// Restore persisted state
|
||||||
|
planName.value = props.stepData.planName;
|
||||||
|
|
||||||
|
const NAMESPACE = FORKLIFT_NAMESPACE;
|
||||||
|
const TARGET_NAMESPACE = 'default';
|
||||||
|
|
||||||
|
watch(planName, (val) => {
|
||||||
|
props.stepData.planName = val;
|
||||||
|
emit('ready', !!val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
|
||||||
|
|
||||||
|
const totalMemoryGB = computed(() => {
|
||||||
|
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
|
||||||
|
|
||||||
|
return mbToGB(totalMB);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalStorageGB = computed(() => {
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
vms.value.forEach((vm) => {
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
totalBytes += vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bytesToGB(totalBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
const vmCards = computed(() => {
|
||||||
|
return vms.value.map((vm) => {
|
||||||
|
const cpus = vm.cpuCount || vm.numCPU || 0;
|
||||||
|
const memMB = vm.memoryMB || vm.memory || 0;
|
||||||
|
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||||
|
|
||||||
|
let totalDiskBytes = 0;
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||||
|
const os = vm.guestName || vm.guestOS || vm.os || '-';
|
||||||
|
|
||||||
|
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
|
||||||
|
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: vm.id,
|
||||||
|
name: vm.name || vm.id,
|
||||||
|
os,
|
||||||
|
cpus,
|
||||||
|
memGB,
|
||||||
|
diskDisplay,
|
||||||
|
networkMappings: vmNetworkMappings,
|
||||||
|
storageMappings: vmStorageMappings,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const startMigrationAction = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
const providerRef = {
|
||||||
|
source: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'Provider',
|
||||||
|
name: props.providerName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'Provider',
|
||||||
|
name: 'host',
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const networkMapName = `${ planName.value }-network-map`;
|
||||||
|
const storageMapName = `${ planName.value }-storage-map`;
|
||||||
|
|
||||||
|
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
metadata: { name: networkMapName, namespace: NAMESPACE },
|
||||||
|
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NAMESPACE), provider: providerRef },
|
||||||
|
});
|
||||||
|
|
||||||
|
await networkMap.save();
|
||||||
|
|
||||||
|
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
metadata: { name: storageMapName, namespace: NAMESPACE },
|
||||||
|
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
|
||||||
|
});
|
||||||
|
|
||||||
|
await storageMap.save();
|
||||||
|
|
||||||
|
const plan = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_PLAN,
|
||||||
|
metadata: { name: planName.value, namespace: NAMESPACE },
|
||||||
|
spec: {
|
||||||
|
provider: providerRef,
|
||||||
|
map: {
|
||||||
|
network: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'NetworkMap',
|
||||||
|
name: networkMapName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'StorageMap',
|
||||||
|
name: storageMapName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
targetNamespace: TARGET_NAMESPACE,
|
||||||
|
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
|
||||||
|
warm: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await plan.save();
|
||||||
|
|
||||||
|
const planOwnerRef = {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'Plan',
|
||||||
|
name: plan.metadata.name,
|
||||||
|
uid: plan.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
networkMap.metadata.ownerReferences = [planOwnerRef];
|
||||||
|
await networkMap.save();
|
||||||
|
storageMap.metadata.ownerReferences = [planOwnerRef];
|
||||||
|
await storageMap.save();
|
||||||
|
|
||||||
|
// Kick off the first migration through the model so the Migration payload
|
||||||
|
// lives in a single place (also reused by the dashboard start/restart action).
|
||||||
|
await plan.startMigration();
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
vms.value = props.selectedVms;
|
||||||
|
|
||||||
|
if (props.mappingEntries) {
|
||||||
|
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
|
||||||
|
source: entry.name || entry.id,
|
||||||
|
target: entry.target || '',
|
||||||
|
usedBy: entry.usedBy || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
|
||||||
|
source: entry.name || entry.id,
|
||||||
|
target: entry.target || '',
|
||||||
|
usedBy: entry.usedBy || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
defineExpose({ startMigration: startMigrationAction });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="review-migration"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="review-migration-content"
|
||||||
|
>
|
||||||
|
<!-- Migration Details Summary -->
|
||||||
|
<div class="migration-details">
|
||||||
|
<h3 class="section-title m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
|
||||||
|
</h3>
|
||||||
|
<div class="span-9">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="planName"
|
||||||
|
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="details-grid span-9">
|
||||||
|
<div class="detail-column">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
|
||||||
|
<span class="detail-value detail-value-large">{{ vms.length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-column">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-value">{{ providerName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-column">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.targetNamespace') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-value">{{ TARGET_NAMESPACE }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-column">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
|
||||||
|
<span class="detail-value">{{ totalVCpu }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
|
||||||
|
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
|
||||||
|
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-column grid-column-2">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-value">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Virtual Machines -->
|
||||||
|
<div class="vm-section">
|
||||||
|
<h3 class="section-title m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="vm-cards-grid">
|
||||||
|
<RcItemCard
|
||||||
|
v-for="vm in vmCards"
|
||||||
|
:id="vm.id"
|
||||||
|
:key="vm.id"
|
||||||
|
:variant="'small'"
|
||||||
|
:header="{ title: { text: vm.name } }"
|
||||||
|
>
|
||||||
|
<template #item-card-content>
|
||||||
|
<div class="vm-card-content">
|
||||||
|
<div class="vm-card-specs">
|
||||||
|
<span class="vm-os text-deemphasized">{{ vm.os }}</span>
|
||||||
|
<span class="vm-resources">
|
||||||
|
<i
|
||||||
|
class="icon icon-disk"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} • {{ vm.memGB }} • {{ vm.diskDisplay }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<MappingsCell
|
||||||
|
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)"
|
||||||
|
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</RcItemCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cold Migration Warning -->
|
||||||
|
<Banner
|
||||||
|
color="warning"
|
||||||
|
class="m-0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="banner-title">{{ t('harvester.addons.vmMigration.reviewMigration.warningTitle') }}</span><br>
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.warningMessage') }}
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
|
||||||
|
<!-- Error banner -->
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.review-migration {
|
||||||
|
gap: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-migration-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 12px clamp(64px, 8vw, 128px);
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&.grid-column-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&.detail-value-large {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-card-specs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.vm-os {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-resources {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ref, computed, watch, nextTick, onBeforeUnmount, toRefs
|
||||||
|
} from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import SortableTable from '@shell/components/SortableTable';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import { BadgeState } from '@components/BadgeState';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { bytesToGB, mbToGB } from '../../utils/forklift';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
provider: { type: Object, default: null },
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['complete', 'loading']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
|
||||||
|
|
||||||
|
const selectedVMs = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const networkMap = ref({});
|
||||||
|
const datastoreMap = ref({});
|
||||||
|
const sortableTableRef = ref(null);
|
||||||
|
const allVMsSelected = ref(false);
|
||||||
|
const errors = ref([]);
|
||||||
|
let skipNextSelectionEvent = false;
|
||||||
|
|
||||||
|
const lastFetchedAt = ref(null);
|
||||||
|
const now = ref(Date.now());
|
||||||
|
|
||||||
|
const formatElapsed = (ms) => {
|
||||||
|
const totalMinutes = Math.floor(ms / 60000);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
if (hours > 0 && minutes > 0) {
|
||||||
|
return t('harvester.addons.vmMigration.generic.elapsed.hoursAndMinutes', { hours, minutes });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return t('harvester.addons.vmMigration.generic.elapsed.hours', { hours });
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('harvester.addons.vmMigration.generic.elapsed.minutes', { minutes: Math.max(1, minutes) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastSyncedTime = computed(() => {
|
||||||
|
if (!lastFetchedAt.value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatElapsed(now.value - lastFetchedAt.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowTimer = setInterval(() => {
|
||||||
|
now.value = Date.now();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(nowTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore selection state from stepData
|
||||||
|
if (selectedVMIds.value.size > 0) {
|
||||||
|
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||||
|
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmCount = computed(() => discoveredVMs.value.length);
|
||||||
|
const selectedCount = computed(() => selectedVMs.value.length);
|
||||||
|
|
||||||
|
const showSelectAllBanner = computed(() => {
|
||||||
|
if (allVMsSelected.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagedRows = sortableTableRef.value?.pagedRows || [];
|
||||||
|
|
||||||
|
if (pagedRows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPageSelected = pagedRows.every((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
return allPageSelected && tableRows.value.length > pagedRows.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
name: 'vmName',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
|
||||||
|
value: 'vmName',
|
||||||
|
sort: ['vmName'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'os',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
|
||||||
|
value: 'os',
|
||||||
|
sort: ['os'],
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resources',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
|
||||||
|
value: 'resources',
|
||||||
|
sort: false,
|
||||||
|
width: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'powerState',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
|
||||||
|
value: 'powerState',
|
||||||
|
sort: ['powerState'],
|
||||||
|
width: 130,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'network',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
|
||||||
|
value: 'network',
|
||||||
|
sort: ['network'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'datastore',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
|
||||||
|
value: 'datastore',
|
||||||
|
sort: ['datastore'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const buildTableRows = () => {
|
||||||
|
return discoveredVMs.value.map((vm) => {
|
||||||
|
const cpus = vm.cpuCount || vm.numCPU || '-';
|
||||||
|
const memMB = vm.memoryMB || vm.memory || 0;
|
||||||
|
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||||
|
|
||||||
|
let totalDiskBytes = 0;
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||||
|
const rawPowerState = vm.powerState || vm.status?.phase || '-';
|
||||||
|
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
let networks = [];
|
||||||
|
|
||||||
|
if (vm.networks && vm.networks.length > 0) {
|
||||||
|
networks = vm.networks.map((n) => ({
|
||||||
|
name: n.name || networkMap.value[n.id] || n.id || n,
|
||||||
|
id: n.id || '',
|
||||||
|
vlanId: n.vlanId || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const datastores = [];
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
vm.disks.forEach((d) => {
|
||||||
|
const dsId = d.datastore?.id || d.datastore?.name;
|
||||||
|
|
||||||
|
if (dsId && !seen.has(dsId)) {
|
||||||
|
seen.add(dsId);
|
||||||
|
datastores.push({
|
||||||
|
name: d.datastore?.name || datastoreMap.value[dsId] || dsId,
|
||||||
|
id: d.datastore?.id || '',
|
||||||
|
type: d.datastore?.type || '',
|
||||||
|
capacity: d.capacity || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_original: vm,
|
||||||
|
_key: vm.id || vm.vmId || vm.metadata?.name,
|
||||||
|
vmName: vm.name || vm.metadata?.name || '-',
|
||||||
|
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
|
||||||
|
os: vm.guestName || vm.guestOS || vm.os || '-',
|
||||||
|
resourcesDisplay: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
|
||||||
|
resourcesSub: `${ memGB } • ${ diskDisplay }`,
|
||||||
|
powerState,
|
||||||
|
powerStateClass: powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off',
|
||||||
|
networks,
|
||||||
|
network: networks.map((n) => n.name).join(', ') || '-',
|
||||||
|
datastores,
|
||||||
|
datastore: datastores.map((d) => d.name).join(', ') || '-',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelect = (rows) => {
|
||||||
|
if (skipNextSelectionEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPageRows = sortableTableRef.value?.pagedRows || [];
|
||||||
|
const selectedKeys = new Set(rows.map((r) => r._key));
|
||||||
|
|
||||||
|
currentPageRows.forEach((row) => {
|
||||||
|
const vmId = row._original?.id;
|
||||||
|
|
||||||
|
if (selectedKeys.has(row._key)) {
|
||||||
|
selectedVMIds.value.add(vmId);
|
||||||
|
} else {
|
||||||
|
selectedVMIds.value.delete(vmId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||||
|
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||||
|
|
||||||
|
emit('complete', { selectedVMs: selectedVMs.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
allVMsSelected.value = false;
|
||||||
|
selectedVMIds.value = new Set();
|
||||||
|
selectedVMs.value = [];
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
table.clearSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllVMs = () => {
|
||||||
|
allVMsSelected.value = true;
|
||||||
|
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
|
||||||
|
selectedVMs.value = discoveredVMs.value.slice();
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sortableTableRef.value?.page,
|
||||||
|
() => {
|
||||||
|
skipNextSelectionEvent = true;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
if (rowsToReselect.length > 0) {
|
||||||
|
table.update(rowsToReselect, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
skipNextSelectionEvent = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshing = ref(false);
|
||||||
|
|
||||||
|
const isLoading = computed(() => loading.value || refreshing.value);
|
||||||
|
|
||||||
|
watch(isLoading, (val) => {
|
||||||
|
emit('loading', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const fetchVMs = async() => {
|
||||||
|
if (!props.provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
const providerUid = props.provider.metadata.uid;
|
||||||
|
const providerType = props.provider.spec?.type || 'vsphere';
|
||||||
|
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
|
||||||
|
`v1/harvester/providers/${ providerType }/${ providerUid }`
|
||||||
|
);
|
||||||
|
|
||||||
|
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/vms` }),
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores` }).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (Array.isArray(vmsResp)) {
|
||||||
|
discoveredVMs.value = vmsResp;
|
||||||
|
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
|
||||||
|
discoveredVMs.value = vmsResp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
|
||||||
|
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
|
||||||
|
|
||||||
|
networkMap.value = networks.reduce((map, n) => {
|
||||||
|
map[n.id] = n.name;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
datastoreMap.value = datastores.reduce((map, d) => {
|
||||||
|
map[d.id] = d.name;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
lastFetchedAt.value = Date.now();
|
||||||
|
tableRows.value = buildTableRows();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshVMs = async() => {
|
||||||
|
refreshing.value = true;
|
||||||
|
skipNextSelectionEvent = true;
|
||||||
|
|
||||||
|
const previousSelectedIds = new Set(selectedVMIds.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
errors.value = [];
|
||||||
|
await fetchVMs();
|
||||||
|
} catch (e) {
|
||||||
|
discoveredVMs.value = [];
|
||||||
|
tableRows.value = [];
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedRefreshVms')];
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedVMIds.value = new Set(
|
||||||
|
discoveredVMs.value
|
||||||
|
.filter((vm) => previousSelectedIds.has(vm.id))
|
||||||
|
.map((vm) => vm.id)
|
||||||
|
);
|
||||||
|
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||||
|
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
|
||||||
|
|
||||||
|
props.stepData.discoveredVMs = discoveredVMs.value;
|
||||||
|
props.stepData.selectedVMIds = selectedVMIds.value;
|
||||||
|
props.stepData.tableRows = tableRows.value;
|
||||||
|
emit('complete', { selectedVMs: selectedVMs.value });
|
||||||
|
refreshing.value = false;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
if (rowsToReselect.length > 0) {
|
||||||
|
table.update(rowsToReselect, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skipNextSelectionEvent = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
if (discoveredVMs.value.length > 0) {
|
||||||
|
if (!lastFetchedAt.value) {
|
||||||
|
lastFetchedAt.value = Date.now();
|
||||||
|
}
|
||||||
|
tableRows.value = buildTableRows();
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchVMs();
|
||||||
|
} catch (e) {
|
||||||
|
discoveredVMs.value = [];
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadVms')];
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit initial complete if VMs are already selected (restored from stepData)
|
||||||
|
if (selectedVMs.value.length > 0) {
|
||||||
|
emit('complete', { selectedVMs: selectedVMs.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading || refreshing" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="select-vms-step"
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized line-height-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
|
||||||
|
<router-link
|
||||||
|
v-if="provider"
|
||||||
|
class="provider-link"
|
||||||
|
:to="provider._detailLocation"
|
||||||
|
>
|
||||||
|
{{ providerName }}
|
||||||
|
</router-link>
|
||||||
|
<br>
|
||||||
|
<span
|
||||||
|
v-if="lastSyncedTime"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
class="text-bold"
|
||||||
|
:class="{ disabled: refreshing }"
|
||||||
|
@click.prevent="refreshVMs"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<!-- Discovered VMs table -->
|
||||||
|
<SortableTable
|
||||||
|
ref="sortableTableRef"
|
||||||
|
:rows="tableRows"
|
||||||
|
:headers="headers"
|
||||||
|
:search="true"
|
||||||
|
:table-actions="true"
|
||||||
|
:row-actions="false"
|
||||||
|
:groupable="false"
|
||||||
|
:paging="true"
|
||||||
|
:rows-per-page="20"
|
||||||
|
key-field="_key"
|
||||||
|
@selection="onSelect"
|
||||||
|
>
|
||||||
|
<template #header-left>
|
||||||
|
<div class="vm-table-title">
|
||||||
|
<h3 class="m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:vmName="{ row }">
|
||||||
|
<div class="vm-name-cell">
|
||||||
|
<span class="vm-name">{{ row.vmName }}</span>
|
||||||
|
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:resources="{ row }">
|
||||||
|
<span>{{ row.resourcesDisplay }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:powerState="{ row }">
|
||||||
|
<BadgeState
|
||||||
|
:label="row.powerState"
|
||||||
|
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell:network="{ row }">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="(net, i) in row.networks"
|
||||||
|
:key="i"
|
||||||
|
:class="{ 'mt-4': i > 0 }"
|
||||||
|
>
|
||||||
|
<span>{{ net.name }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ net.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:datastore="{ row }">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="(ds, i) in row.datastores"
|
||||||
|
:key="i"
|
||||||
|
:class="{ 'mt-4': i > 0 }"
|
||||||
|
>
|
||||||
|
<span>{{ ds.name }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ ds.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SortableTable>
|
||||||
|
|
||||||
|
<Teleport
|
||||||
|
v-if="showSelectAllBanner && theadElement"
|
||||||
|
:to="theadElement"
|
||||||
|
>
|
||||||
|
<tr class="select-all-banner-row">
|
||||||
|
<td
|
||||||
|
:colspan="headers.length + 1"
|
||||||
|
class="select-all-banner-cell"
|
||||||
|
>
|
||||||
|
<template v-if="allVMsSelected">
|
||||||
|
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
@click.prevent="clearSelection"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
@click.prevent="selectAllVMs"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.vm-table-title {
|
||||||
|
.text-deemphasized {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.vm-name {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-id {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-vms-step {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.select-all-banner-row) {
|
||||||
|
.select-all-banner-cell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: var(--sortable-table-header-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -73,7 +73,9 @@ const FEATURE_FLAGS = {
|
|||||||
'supportFilesystem',
|
'supportFilesystem',
|
||||||
'disableResourcePooling',
|
'disableResourcePooling',
|
||||||
'expandOnlineEncryptedVolume',
|
'expandOnlineEncryptedVolume',
|
||||||
'longhornV2HugepageSettings'
|
'longhornV2HugepageSettings',
|
||||||
|
'staticIPForVM',
|
||||||
|
'fsFreezeDeadline',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ import {
|
|||||||
VM_IMPORT_SOURCE_O_STATUS,
|
VM_IMPORT_SOURCE_O_STATUS,
|
||||||
VM_IMPORT_SOURCE_OVA_URL,
|
VM_IMPORT_SOURCE_OVA_URL,
|
||||||
VM_IMPORT_SOURCE_OVA_STATUS,
|
VM_IMPORT_SOURCE_OVA_STATUS,
|
||||||
|
FORKLIFT_PROVIDER_TYPE,
|
||||||
|
FORKLIFT_PROVIDER_URL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_MAP_DEST_PROVIDER,
|
||||||
|
FORKLIFT_PLAN_TARGET_NS,
|
||||||
|
FORKLIFT_PLAN_VM_COUNT,
|
||||||
|
FORKLIFT_MIGRATION_PLAN,
|
||||||
} from './table-headers';
|
} from './table-headers';
|
||||||
import { ADD_ONS } from './harvester-map';
|
import { ADD_ONS } from './harvester-map';
|
||||||
import { registerAddonSideNav } from '../utils/dynamic-nav';
|
import { registerAddonSideNav } from '../utils/dynamic-nav';
|
||||||
@@ -1319,4 +1326,187 @@ export function init($plugin, store) {
|
|||||||
exact: false,
|
exact: false,
|
||||||
ifHaveType: HCI.HOST_NETWORK_CONFIG,
|
ifHaveType: HCI.HOST_NETWORK_CONFIG,
|
||||||
});
|
});
|
||||||
|
// ===========================================================================
|
||||||
|
// Forklift Addon UI Flow
|
||||||
|
// ===========================================================================
|
||||||
|
weightGroup('vmMigration', 0, false);
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
headers(HCI.FORKLIFT_PROVIDER, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_PROVIDER_TYPE,
|
||||||
|
FORKLIFT_PROVIDER_URL,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_PROVIDER, {
|
||||||
|
resource: HCI.FORKLIFT_PROVIDER,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PROVIDER }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_PROVIDER,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.provider',
|
||||||
|
group: 'vmMigration',
|
||||||
|
namespaced: true,
|
||||||
|
weight: 100,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PROVIDER }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NetworkMap
|
||||||
|
headers(HCI.FORKLIFT_NETWORK_MAP, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_MAP_DEST_PROVIDER,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_NETWORK_MAP, {
|
||||||
|
resource: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
|
||||||
|
group: 'vmMigration::Configurations',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// StorageMap
|
||||||
|
headers(HCI.FORKLIFT_STORAGE_MAP, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_MAP_DEST_PROVIDER,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_STORAGE_MAP, {
|
||||||
|
resource: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
|
||||||
|
group: 'vmMigration::Configurations',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plan
|
||||||
|
headers(HCI.FORKLIFT_PLAN, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_PLAN_TARGET_NS,
|
||||||
|
FORKLIFT_PLAN_VM_COUNT,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_PLAN, {
|
||||||
|
resource: HCI.FORKLIFT_PLAN,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PLAN }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_PLAN,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.plan',
|
||||||
|
group: 'vmMigration::Configurations',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PLAN }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migration
|
||||||
|
headers(HCI.FORKLIFT_MIGRATION, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MIGRATION_PLAN,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_MIGRATION, {
|
||||||
|
resource: HCI.FORKLIFT_MIGRATION,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_MIGRATION }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_MIGRATION,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.migration',
|
||||||
|
group: 'vmMigration::Configurations',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_MIGRATION }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
|
||||||
|
virtualType({
|
||||||
|
name: 'forklift-create',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
|
||||||
|
group: 'vmMigration',
|
||||||
|
namespaced: true,
|
||||||
|
weight: 200,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
params: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable SideNav based on Forklift Addon Status
|
||||||
|
// The dashboard entry ('forklift-create') is a schema-less virtual type, so it
|
||||||
|
// is registered with requireSchema:false to still be gated by the addon status.
|
||||||
|
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||||
|
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||||
|
resourceType: HCI.ADD_ONS,
|
||||||
|
navGroup: 'vmMigration',
|
||||||
|
requireSchema: false,
|
||||||
|
types: ['forklift-create']
|
||||||
|
});
|
||||||
|
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||||
|
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||||
|
resourceType: HCI.ADD_ONS,
|
||||||
|
navGroup: 'vmMigration',
|
||||||
|
types: [
|
||||||
|
HCI.FORKLIFT_PROVIDER,
|
||||||
|
]
|
||||||
|
});
|
||||||
|
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||||
|
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||||
|
resourceType: HCI.ADD_ONS,
|
||||||
|
navGroup: 'vmMigration::Configurations',
|
||||||
|
types: [
|
||||||
|
HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
HCI.FORKLIFT_PLAN,
|
||||||
|
HCI.FORKLIFT_MIGRATION,
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// ===========================================================================
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const ADD_ONS = {
|
|||||||
VM_IMPORT_CONTROLLER: 'vm-import-controller',
|
VM_IMPORT_CONTROLLER: 'vm-import-controller',
|
||||||
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
|
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
|
||||||
KUBEOVN_OPERATOR: 'kubeovn-operator',
|
KUBEOVN_OPERATOR: 'kubeovn-operator',
|
||||||
|
FORKLIFT_OPERATOR: 'forklift-operator',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CSI_SECRETS = {
|
export const CSI_SECRETS = {
|
||||||
@@ -92,6 +93,9 @@ export const CSI_SECRETS = {
|
|||||||
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
|
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
|
||||||
|
export const FORKLIFT_NAMESPACE = 'forklift';
|
||||||
|
|
||||||
// Some harvester CRD type is not equal to model file name, define the mapping here
|
// Some harvester CRD type is not equal to model file name, define the mapping here
|
||||||
export const HARVESTER_CRD_MAP = {
|
export const HARVESTER_CRD_MAP = {
|
||||||
node: HCI.HOST,
|
node: HCI.HOST,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const HCI = {
|
|||||||
CREATOR: 'harvesterhci.io/creator',
|
CREATOR: 'harvesterhci.io/creator',
|
||||||
OS: 'harvesterhci.io/os',
|
OS: 'harvesterhci.io/os',
|
||||||
GOLDEN_IMAGE: 'harvesterhci.io/goldenImage',
|
GOLDEN_IMAGE: 'harvesterhci.io/goldenImage',
|
||||||
|
CLUSTER_NETWORK: 'network.harvesterhci.io/clusternetwork',
|
||||||
NETWORK_TYPE: 'network.harvesterhci.io/type',
|
NETWORK_TYPE: 'network.harvesterhci.io/type',
|
||||||
VM_NAME: 'harvesterhci.io/vmName',
|
VM_NAME: 'harvesterhci.io/vmName',
|
||||||
VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix',
|
VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix',
|
||||||
@@ -80,7 +81,9 @@ export const HCI = {
|
|||||||
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
|
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
|
||||||
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
|
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
|
||||||
MAC_ADDRESS: 'harvesterhci.io/mac-address',
|
MAC_ADDRESS: 'harvesterhci.io/mac-address',
|
||||||
|
STATIC_IP: 'static-ip.harvesterhci.io',
|
||||||
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
|
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
|
||||||
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
|
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
|
||||||
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
|
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
|
||||||
|
WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -230,3 +230,70 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
|
|||||||
sort: 'status.status',
|
sort: 'status.status',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Forklift table headers
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Provider type column in forklift.konveyor.io.provider list page
|
||||||
|
export const FORKLIFT_PROVIDER_TYPE = {
|
||||||
|
name: 'providerType',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
|
||||||
|
value: 'spec.type',
|
||||||
|
sort: 'spec.type',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provider URL column in forklift.konveyor.io.provider list page
|
||||||
|
export const FORKLIFT_PROVIDER_URL = {
|
||||||
|
name: 'providerUrl',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
|
||||||
|
value: 'spec.url',
|
||||||
|
sort: 'spec.url',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Source provider column in forklift network/storage map list page
|
||||||
|
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
|
||||||
|
name: 'sourceProvider',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
|
||||||
|
value: 'spec.provider.source.name',
|
||||||
|
sort: 'spec.provider.source.name',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Destination provider column in forklift network/storage map list page
|
||||||
|
export const FORKLIFT_MAP_DEST_PROVIDER = {
|
||||||
|
name: 'destProvider',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
|
||||||
|
value: 'spec.provider.destination.name',
|
||||||
|
sort: 'spec.provider.destination.name',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Target namespace column in forklift.konveyor.io.plan list page
|
||||||
|
export const FORKLIFT_PLAN_TARGET_NS = {
|
||||||
|
name: 'targetNamespace',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
|
||||||
|
value: 'spec.targetNamespace',
|
||||||
|
sort: 'spec.targetNamespace',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// VM count column in forklift.konveyor.io.plan list page
|
||||||
|
export const FORKLIFT_PLAN_VM_COUNT = {
|
||||||
|
name: 'vmCount',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
|
||||||
|
value: 'spec.vms.length',
|
||||||
|
sort: 'spec.vms.length',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plan reference column in forklift.konveyor.io.migration list page
|
||||||
|
export const FORKLIFT_MIGRATION_PLAN = {
|
||||||
|
name: 'plan',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
|
||||||
|
value: 'spec.plan.name',
|
||||||
|
sort: 'spec.plan.name',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { MiniMap } from '@vue-flow/minimap';
|
|||||||
import { allHash } from '@shell/utils/promise';
|
import { allHash } from '@shell/utils/promise';
|
||||||
import { NETWORK_ATTACHMENT } from '@shell/config/types';
|
import { NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||||
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
|
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
|
||||||
|
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||||
import { HCI } from '../types';
|
import { HCI } from '../types';
|
||||||
import { NETWORK_TYPE } from '../config/types';
|
import { NETWORK_TYPE } from '../config/types';
|
||||||
|
|
||||||
@@ -454,10 +455,10 @@ export default {
|
|||||||
const overlayName = nad?.parseConfig?.name || nad?.metadata?.name;
|
const overlayName = nad?.parseConfig?.name || nad?.metadata?.name;
|
||||||
|
|
||||||
const clusterNetwork =
|
const clusterNetwork =
|
||||||
nad?.metadata?.labels?.[HCI.CLUSTER_NETWORK] || 'mgmt';
|
nad?.metadata?.labels?.[HCI_ANNOTATIONS.CLUSTER_NETWORK] || 'mgmt';
|
||||||
|
|
||||||
const nadType =
|
const nadType =
|
||||||
nad?.metadata?.labels?.[HCI.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY;
|
nad?.metadata?.labels?.[HCI_ANNOTATIONS.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY;
|
||||||
const overlayNodeId = `overlay-${ provider }`;
|
const overlayNodeId = `overlay-${ provider }`;
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Card } from '@components/Card';
|
|||||||
import { Banner } from '@components/Banner';
|
import { Banner } from '@components/Banner';
|
||||||
import AsyncButton from '@shell/components/AsyncButton';
|
import AsyncButton from '@shell/components/AsyncButton';
|
||||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HarvesterBackupModal',
|
name: 'HarvesterBackupModal',
|
||||||
@@ -15,6 +17,7 @@ export default {
|
|||||||
AsyncButton,
|
AsyncButton,
|
||||||
Card,
|
Card,
|
||||||
LabeledInput,
|
LabeledInput,
|
||||||
|
LabeledSelect,
|
||||||
Banner
|
Banner
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -27,8 +30,9 @@ export default {
|
|||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
backUpName: '',
|
backUpName: '',
|
||||||
errors: []
|
errors: [],
|
||||||
|
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -37,12 +41,22 @@ export default {
|
|||||||
|
|
||||||
actionResource() {
|
actionResource() {
|
||||||
return this.resources[0];
|
return this.resources[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
fsFreezeDeadlineEnabled() {
|
||||||
|
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
|
||||||
|
},
|
||||||
|
|
||||||
|
fsFreezeDeadlineOptions() {
|
||||||
|
return getFsFreezeDeadlineOptions(this.t);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
close() {
|
close() {
|
||||||
this.backUpName = '';
|
this.backUpName = '';
|
||||||
|
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
|
||||||
|
this.errors = [];
|
||||||
this.$emit('close');
|
this.$emit('close');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -51,7 +65,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const res = await this.actionResource.doAction(
|
const res = await this.actionResource.doAction(
|
||||||
'backup',
|
'backup',
|
||||||
{ name: this.backUpName },
|
{ name: this.backUpName, fsFreezeDeadline: this.fsFreezeDeadline },
|
||||||
{},
|
{},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -100,9 +114,18 @@ export default {
|
|||||||
<template #body>
|
<template #body>
|
||||||
<LabeledInput
|
<LabeledInput
|
||||||
v-model:value="backUpName"
|
v-model:value="backUpName"
|
||||||
|
class="mt-20"
|
||||||
:label="t('generic.name')"
|
:label="t('generic.name')"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<LabeledSelect
|
||||||
|
v-if="fsFreezeDeadlineEnabled"
|
||||||
|
v-model:value="fsFreezeDeadline"
|
||||||
|
class="mt-20"
|
||||||
|
:options="fsFreezeDeadlineOptions"
|
||||||
|
:label="t('harvester.modal.backup.fileSystemFreezeDeadline.label')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Banner
|
<Banner
|
||||||
v-for="(err, i) in errors"
|
v-for="(err, i) in errors"
|
||||||
:key="i"
|
:key="i"
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { Card } from '@components/Card';
|
|||||||
import { Banner } from '@components/Banner';
|
import { Banner } from '@components/Banner';
|
||||||
import AsyncButton from '@shell/components/AsyncButton';
|
import AsyncButton from '@shell/components/AsyncButton';
|
||||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
import { HCI } from '../types';
|
import { HCI } from '../types';
|
||||||
import { BACKUP_TYPE } from '../config/types';
|
import { BACKUP_TYPE } from '../config/types';
|
||||||
|
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HarvesterVMSnapshotDialog',
|
name: 'HarvesterVMSnapshotDialog',
|
||||||
@@ -17,6 +19,7 @@ export default {
|
|||||||
AsyncButton,
|
AsyncButton,
|
||||||
Card,
|
Card,
|
||||||
LabeledInput,
|
LabeledInput,
|
||||||
|
LabeledSelect,
|
||||||
Banner
|
Banner
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -31,7 +34,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
snapshotName: '',
|
snapshotName: '',
|
||||||
snapshotNamespace: '',
|
snapshotNamespace: '',
|
||||||
errors: []
|
errors: [],
|
||||||
|
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -40,13 +44,23 @@ export default {
|
|||||||
|
|
||||||
actionResource() {
|
actionResource() {
|
||||||
return this.resources[0];
|
return this.resources[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
fsFreezeDeadlineEnabled() {
|
||||||
|
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
|
||||||
|
},
|
||||||
|
|
||||||
|
fsFreezeDeadlineOptions() {
|
||||||
|
return getFsFreezeDeadlineOptions(this.t);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
close() {
|
close() {
|
||||||
|
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
|
||||||
this.snapshotNamespace = '';
|
this.snapshotNamespace = '';
|
||||||
this.snapshotName = '';
|
this.snapshotName = '';
|
||||||
|
this.errors = [];
|
||||||
this.$emit('close');
|
this.$emit('close');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -60,7 +74,8 @@ export default {
|
|||||||
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
|
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
|
||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
source: {
|
fsFreezeDeadline: this.fsFreezeDeadline,
|
||||||
|
source: {
|
||||||
apiGroup: 'kubevirt.io',
|
apiGroup: 'kubevirt.io',
|
||||||
kind: 'VirtualMachine',
|
kind: 'VirtualMachine',
|
||||||
name: this.actionResource.metadata.name
|
name: this.actionResource.metadata.name
|
||||||
@@ -132,6 +147,14 @@ export default {
|
|||||||
:label="t('generic.name')"
|
:label="t('generic.name')"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<LabeledSelect
|
||||||
|
v-if="fsFreezeDeadlineEnabled"
|
||||||
|
v-model:value="fsFreezeDeadline"
|
||||||
|
class="mt-20"
|
||||||
|
:options="fsFreezeDeadlineOptions"
|
||||||
|
:label="t('harvester.modal.vmSnapshot.fileSystemFreezeDeadline.label')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Banner
|
<Banner
|
||||||
v-for="(err, i) in errors"
|
v-for="(err, i) in errors"
|
||||||
:key="i"
|
:key="i"
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script>
|
||||||
|
import { h } from 'vue';
|
||||||
|
import { PRODUCT_NAME } from '../config/harvester';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ForkliftProviderEdit',
|
||||||
|
|
||||||
|
created() {
|
||||||
|
const { namespace, id } = this.$route.params;
|
||||||
|
const query = namespace && id ? { providerId: `${ namespace }/${ id }` } : {};
|
||||||
|
|
||||||
|
this.$router.replace({
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
params: {
|
||||||
|
product: this.$route.params.product,
|
||||||
|
cluster: this.$route.params.cluster,
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
render: () => h('div'),
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -468,10 +468,15 @@ export default {
|
|||||||
const size = formatSi(sizeBytes, { increment: 1024, suffix: UNIT_SUFFIX });
|
const size = formatSi(sizeBytes, { increment: 1024, suffix: UNIT_SUFFIX });
|
||||||
const parentDevice = d.status?.deviceStatus?.parentDevice;
|
const parentDevice = d.status?.deviceStatus?.parentDevice;
|
||||||
const isChildAdded = this.newDisks.find((newDisk) => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath);
|
const isChildAdded = this.newDisks.find((newDisk) => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath);
|
||||||
|
const mountPoint = d.status?.deviceStatus?.fileSystem?.mountPoint;
|
||||||
const name = d.displayName;
|
const name = d.displayName;
|
||||||
|
|
||||||
let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`;
|
let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`;
|
||||||
|
|
||||||
|
if (mountPoint) {
|
||||||
|
label = `${ label } - ${ this.t('harvester.host.disk.mounted') }`;
|
||||||
|
}
|
||||||
|
|
||||||
if (parentDevice) {
|
if (parentDevice) {
|
||||||
label = `- ${ label }`;
|
label = `- ${ label }`;
|
||||||
}
|
}
|
||||||
@@ -481,7 +486,7 @@ export default {
|
|||||||
value: d.id,
|
value: d.id,
|
||||||
action: this.addDisk,
|
action: this.addDisk,
|
||||||
kind: !parentDevice ? 'group' : '',
|
kind: !parentDevice ? 'group' : '',
|
||||||
disabled: !!isChildAdded,
|
disabled: !!isChildAdded || !!mountPoint,
|
||||||
group: parentDevice || devPath,
|
group: parentDevice || devPath,
|
||||||
isParent: !!parentDevice,
|
isParent: !!parentDevice,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ export default {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
return this.isMasquerade ? masquerade : other;
|
return this.isMasquerade ? masquerade : other;
|
||||||
|
},
|
||||||
|
|
||||||
|
staticIPForVMEnabled() {
|
||||||
|
return this.$store.getters['harvester-common/getFeatureEnabled']('staticIPForVM');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -150,6 +154,7 @@ export default {
|
|||||||
isSingle(neu) {
|
isSingle(neu) {
|
||||||
if (!neu) {
|
if (!neu) {
|
||||||
this.value['macAddress'] = '';
|
this.value['macAddress'] = '';
|
||||||
|
this.value['staticIp'] = '';
|
||||||
this.update();
|
this.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +168,7 @@ export default {
|
|||||||
if (neu === MANAGEMENT_NETWORK) {
|
if (neu === MANAGEMENT_NETWORK) {
|
||||||
this.value.isPod = true;
|
this.value.isPod = true;
|
||||||
this.value.macAddress = '';
|
this.value.macAddress = '';
|
||||||
|
this.value.staticIp = '';
|
||||||
} else {
|
} else {
|
||||||
this.value.isPod = false;
|
this.value.isPod = false;
|
||||||
}
|
}
|
||||||
@@ -289,9 +295,30 @@ export default {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div
|
||||||
|
v-if="showAdvanced"
|
||||||
|
class="row"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="staticIPForVMEnabled"
|
||||||
|
data-testid="input-hen-staticIp"
|
||||||
|
class="col span-6"
|
||||||
|
>
|
||||||
|
<InputOrDisplay
|
||||||
|
:name="t('harvester.fields.staticIp')"
|
||||||
|
:value="value.staticIp"
|
||||||
|
:mode="mode"
|
||||||
|
>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="value.staticIp"
|
||||||
|
label-key="harvester.fields.staticIp"
|
||||||
|
:mode="mode"
|
||||||
|
:tooltip="t('harvester.virtualMachine.volume.staticIpTip', { nic_name: value.name || 'default' })"
|
||||||
|
@update:value="update"
|
||||||
|
/>
|
||||||
|
</InputOrDisplay>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showAdvanced"
|
|
||||||
data-testid="input-hen-macAddress"
|
data-testid="input-hen-macAddress"
|
||||||
class="col span-6"
|
class="col span-6"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
|
||||||
|
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||||
|
import { SECRET } from '@shell/config/types';
|
||||||
|
|
||||||
|
const _AUTOUNATTENDXML = 'autounattend.xml';
|
||||||
|
const _NEW = '_NEW';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'VirtualMachineWindowsSysprep',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
Banner,
|
||||||
|
LabeledInput,
|
||||||
|
LabeledSelect,
|
||||||
|
ModalWithCard,
|
||||||
|
YamlEditor,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'create',
|
||||||
|
},
|
||||||
|
namespace: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
secretName: '',
|
||||||
|
xmlContent: '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
secretOptions: [],
|
||||||
|
errors: [],
|
||||||
|
secretName: '',
|
||||||
|
xmlContent: '',
|
||||||
|
newSecretName: '',
|
||||||
|
newXmlContent: '',
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetch() {
|
||||||
|
await this.loadSecretsOptions();
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
// Force the loading of the XML content.
|
||||||
|
this.secretName = this.value?.secretName || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
secretName() {
|
||||||
|
if (this.secretName === _NEW) {
|
||||||
|
this.isOpen = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the content from the given secret, or set empty value.
|
||||||
|
this.xmlContent = this.secretName === '' ? '' : this.loadXmlContentFromSecret(this.secretName);
|
||||||
|
|
||||||
|
// Update the editor form field.
|
||||||
|
this.updateValue();
|
||||||
|
|
||||||
|
// Update the property.
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
xmlContent() {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapGetters({ t: 'i18n/t' }),
|
||||||
|
|
||||||
|
editorMode() {
|
||||||
|
return this.mode === 'view' ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
|
||||||
|
},
|
||||||
|
|
||||||
|
isView() {
|
||||||
|
return this.mode === 'view';
|
||||||
|
},
|
||||||
|
|
||||||
|
xmlContentValidationError() {
|
||||||
|
return this.validateXmlContent(this.xmlContent);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
update() {
|
||||||
|
this.$emit('update:value', {
|
||||||
|
secretName: this.secretName,
|
||||||
|
xmlContent: this.xmlContent,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateValue() {
|
||||||
|
this.$refs?.xmlEditor?.updateValue?.(this.xmlContent);
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.$refs?.xmlEditor?.refresh?.();
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
if (this.secretName === _NEW) {
|
||||||
|
this.secretName = '';
|
||||||
|
}
|
||||||
|
this.newSecretName = '';
|
||||||
|
this.newXmlContent = '';
|
||||||
|
this.errors = [];
|
||||||
|
this.isOpen = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSecretsOptions() {
|
||||||
|
const secrets = await this.$store.dispatch('harvester/findAll', { type: SECRET });
|
||||||
|
|
||||||
|
const templates = secrets
|
||||||
|
.filter(
|
||||||
|
(s) => s.metadata?.namespace === this.namespace &&
|
||||||
|
s.decodedData?.[_AUTOUNATTENDXML]
|
||||||
|
)
|
||||||
|
.map((s) => ({
|
||||||
|
label: s.metadata.name,
|
||||||
|
value: `${ s.metadata.namespace }/${ s.metadata.name }`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.secretOptions = [
|
||||||
|
{
|
||||||
|
label: this.t('generic.none'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: this.t('harvester.virtualMachine.sysprep.createNew'),
|
||||||
|
value: _NEW,
|
||||||
|
},
|
||||||
|
...templates,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(buttonCb) {
|
||||||
|
this.errors = [];
|
||||||
|
|
||||||
|
if (!this.newSecretName?.trim()) {
|
||||||
|
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.name') }, true));
|
||||||
|
buttonCb(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.newXmlContent?.trim()) {
|
||||||
|
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
|
||||||
|
buttonCb(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlError = this.validateXmlContent(this.newXmlContent);
|
||||||
|
|
||||||
|
if (xmlError) {
|
||||||
|
this.errors.push(xmlError);
|
||||||
|
buttonCb(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = await this.$store.dispatch('harvester/create', {
|
||||||
|
type: SECRET,
|
||||||
|
metadata: {
|
||||||
|
name: this.newSecretName,
|
||||||
|
namespace: this.namespace,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
secret.setData(_AUTOUNATTENDXML, this.newXmlContent);
|
||||||
|
const res = await secret.save();
|
||||||
|
|
||||||
|
if (res?.metadata?.name) {
|
||||||
|
await this.loadSecretsOptions();
|
||||||
|
this.secretName = `${ this.namespace }/${ res.metadata.name }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonCb(true);
|
||||||
|
this.cancel();
|
||||||
|
} catch (err) {
|
||||||
|
this.errors = [err.message];
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
validateXmlContent(content) {
|
||||||
|
if (!content?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(content, 'text/xml');
|
||||||
|
const parseError = doc.querySelector('parsererror');
|
||||||
|
|
||||||
|
if (parseError) {
|
||||||
|
return this.t('harvester.virtualMachine.sysprep.validation.invalidXml');
|
||||||
|
}
|
||||||
|
|
||||||
|
// See https://learn.microsoft.com/en-us/windows-hardware/drivers/gpiobtn/implement-the-unattended-windows-setup-setting
|
||||||
|
if (doc.documentElement.namespaceURI !== 'urn:schemas-microsoft-com:unattend') {
|
||||||
|
return this.t('harvester.virtualMachine.sysprep.validation.invalidNamespace');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return e.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadXmlContentFromSecret(id) {
|
||||||
|
const secret = this.$store.getters['harvester/byId'](SECRET, id);
|
||||||
|
|
||||||
|
return secret?.decodedData?.[_AUTOUNATTENDXML] || '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 v-if="!isView">
|
||||||
|
{{ t('harvester.virtualMachine.sysprep.title') }}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
v-if="!isView"
|
||||||
|
class="text-muted mb-20"
|
||||||
|
>
|
||||||
|
<t
|
||||||
|
k="harvester.virtualMachine.sysprep.description"
|
||||||
|
:raw="true"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Secret Selection -->
|
||||||
|
<div class="row mb-20">
|
||||||
|
<div class="col span-12">
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="secretName"
|
||||||
|
:options="secretOptions"
|
||||||
|
:label="t('harvester.virtualMachine.sysprep.secret.label')"
|
||||||
|
:mode="mode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- XML Editor -->
|
||||||
|
<div class="mb-20">
|
||||||
|
<Banner
|
||||||
|
v-if="xmlContentValidationError"
|
||||||
|
color="error"
|
||||||
|
class="mb-10"
|
||||||
|
>
|
||||||
|
<i class="icon icon-error" />
|
||||||
|
{{ xmlContentValidationError }}
|
||||||
|
</Banner>
|
||||||
|
|
||||||
|
<div class="resource-xml">
|
||||||
|
<YamlEditor
|
||||||
|
ref="xmlEditor"
|
||||||
|
v-model:value="xmlContent"
|
||||||
|
:editor-mode="editorMode"
|
||||||
|
class="xml-editor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalWithCard
|
||||||
|
v-if="isOpen"
|
||||||
|
name="createSysprepSecret"
|
||||||
|
width="40%"
|
||||||
|
:errors="errors"
|
||||||
|
@finish="save"
|
||||||
|
@close="cancel"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
{{ t('harvester.virtualMachine.sysprep.createNewTitle') }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="newSecretName"
|
||||||
|
:label="t('harvester.virtualMachine.input.name')"
|
||||||
|
class="mb-20"
|
||||||
|
required
|
||||||
|
@keydown.native.enter.prevent="()=>{}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="xml">
|
||||||
|
<div class="resource-xml">
|
||||||
|
<YamlEditor
|
||||||
|
ref="createTemplate"
|
||||||
|
v-model:value="newXmlContent"
|
||||||
|
:editor-mode="editorMode"
|
||||||
|
class="xml-editor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ModalWithCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$xml-height: 350px;
|
||||||
|
|
||||||
|
:deep() .resource-xml {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
& .xml-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: $xml-height;
|
||||||
|
font-family: monospace;
|
||||||
|
|
||||||
|
& .code-mirror .CodeMirror {
|
||||||
|
min-height: $xml-height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.xml {
|
||||||
|
height: $xml-height;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,6 +29,7 @@ import RestartVMDialog from '../../dialog/RestartVMDialog';
|
|||||||
import PciDevices from './VirtualMachinePciDevices/index';
|
import PciDevices from './VirtualMachinePciDevices/index';
|
||||||
import AccessCredentials from './VirtualMachineAccessCredentials';
|
import AccessCredentials from './VirtualMachineAccessCredentials';
|
||||||
import CloudConfig from './VirtualMachineCloudConfig';
|
import CloudConfig from './VirtualMachineCloudConfig';
|
||||||
|
import WindowsSysprep from './VirtualMachineWindowsSysprep';
|
||||||
import CpuMemory from './VirtualMachineCpuMemory';
|
import CpuMemory from './VirtualMachineCpuMemory';
|
||||||
import CpuModel from './VirtualMachineCpuModel';
|
import CpuModel from './VirtualMachineCpuModel';
|
||||||
import Network from './VirtualMachineNetwork';
|
import Network from './VirtualMachineNetwork';
|
||||||
@@ -59,6 +60,7 @@ export default {
|
|||||||
CpuMemory,
|
CpuMemory,
|
||||||
CpuModel,
|
CpuModel,
|
||||||
CloudConfig,
|
CloudConfig,
|
||||||
|
WindowsSysprep,
|
||||||
NodeScheduling,
|
NodeScheduling,
|
||||||
PodAffinity,
|
PodAffinity,
|
||||||
AccessCredentials,
|
AccessCredentials,
|
||||||
@@ -324,6 +326,10 @@ export default {
|
|||||||
try {
|
try {
|
||||||
await this.saveSecret(res);
|
await this.saveSecret(res);
|
||||||
await this.saveAccessCredentials(res);
|
await this.saveAccessCredentials(res);
|
||||||
|
|
||||||
|
if (this.isWindows) {
|
||||||
|
await this.saveSysprepConfig(res);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.errors.push(...exceptionToErrorsArray(e));
|
this.errors.push(...exceptionToErrorsArray(e));
|
||||||
}
|
}
|
||||||
@@ -369,6 +375,7 @@ export default {
|
|||||||
clear(this.errors);
|
clear(this.errors);
|
||||||
|
|
||||||
this.validateCPUMemory();
|
this.validateCPUMemory();
|
||||||
|
this.validateWindowsSysprep();
|
||||||
|
|
||||||
// block create VM flow if has validation errors
|
// block create VM flow if has validation errors
|
||||||
if (this.errors.length) {
|
if (this.errors.length) {
|
||||||
@@ -396,6 +403,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
validateWindowsSysprep() {
|
||||||
|
if (!this.isWindows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sysprep?.secretName?.trim?.() && !this.sysprep?.xmlContent?.trim?.()) {
|
||||||
|
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sysprepValidationError = this.$refs.sysprepConfig?.xmlContentValidationError;
|
||||||
|
|
||||||
|
if (sysprepValidationError) {
|
||||||
|
this.errors.push(sysprepValidationError);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async saveSingle(buttonCb) {
|
async saveSingle(buttonCb) {
|
||||||
this.parseVM();
|
this.parseVM();
|
||||||
this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name;
|
this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name;
|
||||||
@@ -546,6 +571,7 @@ export default {
|
|||||||
onTabChanged({ tab }) {
|
onTabChanged({ tab }) {
|
||||||
if (tab.name === 'advanced') {
|
if (tab.name === 'advanced') {
|
||||||
this.$refs.yamlEditor?.refresh();
|
this.$refs.yamlEditor?.refresh();
|
||||||
|
this.$refs.sysprepConfig?.refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1019,11 +1045,11 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CloudConfig
|
<CloudConfig
|
||||||
|
v-if="!isWindows"
|
||||||
ref="yamlEditor"
|
ref="yamlEditor"
|
||||||
:user-script="userScript"
|
:user-script="userScript"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
:os-type="osType"
|
:os-type="osType"
|
||||||
:view-code="isWindows"
|
|
||||||
:namespace="value.metadata.namespace"
|
:namespace="value.metadata.namespace"
|
||||||
:network-script="networkScript"
|
:network-script="networkScript"
|
||||||
@updateUserData="updateUserData"
|
@updateUserData="updateUserData"
|
||||||
@@ -1031,6 +1057,14 @@ export default {
|
|||||||
@updateDataTemplateId="updateDataTemplateId"
|
@updateDataTemplateId="updateDataTemplateId"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WindowsSysprep
|
||||||
|
v-if="isWindows"
|
||||||
|
ref="sysprepConfig"
|
||||||
|
v-model:value="sysprep"
|
||||||
|
:mode="mode"
|
||||||
|
:namespace="value.metadata.namespace"
|
||||||
|
/>
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-if="value.cpuPinningFeatureEnabled"
|
v-if="value.cpuPinningFeatureEnabled"
|
||||||
v-model:value="cpuPinning"
|
v-model:value="cpuPinning"
|
||||||
|
|||||||
@@ -283,9 +283,9 @@ export default {
|
|||||||
v-model:value.number="vlanID"
|
v-model:value.number="vlanID"
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
:min="2"
|
:min="1"
|
||||||
:max="4094"
|
:max="4094"
|
||||||
placeholder="e.g. 2 ~ 4094"
|
placeholder="e.g. 1 ~ 4094"
|
||||||
:label="t('harvester.hostNetworkConfig.vlanID.label')"
|
:label="t('harvester.hostNetworkConfig.vlanID.label')"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export default {
|
|||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
ips() {
|
ips() {
|
||||||
|
if (this.staticAnnotationIP.length) {
|
||||||
|
return this.staticAnnotationIP;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.vmiIp.length) {
|
if (this.vmiIp.length) {
|
||||||
return [...this.vmiIp, ...this.networkAnnotationIP]
|
return [...this.vmiIp, ...this.networkAnnotationIP]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -45,6 +49,24 @@ export default {
|
|||||||
return this.customAnnotationIP;
|
return this.customAnnotationIP;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
staticAnnotationIP() {
|
||||||
|
const annotations = this.row?.metadata?.annotations || {};
|
||||||
|
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
|
||||||
|
|
||||||
|
return Object.entries(annotations)
|
||||||
|
.filter(([key, value]) => key.startsWith(staticIpPrefix) && !!value)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const nicName = key.slice(staticIpPrefix.length);
|
||||||
|
const ip = String(value).replace(/\/[\d\D]*/, '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
name: nicName
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(({ ip }) => isIpv4(ip));
|
||||||
|
},
|
||||||
|
|
||||||
customAnnotationIP() {
|
customAnnotationIP() {
|
||||||
const annotationIp = get(this.row, `metadata.annotations."${ HCI_ANNOTATIONS.CUSTOM_IP }"`);
|
const annotationIp = get(this.row, `metadata.annotations."${ HCI_ANNOTATIONS.CUSTOM_IP }"`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
wizard:
|
||||||
|
create: Create
|
||||||
|
next: Proceed
|
||||||
|
previous: Back
|
||||||
|
|
||||||
generic:
|
generic:
|
||||||
tip: Tip
|
tip: Tip
|
||||||
resourceExternalLinkTips: 'External Link'
|
resourceExternalLinkTips: 'External Link'
|
||||||
@@ -7,6 +12,16 @@ generic:
|
|||||||
inProgress: In Progress
|
inProgress: In Progress
|
||||||
basic: Basic
|
basic: Basic
|
||||||
loading: Loading...
|
loading: Loading...
|
||||||
|
none: None
|
||||||
|
duration:
|
||||||
|
infinite: Infinite
|
||||||
|
1s: 1 second
|
||||||
|
5s: 5 seconds
|
||||||
|
10s: 10 seconds
|
||||||
|
30s: 30 seconds
|
||||||
|
1m: 1 minute
|
||||||
|
3m: 3 minutes
|
||||||
|
5m: 5 minutes
|
||||||
|
|
||||||
unsupported:
|
unsupported:
|
||||||
serverVersion: 'Current version: <code>{serverVersion}</code>'
|
serverVersion: 'Current version: <code>{serverVersion}</code>'
|
||||||
@@ -21,6 +36,8 @@ nav:
|
|||||||
Logging: Logging
|
Logging: Logging
|
||||||
'Monitoring and Logging': Monitoring and Logging
|
'Monitoring and Logging': Monitoring and Logging
|
||||||
vmimport: Virtual Machine Imports
|
vmimport: Virtual Machine Imports
|
||||||
|
vmMigration: Virtual Machine Migration
|
||||||
|
Configurations: Configurations
|
||||||
|
|
||||||
resourceTable:
|
resourceTable:
|
||||||
groupBy:
|
groupBy:
|
||||||
@@ -34,6 +51,8 @@ members:
|
|||||||
projectMembership: Project Membership
|
projectMembership: Project Membership
|
||||||
|
|
||||||
asyncButton:
|
asyncButton:
|
||||||
|
createAndStart:
|
||||||
|
action: Create and Start
|
||||||
restart:
|
restart:
|
||||||
action: Save and Restart
|
action: Save and Restart
|
||||||
success: Restarted
|
success: Restarted
|
||||||
@@ -74,6 +93,8 @@ harvester:
|
|||||||
backup:
|
backup:
|
||||||
success: 'Backup { backUpName } has been initiated.'
|
success: 'Backup { backUpName } has been initiated.'
|
||||||
addBackup: Add Backup
|
addBackup: Add Backup
|
||||||
|
fileSystemFreezeDeadline:
|
||||||
|
label: 'File System Freeze Deadline'
|
||||||
quota:
|
quota:
|
||||||
editVMQuota: Edit VM Quota
|
editVMQuota: Edit VM Quota
|
||||||
editQuota: Edit Quota
|
editQuota: Edit Quota
|
||||||
@@ -213,6 +234,8 @@ harvester:
|
|||||||
title: Take Virtual Machine Snapshot
|
title: Take Virtual Machine Snapshot
|
||||||
name: Name
|
name: Name
|
||||||
success: 'Take virtual machine Snapshot { name } successfully.'
|
success: 'Take virtual machine Snapshot { name } successfully.'
|
||||||
|
fileSystemFreezeDeadline:
|
||||||
|
label: 'File System Freeze Deadline'
|
||||||
restart:
|
restart:
|
||||||
title: Restart Virtual Machine
|
title: Restart Virtual Machine
|
||||||
tip: Restart the virtual machine for configuration changes to take effect.
|
tip: Restart the virtual machine for configuration changes to take effect.
|
||||||
@@ -351,6 +374,13 @@ harvester:
|
|||||||
v4ip: V4 IP
|
v4ip: V4 IP
|
||||||
v6ip: V6 IP
|
v6ip: V6 IP
|
||||||
eipName: EIP Name
|
eipName: EIP Name
|
||||||
|
vmMigrationProviderType: Type
|
||||||
|
vmMigrationProviderUrl: URL
|
||||||
|
vmMigrationMapSourceProvider: Source Provider
|
||||||
|
vmMigrationMapDestProvider: Destination Provider
|
||||||
|
vmMigrationPlanTargetNs: Target Namespace
|
||||||
|
vmMigrationPlanVmCount: VM Count
|
||||||
|
vmMigrationMigrationPlan: Plan
|
||||||
tab:
|
tab:
|
||||||
volume: Volumes
|
volume: Volumes
|
||||||
network: Networks
|
network: Networks
|
||||||
@@ -375,6 +405,7 @@ harvester:
|
|||||||
network: Network
|
network: Network
|
||||||
model: Model
|
model: Model
|
||||||
macAddress: MAC address
|
macAddress: MAC address
|
||||||
|
staticIp: Static IP
|
||||||
port: Port
|
port: Port
|
||||||
protocol: Protocol
|
protocol: Protocol
|
||||||
remove: Remove
|
remove: Remove
|
||||||
@@ -610,6 +641,7 @@ harvester:
|
|||||||
notification:
|
notification:
|
||||||
success: 'Update host "{name}" storage configurations successfully.'
|
success: 'Update host "{name}" storage configurations successfully.'
|
||||||
error: Host has unready or unschedulable disks.
|
error: Host has unready or unschedulable disks.
|
||||||
|
mounted: Already Mounted
|
||||||
fileSystem:
|
fileSystem:
|
||||||
info: Current file system is {system}, You can format it manually.
|
info: Current file system is {system}, You can format it manually.
|
||||||
formatting: Disk is formatting, please wait.
|
formatting: Disk is formatting, please wait.
|
||||||
@@ -749,6 +781,7 @@ harvester:
|
|||||||
dragTip: Drag and drop volumes, or use the volume's arrows, to change the boot order.
|
dragTip: Drag and drop volumes, or use the volume's arrows, to change the boot order.
|
||||||
volumeTip: The virtual machine only contains a CD-ROM volume. You may want to add additional disk volumes.
|
volumeTip: The virtual machine only contains a CD-ROM volume. You may want to add additional disk volumes.
|
||||||
macTip: "MAC address as seen inside the guest system."
|
macTip: "MAC address as seen inside the guest system."
|
||||||
|
staticIpTip: "Static IP that will be stored to annotation static-ip.harvesterhci.io/{nic_name}."
|
||||||
volumeUpdate: 'Set volume { name } successfully'
|
volumeUpdate: 'Set volume { name } successfully'
|
||||||
type: Type
|
type: Type
|
||||||
size: Size
|
size: Size
|
||||||
@@ -797,6 +830,17 @@ harvester:
|
|||||||
label: Network Data Template
|
label: Network Data Template
|
||||||
title: "Network Data:"
|
title: "Network Data:"
|
||||||
tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
|
tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
|
||||||
|
sysprep:
|
||||||
|
title: Windows Sysprep Configuration
|
||||||
|
description: "Configure Windows automated installation using autounattend.xml. The configuration will be stored in a Kubernetes Secret and mounted as a CD-ROM during installation. <a href='https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/automate-windows-setup' target='_blank'>Learn more</a>"
|
||||||
|
createNew: Create new...
|
||||||
|
createNewTitle: Create Windows Sysprep Template
|
||||||
|
xmlContent: autounattend.xml content
|
||||||
|
secret:
|
||||||
|
label: Windows Sysprep Template
|
||||||
|
validation:
|
||||||
|
invalidXml: Invalid XML syntax
|
||||||
|
invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend'
|
||||||
scheduling:
|
scheduling:
|
||||||
affinity:
|
affinity:
|
||||||
anyNode: 'Run virtual machine on any available node'
|
anyNode: 'Run virtual machine on any available node'
|
||||||
@@ -1787,6 +1831,181 @@ harvester:
|
|||||||
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
|
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
|
||||||
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
|
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
|
||||||
|
|
||||||
|
vmMigration:
|
||||||
|
generic:
|
||||||
|
unknown: Unknown
|
||||||
|
identifier: Identifier
|
||||||
|
podNetwork: Pod Network
|
||||||
|
usedBy: "Used by:"
|
||||||
|
vlan: "VLAN {id}"
|
||||||
|
vCpu: "{count} vCPU"
|
||||||
|
memoryGb: "{value} GB"
|
||||||
|
memoryTb: "{value} TB"
|
||||||
|
elapsed:
|
||||||
|
hoursAndMinutes: "{hours} {hours, plural, one {hour} other {hours}} and {minutes} {minutes, plural, one {minute} other {minutes}}"
|
||||||
|
hours: "{hours} {hours, plural, one {hour} other {hours}}"
|
||||||
|
minutes: "{minutes} {minutes, plural, one {minute} other {minutes}}"
|
||||||
|
errors:
|
||||||
|
failedLoadPlans: Failed to load migration plans
|
||||||
|
failedLoadVms: Failed to load virtual machines
|
||||||
|
failedRefreshVms: Failed to refresh virtual machines
|
||||||
|
failedLoadProviders: Failed to load providers
|
||||||
|
failedLoadProvider: "Failed to load provider: {error}"
|
||||||
|
failedResolveDetails: Failed to resolve network and datastore details
|
||||||
|
failedDecodeCredentials: Failed to decode provider credentials
|
||||||
|
connectionFailed: Connection failed
|
||||||
|
providerNotReady: Provider not ready
|
||||||
|
labels:
|
||||||
|
dashboard: Migration Overview
|
||||||
|
provider: Providers
|
||||||
|
networkMap: Network Maps
|
||||||
|
storageMap: Storage Maps
|
||||||
|
plan: Migration Plans
|
||||||
|
migration: Migrations
|
||||||
|
providerWizard:
|
||||||
|
title: Create Provider
|
||||||
|
editTitle: Edit Provider
|
||||||
|
wizard:
|
||||||
|
title: VM Migration
|
||||||
|
steps:
|
||||||
|
configureProvider:
|
||||||
|
label: Provider
|
||||||
|
description: Configure Provider
|
||||||
|
selectVms:
|
||||||
|
label: VMs
|
||||||
|
description: Select VMs
|
||||||
|
configureMappings:
|
||||||
|
label: Mappings
|
||||||
|
description: Define Mappings
|
||||||
|
reviewMigration:
|
||||||
|
label: Migration Plan
|
||||||
|
description: Review Migration Plan
|
||||||
|
fields:
|
||||||
|
username: Username
|
||||||
|
password: Password
|
||||||
|
configureProvider:
|
||||||
|
title: Configure Provider
|
||||||
|
description: Connect to your VMware vCenter or ESXi host to discover virtual machines for migration
|
||||||
|
connectionDetails: Connection Details
|
||||||
|
providerSelect: Provider
|
||||||
|
createNew: Create new provider
|
||||||
|
requirementsTitle: Requirements
|
||||||
|
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Rancher."
|
||||||
|
name: Name
|
||||||
|
urlLabel: vCenter/ESXi URL
|
||||||
|
urlPlaceholder: "https://vcenter.example.com/sdk"
|
||||||
|
urlHint: Enter the full URL including https://
|
||||||
|
skipSsl: Skip SSL certificate verification
|
||||||
|
skipSslHint: Not recommended for production environments
|
||||||
|
testConnection:
|
||||||
|
action: Test Connection
|
||||||
|
success: Connection successful
|
||||||
|
error: Connection failed
|
||||||
|
waiting: Testing connection...
|
||||||
|
testSuccess: Connection test passed
|
||||||
|
testFailed: Connection test failed
|
||||||
|
testMissingFields: Please fill in all required fields before testing
|
||||||
|
testTimeout: Connection test timed out — the provider did not report a status in time
|
||||||
|
save: Save Provider and Continue
|
||||||
|
saveExisting: Check Provider and Continue
|
||||||
|
selectVms:
|
||||||
|
title: Select Virtual Machines
|
||||||
|
description: Select the virtual machines you want to migrate from the source provider
|
||||||
|
discovered: "{count} VMs discovered from"
|
||||||
|
lastSynced: "Last synced {time} ago • "
|
||||||
|
refreshNow: Refresh now
|
||||||
|
availableVms: Available Virtual Machines
|
||||||
|
selected: selected
|
||||||
|
saveSelection: Save Selection and Continue
|
||||||
|
selectAllBanner:
|
||||||
|
pageOnly: "Your current selection includes only VMs currently shown on the page."
|
||||||
|
selectAll: "Select all {count} VMs from this provider"
|
||||||
|
allSelected: "Your current selection includes all VMs."
|
||||||
|
clearSelection: "Clear Selection"
|
||||||
|
manualEntry:
|
||||||
|
title: Add Virtual Machines
|
||||||
|
description: No VMs were discovered from the provider inventory. Add VMs manually by entering their IDs and names.
|
||||||
|
vmId: VM ID
|
||||||
|
vmIdPlaceholder: "e.g. vm-60527"
|
||||||
|
add: Add
|
||||||
|
added: VMs added
|
||||||
|
columns:
|
||||||
|
vmName: VM Name
|
||||||
|
vmId: VM ID
|
||||||
|
os: Operating System
|
||||||
|
resources: Resources
|
||||||
|
powerState: Power State
|
||||||
|
network: Network
|
||||||
|
datastore: Datastore
|
||||||
|
dashboard:
|
||||||
|
title: Virtual Machine Migration Overview
|
||||||
|
description: "Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester."
|
||||||
|
createPlan: Create Migration
|
||||||
|
tableTitle: Migrations
|
||||||
|
columns:
|
||||||
|
status: Status
|
||||||
|
plan: Migration Plan
|
||||||
|
progress: Progress
|
||||||
|
mappings: Mappings
|
||||||
|
duration: Duration
|
||||||
|
progress:
|
||||||
|
failed: Failed
|
||||||
|
migration: Migration
|
||||||
|
initializingMigration: Initializing migration
|
||||||
|
finishedSuccessfully: Finished Successfully
|
||||||
|
step: "Step {index}"
|
||||||
|
vmCount: "{count} VMs"
|
||||||
|
vmId: "id: {id}"
|
||||||
|
configureMappings:
|
||||||
|
title: Set Mappings
|
||||||
|
description: Map VMware networks and datastores to Harvester and Longhorn target resources
|
||||||
|
save: Save Mappings and Continue
|
||||||
|
noTemplate: Start from scratch
|
||||||
|
removeMap: Remove Map
|
||||||
|
networkMapping:
|
||||||
|
title: Network Mapping
|
||||||
|
description: Map VMware port groups to Harvester networks
|
||||||
|
placeholder: Choose a Harvester network
|
||||||
|
template: Use existing network mapping as template
|
||||||
|
options:
|
||||||
|
podNetworking: Management Network
|
||||||
|
ignored: Ignored
|
||||||
|
storageMapping:
|
||||||
|
title: Storage Mapping
|
||||||
|
description: Map VMware datastores to Harvester storage classes
|
||||||
|
placeholder: Choose a Longhorn Storage Volume
|
||||||
|
template: Use existing storage mapping as template
|
||||||
|
reviewMigration:
|
||||||
|
title: Review Migration Plan
|
||||||
|
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
|
||||||
|
planName: Name
|
||||||
|
planNamePlaceholder: "e.g. my-migration-plan"
|
||||||
|
migrationDetails: Migration Details
|
||||||
|
totalVms: Total VMs
|
||||||
|
vcpu: vCPU
|
||||||
|
memory: Memory
|
||||||
|
storage: Storage
|
||||||
|
source: Source
|
||||||
|
targetNamespace: Target Namespace
|
||||||
|
migrationMode: Migration Mode
|
||||||
|
coldMigration: Cold Migration
|
||||||
|
virtualMachines: Virtual Machines
|
||||||
|
warningTitle: "Cold Migration Warning"
|
||||||
|
warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders"
|
||||||
|
startMigration: Start Migration Plan
|
||||||
|
plan:
|
||||||
|
states:
|
||||||
|
error: Error
|
||||||
|
canceled: Canceled
|
||||||
|
inProgress: In Progress
|
||||||
|
active: Succeeded
|
||||||
|
actions:
|
||||||
|
start: Start
|
||||||
|
restart: Restart
|
||||||
|
stop: Stop
|
||||||
|
deleteDialog:
|
||||||
|
warning: Deleting this migration plan will also delete the following related resources
|
||||||
|
|
||||||
vmImport:
|
vmImport:
|
||||||
titles:
|
titles:
|
||||||
basic: Basic
|
basic: Basic
|
||||||
@@ -2422,3 +2641,28 @@ typeLabel:
|
|||||||
one { Virtual Machine Import }
|
one { Virtual Machine Import }
|
||||||
other { Virtual Machine Imports }
|
other { Virtual Machine Imports }
|
||||||
}
|
}
|
||||||
|
forklift.konveyor.io.provider: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Provider }
|
||||||
|
other { Providers }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.networkmap: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Network Map }
|
||||||
|
other { Network Maps }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.storagemap: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Storage Map }
|
||||||
|
other { Storage Maps }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.plan: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Migration Plan }
|
||||||
|
other { Migration Plans }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.migration: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Migration }
|
||||||
|
other { Migrations }
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export default {
|
|||||||
|
|
||||||
devices() {
|
devices() {
|
||||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
const data = this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || [];
|
const data = this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || [];
|
||||||
|
|
||||||
data.forEach((row) => {
|
data.forEach((row) => {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default {
|
|||||||
|
|
||||||
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
|
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
|
||||||
|
|
||||||
return configs;
|
return configs.filter((config) => config.spec?.clusterNetwork !== 'mgmt');
|
||||||
},
|
},
|
||||||
|
|
||||||
vlanConfigSchema() {
|
vlanConfigSchema() {
|
||||||
@@ -121,6 +121,22 @@ export default {
|
|||||||
|
|
||||||
return [...this.rows, ...fakeRows];
|
return [...this.rows, ...fakeRows];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
managementVlanConfigs() {
|
||||||
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
|
||||||
|
|
||||||
|
return configs
|
||||||
|
.filter((vc) => vc.spec?.clusterNetwork === 'mgmt')
|
||||||
|
.map((vc) => ({
|
||||||
|
id: vc.id,
|
||||||
|
node: vc.id.replace('mgmt-vlanconfig-', ''),
|
||||||
|
bondMode: vc.spec?.uplink?.bondOptions?.mode || '-',
|
||||||
|
interfaces: (vc.spec?.uplink?.nics?.length ? vc.spec.uplink.nics.join(', ') : '-'),
|
||||||
|
mtu: vc.spec?.uplink?.linkAttributes?.mtu || '-',
|
||||||
|
}));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
@@ -224,6 +240,29 @@ export default {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="group.key === 'mgmt'"
|
||||||
|
class="mgmt-summary"
|
||||||
|
>
|
||||||
|
<div class="mgmt-header">
|
||||||
|
<div>{{ t('harvester.tableHeaders.vm.node') }}</div>
|
||||||
|
<div>{{ t('harvester.vlanConfig.uplink.bondOptions.mode.label') }}</div>
|
||||||
|
<div>{{ t('harvester.vlanConfig.uplink.nics.label') }}</div>
|
||||||
|
<div>{{ t('harvester.vlanConfig.uplink.linkAttributes.mtu.label') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="config in managementVlanConfigs"
|
||||||
|
:key="config.id"
|
||||||
|
class="mgmt-row"
|
||||||
|
>
|
||||||
|
<div>{{ config.node }}</div>
|
||||||
|
<div>{{ config.bondMode }}</div>
|
||||||
|
<div>{{ config.interfaces }}</div>
|
||||||
|
<div>{{ config.mtu }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template
|
<template
|
||||||
v-for="(clusterNetwork, i) in clusterNetworkWithoutConfigs"
|
v-for="(clusterNetwork, i) in clusterNetworkWithoutConfigs"
|
||||||
@@ -273,6 +312,21 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mgmt-header,
|
||||||
|
.mgmt-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 2fr 2fr 1fr;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mgmt-header {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mgmt-row {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
@@ -255,6 +255,29 @@ export default {
|
|||||||
return this.convertToJson(userData)?.ssh_authorized_keys || [];
|
return this.convertToJson(userData)?.ssh_authorized_keys || [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getSysprepConfig(spec) {
|
||||||
|
const sysprepVolume = spec?.template?.spec?.volumes?.find(
|
||||||
|
(v) => v.name === 'sysprep' && v.sysprep?.secret
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sysprepVolume) {
|
||||||
|
return { secretName: '', xmlContent: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||||
|
const namespace = this.value.metadata.namespace;
|
||||||
|
const secretName = sysprepVolume.sysprep.secret.name;
|
||||||
|
const secret = this.$store.getters[`${ inStore }/byId`](
|
||||||
|
SECRET,
|
||||||
|
`${ namespace }/${ secretName }`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
secretName: `${ namespace }/${ secretName }`,
|
||||||
|
xmlContent: secret?.decodedData?.['autounattend.xml'] || ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
compareSSHValue(a = '', b = '') {
|
compareSSHValue(a = '', b = '') {
|
||||||
const r = /(\r\n\t|\n|\r\t)|(\s*)/gm;
|
const r = /(\r\n\t|\n|\r\t)|(\s*)/gm;
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export default {
|
|||||||
terminationGracePeriodSeconds: '',
|
terminationGracePeriodSeconds: '',
|
||||||
cpuPinning: false,
|
cpuPinning: false,
|
||||||
cpuModel: '',
|
cpuModel: '',
|
||||||
|
sysprep: { secretName: '', xmlContent: '' },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -445,6 +446,17 @@ export default {
|
|||||||
this['diskRows'] = diskRows;
|
this['diskRows'] = diskRows;
|
||||||
this['filesystemRows'] = this.getFilesystemRows(vm);
|
this['filesystemRows'] = this.getFilesystemRows(vm);
|
||||||
|
|
||||||
|
let sysprepConfig = { secretName: '', xmlContent: '' };
|
||||||
|
|
||||||
|
if (osType === 'windows') {
|
||||||
|
sysprepConfig = this.getSysprepConfig(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
this['sysprep'] = {
|
||||||
|
secretName: sysprepConfig.secretName || this.sysprep?.secretName || '',
|
||||||
|
xmlContent: sysprepConfig.xmlContent || this.sysprep?.xmlContent || '',
|
||||||
|
};
|
||||||
|
|
||||||
this.refreshYamlEditor();
|
this.refreshYamlEditor();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -609,7 +621,8 @@ export default {
|
|||||||
|
|
||||||
out = sortBy(out, 'bootOrder');
|
out = sortBy(out, 'bootOrder');
|
||||||
|
|
||||||
return out.filter( (O) => O.name !== 'cloudinitdisk');
|
// Filter out cloudinitdisk and sysprep disk from UI display.
|
||||||
|
return out.filter( (O) => O.name !== 'cloudinitdisk' && O.name !== 'sysprep');
|
||||||
},
|
},
|
||||||
|
|
||||||
getNetworkRows(vm, config) {
|
getNetworkRows(vm, config) {
|
||||||
@@ -617,6 +630,8 @@ export default {
|
|||||||
|
|
||||||
const networks = vm.spec.template.spec.networks || [];
|
const networks = vm.spec.template.spec.networks || [];
|
||||||
const interfaces = vm.spec.template.spec.domain.devices.interfaces || [];
|
const interfaces = vm.spec.template.spec.domain.devices.interfaces || [];
|
||||||
|
const annotations = vm.metadata?.annotations || {};
|
||||||
|
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
|
||||||
|
|
||||||
const out = interfaces.map( (I, index) => {
|
const out = interfaces.map( (I, index) => {
|
||||||
const network = networks.find( (N) => I.name === N.name);
|
const network = networks.find( (N) => I.name === N.name);
|
||||||
@@ -633,6 +648,7 @@ export default {
|
|||||||
newCreateId: (fromTemplate || init) ? randomStr(10) : false,
|
newCreateId: (fromTemplate || init) ? randomStr(10) : false,
|
||||||
model: I.model,
|
model: I.model,
|
||||||
networkName: isPod ? MANAGEMENT_NETWORK : network?.multus?.networkName,
|
networkName: isPod ? MANAGEMENT_NETWORK : network?.multus?.networkName,
|
||||||
|
staticIp: annotations[`${ staticIpPrefix }${ I.name }`] || '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -734,6 +750,8 @@ export default {
|
|||||||
|
|
||||||
const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm;
|
const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm;
|
||||||
|
|
||||||
|
this.syncStaticIpAnnotations(vm);
|
||||||
|
|
||||||
// parse reserved memory
|
// parse reserved memory
|
||||||
if (!this.reservedMemory) {
|
if (!this.reservedMemory) {
|
||||||
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
|
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
|
||||||
@@ -755,6 +773,30 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
syncStaticIpAnnotations(vm) {
|
||||||
|
if (!vm?.metadata) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vm.metadata.annotations) {
|
||||||
|
vm.metadata.annotations = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
|
||||||
|
|
||||||
|
Object.keys(vm.metadata.annotations).forEach((key) => {
|
||||||
|
if (key.startsWith(staticIpPrefix)) {
|
||||||
|
delete vm.metadata.annotations[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.networkRows.forEach((row) => {
|
||||||
|
if (row.name && row.staticIp) {
|
||||||
|
vm.metadata.annotations[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setCPUAndMemory() {
|
setCPUAndMemory() {
|
||||||
if (this.cpuMemoryHotplugEnabled) {
|
if (this.cpuMemoryHotplugEnabled) {
|
||||||
// set CPU
|
// set CPU
|
||||||
@@ -828,6 +870,32 @@ export default {
|
|||||||
this.secretName = this.generateSecretName(this.secretNamePrefix);
|
this.secretName = this.generateSecretName(this.secretNamePrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) {
|
||||||
|
const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.();
|
||||||
|
|
||||||
|
// If we have content but no secret name, it's a new secret that needs a name.
|
||||||
|
if (hasSysprepContent && !this.sysprep.secretName?.trim?.()) {
|
||||||
|
const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep';
|
||||||
|
|
||||||
|
this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve/attach sysprep whenever a secret is selected/known.
|
||||||
|
if (this.sysprep.secretName) {
|
||||||
|
disks.push({
|
||||||
|
name: 'sysprep',
|
||||||
|
cdrom: { bus: 'sata' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
|
||||||
|
|
||||||
|
volumes.push({
|
||||||
|
name: 'sysprep',
|
||||||
|
sysprep: { secret: { name: secretName } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) {
|
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) {
|
||||||
if (!this.isWindows) {
|
if (!this.isWindows) {
|
||||||
disks.push({
|
disks.push({
|
||||||
@@ -836,7 +904,6 @@ export default {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
|
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
|
||||||
|
|
||||||
const cloudinitdisk = {
|
const cloudinitdisk = {
|
||||||
name: 'cloudinitdisk',
|
name: 'cloudinitdisk',
|
||||||
cloudInitNoCloud: {}
|
cloudInitNoCloud: {}
|
||||||
@@ -1482,6 +1549,42 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async saveSysprepConfig(vm) {
|
||||||
|
if (!this.isWindows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.sysprep.secretName?.trim?.() && !this.sysprep.xmlContent?.trim?.()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
|
||||||
|
const namespace = vm.metadata.namespace;
|
||||||
|
const namespacedName = `${ namespace }/${ secretName }`;
|
||||||
|
|
||||||
|
let secret;
|
||||||
|
|
||||||
|
try {
|
||||||
|
secret = await this.$store.dispatch('harvester/find', { type: SECRET, id: namespacedName });
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.status !== 404) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
secret = await this.$store.dispatch('harvester/create', {
|
||||||
|
type: SECRET,
|
||||||
|
metadata: {
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
labels: { [HCI_ANNOTATIONS.WINDOWS_SYSPREP]: 'true' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.setData('autounattend.xml', this.sysprep.xmlContent);
|
||||||
|
await secret.save();
|
||||||
|
},
|
||||||
|
|
||||||
getAccessCredentialsValidation() {
|
getAccessCredentialsValidation() {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
@@ -1685,7 +1788,18 @@ export default {
|
|||||||
const specDevice = specDeviceMap.get(device.name);
|
const specDevice = specDeviceMap.get(device.name);
|
||||||
|
|
||||||
if (specDevice) {
|
if (specDevice) {
|
||||||
return { ...specDevice, ...device };
|
const merged = { ...specDevice, ...device };
|
||||||
|
|
||||||
|
// A disk entry must be either `disk` or `cdrom`, never both.
|
||||||
|
const type = device?.cdrom ? CD_ROM : device?.disk ? HARD_DISK : '';
|
||||||
|
|
||||||
|
if (type === CD_ROM) {
|
||||||
|
delete merged.disk;
|
||||||
|
} else if (type === HARD_DISK) {
|
||||||
|
delete merged.cdrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
return device;
|
return device;
|
||||||
@@ -1733,6 +1847,15 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
|
networkRows: {
|
||||||
|
handler() {
|
||||||
|
const vm = this.resourceType === HCI.VM ? this.value : this.value?.spec?.vm;
|
||||||
|
|
||||||
|
this.syncStaticIpAnnotations(vm);
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
},
|
||||||
|
|
||||||
diskRows: {
|
diskRows: {
|
||||||
handler(neu, old) {
|
handler(neu, old) {
|
||||||
if (Array.isArray(neu)) {
|
if (Array.isArray(neu)) {
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import HarvesterResource from './harvester';
|
||||||
|
import { HCI } from '../types';
|
||||||
|
import { PRODUCT_NAME } from '../config/harvester';
|
||||||
|
import { randomStr } from '@shell/utils/string';
|
||||||
|
|
||||||
|
export default class ForkliftPlan extends HarvesterResource {
|
||||||
|
get listLocation() {
|
||||||
|
return {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
params: {
|
||||||
|
product: this.$rootGetters['productId'],
|
||||||
|
cluster: this.$rootGetters['clusterId'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base HarvesterResource sets `doneOverride` to `listLocation`, which would
|
||||||
|
* force every single-item delete to redirect to the VM Migration dashboard.
|
||||||
|
* Return undefined so the delete flow stays on the current page (list/dashboard)
|
||||||
|
* and only falls back to the shell's default navigation when a now-invalid
|
||||||
|
* detail page is being viewed.
|
||||||
|
*/
|
||||||
|
get doneOverride() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get planFailed() {
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
return conditions.some((c) => c.type === 'Failed' && c.status === 'True');
|
||||||
|
}
|
||||||
|
|
||||||
|
get planCritical() {
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
return conditions.some((c) => c.category === 'Critical' && c.status === 'True');
|
||||||
|
}
|
||||||
|
|
||||||
|
get criticalMessages() {
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
return conditions
|
||||||
|
.filter((c) => c.category === 'Critical' && c.status === 'True')
|
||||||
|
.map((c) => c.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateDescription() {
|
||||||
|
if (this.metadata?.deletionTimestamp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
if (this.planFailed) {
|
||||||
|
const failedMsg = conditions.find((c) => c.type === 'Failed' && c.status === 'True')?.message;
|
||||||
|
|
||||||
|
if (failedMsg) {
|
||||||
|
messages.push(failedMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCritical) {
|
||||||
|
messages.push(...this.criticalMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.length > 0 ? messages.join(' ') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateObj() {
|
||||||
|
return {
|
||||||
|
error: this.planFailed || this.planCritical,
|
||||||
|
transitioning: this.isMigrating || this.isPendingMigrationStart || !!this.metadata?.deletionTimestamp,
|
||||||
|
message: this.stateDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMigrating() {
|
||||||
|
const migration = this.status?.migration;
|
||||||
|
|
||||||
|
return !!migration?.started && !migration?.completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right after creating a plan, controller status can lag briefly.
|
||||||
|
// Treat this window as in-progress so we don't flash a succeeded badge.
|
||||||
|
get isPendingMigrationStart() {
|
||||||
|
const vmCount = this.spec?.vms?.length || 0;
|
||||||
|
|
||||||
|
return vmCount > 0 && !this.planFailed && !this.planCritical && !this.planCanceled && !this.planSucceeded && !this.isMigrating;
|
||||||
|
}
|
||||||
|
|
||||||
|
get planCanceled() {
|
||||||
|
const history = this.status?.migration?.history || [];
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = history[history.length - 1];
|
||||||
|
const conditions = latest.conditions || [];
|
||||||
|
|
||||||
|
return conditions.some((c) => c.type === 'Canceled' && c.status === 'True');
|
||||||
|
}
|
||||||
|
|
||||||
|
get planSucceeded() {
|
||||||
|
const migration = this.status?.migration;
|
||||||
|
|
||||||
|
return !!migration?.completed && !this.planFailed && !this.planCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateDisplay() {
|
||||||
|
if (this.metadata?.deletionTimestamp) {
|
||||||
|
return 'Terminating';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planFailed) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCritical) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCanceled) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.canceled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMigrating) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPendingMigrationStart) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.active');
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateBackground() {
|
||||||
|
if (this.metadata?.deletionTimestamp) {
|
||||||
|
return 'bg-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planFailed) {
|
||||||
|
return 'bg-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCritical) {
|
||||||
|
return 'bg-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCanceled) {
|
||||||
|
return 'bg-warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMigrating) {
|
||||||
|
return 'bg-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPendingMigrationStart) {
|
||||||
|
return 'bg-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-success';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isForkliftDashboard() {
|
||||||
|
const route = this.currentRouter()?.currentRoute?.value;
|
||||||
|
|
||||||
|
return route?.name?.endsWith('-vm-migration');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _availableActions() {
|
||||||
|
const canStop = this.isMigrating && !this.planCanceled;
|
||||||
|
const canStart = !this.planSucceeded && (!this.isMigrating || this.planFailed || this.planCanceled || this.planCritical);
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
if (canStart) {
|
||||||
|
out.push({
|
||||||
|
action: 'startMigration',
|
||||||
|
enabled: true,
|
||||||
|
icon: 'icon icon-play',
|
||||||
|
label: this.isForkliftDashboard ? this.t('harvester.addons.vmMigration.plan.actions.restart') : this.t('harvester.addons.vmMigration.plan.actions.start'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canStop) {
|
||||||
|
out.push({
|
||||||
|
action: 'stopMigration',
|
||||||
|
enabled: true,
|
||||||
|
icon: 'icon icon-pause',
|
||||||
|
label: this.t('harvester.addons.vmMigration.plan.actions.stop'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isForkliftDashboard) {
|
||||||
|
out.push({
|
||||||
|
action: 'promptRemove',
|
||||||
|
altAction: 'remove',
|
||||||
|
label: this.t('action.remove'),
|
||||||
|
icon: 'icon icon-trash',
|
||||||
|
bulkable: true,
|
||||||
|
enabled: this.canDelete,
|
||||||
|
bulkAction: 'promptRemove',
|
||||||
|
weight: -10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
} else {
|
||||||
|
out.push(...super._availableActions);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopMigration() {
|
||||||
|
const history = this.status?.migration?.history || [];
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = history[history.length - 1];
|
||||||
|
const migrationName = latest.migration?.name;
|
||||||
|
|
||||||
|
if (migrationName) {
|
||||||
|
const selfUrl = this.linkFor('self');
|
||||||
|
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ migrationName }`);
|
||||||
|
|
||||||
|
await this.$dispatch('request', { url, method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startMigration() {
|
||||||
|
const namespace = this.metadata.namespace;
|
||||||
|
const history = this.status?.migration?.history || [];
|
||||||
|
|
||||||
|
// Delete previous migrations before starting a new one
|
||||||
|
for (const entry of history) {
|
||||||
|
const name = entry.migration?.name;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
const selfUrl = this.linkFor('self');
|
||||||
|
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ name }`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.$dispatch('request', { url, method: 'DELETE' });
|
||||||
|
} catch (e) {
|
||||||
|
// Migration may already be gone — ignore 404s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const migration = await this.$dispatch('create', {
|
||||||
|
type: HCI.FORKLIFT_MIGRATION,
|
||||||
|
metadata: {
|
||||||
|
name: `${ this.metadata.name }-migration-${ randomStr(5).toLowerCase() }`,
|
||||||
|
namespace,
|
||||||
|
ownerReferences: [
|
||||||
|
{
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Plan',
|
||||||
|
name: this.metadata.name,
|
||||||
|
uid: this.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
plan: {
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Plan',
|
||||||
|
name: this.metadata.name,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await migration.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlan() {
|
||||||
|
await this.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import HarvesterResource from './harvester';
|
||||||
|
import { PRODUCT_NAME } from '../config/harvester';
|
||||||
|
|
||||||
|
export default class ForkliftProvider extends HarvesterResource {
|
||||||
|
get _createLocation() {
|
||||||
|
return {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
params: {
|
||||||
|
product: this.$rootGetters['productId'],
|
||||||
|
cluster: this.$rootGetters['clusterId'],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get _editLocation() {
|
||||||
|
return {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
params: {
|
||||||
|
product: this.$rootGetters['productId'],
|
||||||
|
cluster: this.$rootGetters['clusterId'],
|
||||||
|
},
|
||||||
|
query: { providerId: `${ this.metadata.namespace }/${ this.metadata.name }` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a Provider cascades via ownerReferences set at creation time.
|
||||||
|
* Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap.
|
||||||
|
* Use foreground propagation to ensure children are deleted before the parent.
|
||||||
|
*/
|
||||||
|
remove(opt = {}) {
|
||||||
|
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
|
||||||
|
|
||||||
|
return this._remove(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "harvester",
|
"name": "harvester",
|
||||||
"description": "Rancher UI Extension for Harvester",
|
"description": "Rancher UI Extension for Harvester",
|
||||||
"version": "1.9.0-dev",
|
"version": "1.9.0-rc2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"rancher": {
|
"rancher": {
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"catalog.cattle.io/display-name": "Harvester",
|
"catalog.cattle.io/display-name": "Harvester",
|
||||||
"catalog.cattle.io/kube-version": ">= 1.16.0-0",
|
"catalog.cattle.io/kube-version": ">= 1.16.0-0",
|
||||||
"catalog.cattle.io/rancher-version": ">= 2.14.0-0",
|
"catalog.cattle.io/rancher-version": ">= 2.15.0-0",
|
||||||
"catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0"
|
"catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import Masthead from '@shell/components/ResourceList/Masthead';
|
||||||
|
import ResourceTable from '@shell/components/ResourceTable';
|
||||||
|
import PercentageBar from '@shell/components/PercentageBar';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import MappingsCell from '../../../../components/MappingsCell';
|
||||||
|
import { SCHEMA } from '@shell/config/types';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { HCI } from '../../../../types';
|
||||||
|
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||||
|
import { ADD_ONS } from '../../../../config/harvester-map';
|
||||||
|
import { currentRouter } from '../../../../utils/router';
|
||||||
|
import { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers';
|
||||||
|
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
id: HCI.FORKLIFT_PLAN,
|
||||||
|
type: SCHEMA,
|
||||||
|
attributes: {
|
||||||
|
kind: HCI.FORKLIFT_PLAN,
|
||||||
|
namespaced: true
|
||||||
|
},
|
||||||
|
metadata: { name: HCI.FORKLIFT_PLAN },
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const errors = ref([]);
|
||||||
|
|
||||||
|
const inStore = computed(() => store.getters['currentProduct'].inStore);
|
||||||
|
|
||||||
|
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
|
||||||
|
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
|
||||||
|
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
|
||||||
|
|
||||||
|
const escapeHtml = (value = '') => String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
|
const getPipelineStepMeta = (step = {}) => {
|
||||||
|
const phase = String(step.phase || '').toLowerCase();
|
||||||
|
const hasProgress = typeof step.progress?.completed === 'number' && typeof step.progress?.total === 'number' && step.progress.total > 0;
|
||||||
|
const progressPct = hasProgress ? Math.round((step.progress.completed / step.progress.total) * 1000) / 10 : null;
|
||||||
|
|
||||||
|
if (phase === 'completed' || phase === 'succeeded') {
|
||||||
|
return {
|
||||||
|
icon: '✓',
|
||||||
|
color: 'var(--success)',
|
||||||
|
text: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'running' || phase === 'inprogress' || phase === 'progressing' || phase === 'started' || (hasProgress && progressPct > 0)) {
|
||||||
|
return {
|
||||||
|
icon: '↻',
|
||||||
|
color: 'var(--primary)',
|
||||||
|
text: progressPct !== null ? `${ step.phase || t('generic.inProgress') } (${ progressPct }%)` : (step.phase || t('generic.inProgress'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'failed' || phase === 'error') {
|
||||||
|
return {
|
||||||
|
icon: '✕',
|
||||||
|
color: 'var(--error)',
|
||||||
|
text: step.phase || t('harvester.addons.vmMigration.dashboard.progress.failed')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: '○',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
text: ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPipelineTooltip = (pipeline = []) => {
|
||||||
|
if (!pipeline.length) {
|
||||||
|
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:var(--muted);font-weight:700;">○</span><span>${ escapeHtml(t('harvester.addons.vmMigration.dashboard.progress.initializingMigration')) }</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pipeline
|
||||||
|
.map((step, idx) => {
|
||||||
|
const stepName = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
|
||||||
|
const meta = getPipelineStepMeta(step);
|
||||||
|
const lineText = meta.text ? `${ escapeHtml(stepName) } : ${ escapeHtml(meta.text) }` : escapeHtml(stepName);
|
||||||
|
|
||||||
|
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:${ meta.color };font-weight:700;">${ meta.icon }</span><span>${ lineText }</span></div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (started, completed) => {
|
||||||
|
if (!started || !completed) {
|
||||||
|
return { display: null, seconds: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = new Date(started).getTime();
|
||||||
|
const endMs = new Date(completed).getTime();
|
||||||
|
|
||||||
|
if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) {
|
||||||
|
return { display: null, seconds: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSeconds = Math.floor((endMs - startMs) / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
const display = hours > 0 ? `${ hours }h ${ minutes }m` : `${ minutes }m ${ seconds }s`;
|
||||||
|
|
||||||
|
return { display, seconds: totalSeconds };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
return allPlans.value.map((plan) => {
|
||||||
|
const netMapName = plan.spec?.map?.network?.name;
|
||||||
|
const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace;
|
||||||
|
const storMapName = plan.spec?.map?.storage?.name;
|
||||||
|
const storMapNs = plan.spec?.map?.storage?.namespace || plan.metadata.namespace;
|
||||||
|
|
||||||
|
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
|
||||||
|
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
|
||||||
|
|
||||||
|
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.generic.podNetwork') : (e.destination?.name || '-') }`);
|
||||||
|
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.storageClass || '-' }`);
|
||||||
|
|
||||||
|
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
|
||||||
|
|
||||||
|
const vms = plan.status?.migration?.vms || [];
|
||||||
|
|
||||||
|
plan.vmProgress = vms.map((vm) => {
|
||||||
|
const pipeline = vm.pipeline || [];
|
||||||
|
const totalSteps = pipeline.length || 1;
|
||||||
|
let overallProgress = 0;
|
||||||
|
let currentStep = '';
|
||||||
|
let errorMsg = '';
|
||||||
|
|
||||||
|
pipeline.forEach((step, idx) => {
|
||||||
|
const stepWeight = 100 / totalSteps;
|
||||||
|
|
||||||
|
if (step.phase === 'Completed') {
|
||||||
|
overallProgress += stepWeight;
|
||||||
|
} else {
|
||||||
|
const stepPct = (step.progress?.completed && step.progress?.total) ? (step.progress.completed / step.progress.total) * 100 : 0;
|
||||||
|
|
||||||
|
overallProgress += (stepPct / 100) * stepWeight;
|
||||||
|
|
||||||
|
if (!currentStep) {
|
||||||
|
currentStep = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.error && !errorMsg) {
|
||||||
|
const reasons = (step.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.plan.states.error');
|
||||||
|
|
||||||
|
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ reasons }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.phase === 'Failed' && !errorMsg) {
|
||||||
|
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback to VM-level error if no step-level error found
|
||||||
|
if (!errorMsg && vm.error) {
|
||||||
|
const reasons = (vm.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.dashboard.progress.failed');
|
||||||
|
|
||||||
|
errorMsg = `${ currentStep || vm.error.phase || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ reasons }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
overallProgress = Math.round(overallProgress * 10) / 10;
|
||||||
|
|
||||||
|
// If no step-level error but the plan itself is failed, surface it
|
||||||
|
if (!errorMsg && plan.planFailed) {
|
||||||
|
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
vmId: vm.id || '',
|
||||||
|
progress: overallProgress,
|
||||||
|
currentStep,
|
||||||
|
pipelineTooltip: formatPipelineTooltip(pipeline),
|
||||||
|
errorMsg,
|
||||||
|
canceled: plan.planCanceled,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no migration progress but we have VMs in the spec, show them at 0%
|
||||||
|
if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) {
|
||||||
|
plan.vmProgress = plan.spec.vms.map((vm) => ({
|
||||||
|
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
vmId: vm.id || '',
|
||||||
|
progress: 0,
|
||||||
|
currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'),
|
||||||
|
pipelineTooltip: formatPipelineTooltip([]),
|
||||||
|
errorMsg: '',
|
||||||
|
canceled: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0;
|
||||||
|
|
||||||
|
const durationResult = formatDuration(plan.status?.migration?.started, plan.status?.migration?.completed);
|
||||||
|
|
||||||
|
plan.duration = durationResult.display;
|
||||||
|
plan.durationSeconds = durationResult.seconds;
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createLocation = computed(() => ({
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
|
||||||
|
{
|
||||||
|
...NAME_COL,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
|
||||||
|
},
|
||||||
|
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
|
||||||
|
{
|
||||||
|
name: 'progress',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
|
||||||
|
value: 'progress',
|
||||||
|
width: 450,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mappings',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'duration',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration',
|
||||||
|
value: 'duration',
|
||||||
|
sort: 'durationSeconds',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{ ...AGE },
|
||||||
|
];
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
try {
|
||||||
|
// Guard: the Forklift dashboard is only available when the Forklift addon is
|
||||||
|
// enabled. Redirect back to the Harvester dashboard otherwise (e.g. direct URL).
|
||||||
|
let forkliftEnabled = false;
|
||||||
|
|
||||||
|
if (store.getters[`${ inStore.value }/schemaFor`](HCI.ADD_ONS)) {
|
||||||
|
const addons = await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.ADD_ONS });
|
||||||
|
|
||||||
|
forkliftEnabled = addons.find((a) => a.metadata?.name === ADD_ONS.FORKLIFT_OPERATOR)?.spec?.enabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forkliftEnabled) {
|
||||||
|
await currentRouter().replace({
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
resource: HCI.DASHBOARD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }),
|
||||||
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
|
||||||
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div v-else>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<Masthead
|
||||||
|
:schema="schema"
|
||||||
|
:resource="schema.id"
|
||||||
|
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
|
||||||
|
>
|
||||||
|
<template #subHeader>
|
||||||
|
<div class="mmt-5">
|
||||||
|
<p
|
||||||
|
v-clean-html="t('harvester.addons.vmMigration.dashboard.description')"
|
||||||
|
class="text-muted"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #createButton>
|
||||||
|
<router-link
|
||||||
|
:to="createLocation"
|
||||||
|
class="btn role-primary"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</Masthead>
|
||||||
|
|
||||||
|
<ResourceTable
|
||||||
|
:schema="schema"
|
||||||
|
:rows="rows"
|
||||||
|
:headers="headers"
|
||||||
|
:groupable="false"
|
||||||
|
:table-actions="false"
|
||||||
|
:search="false"
|
||||||
|
key-field="_key"
|
||||||
|
>
|
||||||
|
<template #cell:name="{ row }">
|
||||||
|
<div class="plan-name-cell">
|
||||||
|
<router-link
|
||||||
|
v-if="row.detailLocation"
|
||||||
|
:to="row.detailLocation"
|
||||||
|
class="plan-name"
|
||||||
|
>
|
||||||
|
{{ row.metadata.name }}
|
||||||
|
</router-link>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="plan-name"
|
||||||
|
>
|
||||||
|
{{ row.metadata.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:vmCount="{ row }">
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.progress.vmCount', { count: (row.spec.vms || []).length }) }}
|
||||||
|
</template>
|
||||||
|
<template #cell:progress="{ row }">
|
||||||
|
<div
|
||||||
|
v-if="row.vmProgress && row.vmProgress.length"
|
||||||
|
class="progress-cells"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="vm in row.vmProgress"
|
||||||
|
:key="vm.vmId"
|
||||||
|
class="vm-progress"
|
||||||
|
>
|
||||||
|
<div class="vm-progress-header">
|
||||||
|
<div class="vm-name-block">
|
||||||
|
<span class="vm-name">{{ vm.vmName }}</span>
|
||||||
|
<span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vm-pct-block">
|
||||||
|
<div
|
||||||
|
v-clean-tooltip="{ content: vm.pipelineTooltip, html: true }"
|
||||||
|
class="vm-bar"
|
||||||
|
>
|
||||||
|
<PercentageBar
|
||||||
|
:model-value="vm.progress"
|
||||||
|
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
|
||||||
|
preferred-direction="MORE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="vm.progress >= 100"
|
||||||
|
class="step-label text-muted"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.progress.finishedSuccessfully') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="vm.errorMsg"
|
||||||
|
class="step-label text-error"
|
||||||
|
>
|
||||||
|
{{ vm.errorMsg }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="vm.canceled"
|
||||||
|
class="step-label text-muted"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.plan.states.canceled') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="vm.currentStep"
|
||||||
|
class="step-label text-muted"
|
||||||
|
>
|
||||||
|
{{ vm.currentStep }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-if="!row.vmProgress.length">-</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:mappings="{ row }">
|
||||||
|
<MappingsCell
|
||||||
|
:network-entries="row.networkEntries"
|
||||||
|
:storage-entries="row.storageEntries"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell:duration="{ row }">
|
||||||
|
<span v-if="row.duration">{{ row.duration }}</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-muted"
|
||||||
|
>—</span>
|
||||||
|
</template>
|
||||||
|
</ResourceTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.plan-name-cell {
|
||||||
|
.plan-name {
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-vms {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-cells {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-id {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pct {
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pct-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-bar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&.text-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref, computed, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import CruResource from '@shell/components/CruResource';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import { SECRET } from '@shell/config/types';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
|
||||||
|
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
|
||||||
|
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
|
||||||
|
import { currentRouter, currentRoute } from '@pkg/harvester/utils/router';
|
||||||
|
import { decodeSecretValue } from '@pkg/harvester/utils/forklift';
|
||||||
|
import { HCI } from '@pkg/harvester/types';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const route = currentRoute();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const cruRef = ref(null);
|
||||||
|
const providerStepRef = ref(null);
|
||||||
|
const mappingsStepRef = ref(null);
|
||||||
|
|
||||||
|
const providerName = ref('');
|
||||||
|
const provider = ref(null);
|
||||||
|
const errors = ref([]);
|
||||||
|
|
||||||
|
const providerReady = ref(false);
|
||||||
|
const providerFormValid = ref(false);
|
||||||
|
const providerTesting = ref(false);
|
||||||
|
const mappingsReady = ref(false);
|
||||||
|
|
||||||
|
const existingNetworkMap = ref(null);
|
||||||
|
const existingStorageMap = ref(null);
|
||||||
|
const initialLoading = ref(true);
|
||||||
|
|
||||||
|
const dummyResource = ref({ save: () => Promise.resolve() });
|
||||||
|
|
||||||
|
const providerId = route.query?.providerId || null;
|
||||||
|
const isEditMode = !!providerId;
|
||||||
|
|
||||||
|
const stepData = reactive({
|
||||||
|
provider: {
|
||||||
|
selectedProvider: isEditMode ? '' : '__create_new__',
|
||||||
|
providerName: '',
|
||||||
|
url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
skipTlsVerify: false,
|
||||||
|
testPassed: false,
|
||||||
|
testResult: null,
|
||||||
|
testError: null,
|
||||||
|
createdProvider: null,
|
||||||
|
createdSecret: null,
|
||||||
|
},
|
||||||
|
mappings: {
|
||||||
|
networkEntries: [],
|
||||||
|
storageEntries: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = reactive([
|
||||||
|
{
|
||||||
|
name: 'configure-provider',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'configure-mappings',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
watch([providerFormValid, providerTesting], () => {
|
||||||
|
steps[0].ready = providerFormValid.value && !providerTesting.value;
|
||||||
|
}, { immediate: true });
|
||||||
|
watch(mappingsReady, (val) => {
|
||||||
|
steps[1].ready = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
|
||||||
|
// "test connection before advancing" handshake, since the provider must be verified
|
||||||
|
// before the user can leave the first step.
|
||||||
|
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
|
||||||
|
|
||||||
|
const pendingProceed = ref(false);
|
||||||
|
|
||||||
|
// Handshake to gate step 1 -> 2 on a successful connection test:
|
||||||
|
// 1. When the user tries to advance off the provider step before it's ready,
|
||||||
|
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
|
||||||
|
// programmatically trigger the async "Test connection" button.
|
||||||
|
// 2. Once the test flips `providerReady`, mark the step ready and advance
|
||||||
|
// (goToStep(2)). If the test fails, the user simply stays on the provider step.
|
||||||
|
watch(
|
||||||
|
() => wizardComponent.value?.activeStepIndex,
|
||||||
|
(newIdx, oldIdx) => {
|
||||||
|
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
|
||||||
|
wizardComponent.value.goToStep(1);
|
||||||
|
pendingProceed.value = true;
|
||||||
|
providerStepRef.value.clickTestButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(providerReady, (val) => {
|
||||||
|
if (val && pendingProceed.value) {
|
||||||
|
pendingProceed.value = false;
|
||||||
|
steps[0].ready = true;
|
||||||
|
wizardComponent.value.goToStep(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const wizardTitle = computed(() => {
|
||||||
|
return isEditMode ? t('harvester.addons.vmMigration.providerWizard.editTitle') : t('harvester.addons.vmMigration.providerWizard.title');
|
||||||
|
});
|
||||||
|
|
||||||
|
const onProviderComplete = (data) => {
|
||||||
|
providerName.value = data.providerName;
|
||||||
|
provider.value = data.provider;
|
||||||
|
providerReady.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderFormValid = (valid) => {
|
||||||
|
providerFormValid.value = valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderTesting = (testing) => {
|
||||||
|
providerTesting.value = testing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderReady = (ready) => {
|
||||||
|
providerReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMappingsReady = (ready) => {
|
||||||
|
mappingsReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear mappings when provider changes
|
||||||
|
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
|
||||||
|
if (oldVal && newVal !== oldVal) {
|
||||||
|
stepData.mappings.networkEntries = [];
|
||||||
|
stepData.mappings.storageEntries = [];
|
||||||
|
mappingsReady.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerListLocation = {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
resource: HCI.FORKLIFT_PROVIDER,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async(buttonCb) => {
|
||||||
|
try {
|
||||||
|
await mappingsStepRef.value.saveMappings();
|
||||||
|
buttonCb(true);
|
||||||
|
currentRouter().push(providerListLocation);
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = [err instanceof Error ? err.message : String(err)];
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
currentRouter().push(providerListLocation);
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
if (!isEditMode) {
|
||||||
|
initialLoading.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchedProvider = await store.dispatch(`${ inStore }/find`, {
|
||||||
|
type: HCI.FORKLIFT_PROVIDER,
|
||||||
|
id: providerId,
|
||||||
|
opt: { force: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
provider.value = fetchedProvider;
|
||||||
|
providerName.value = fetchedProvider.metadata.name;
|
||||||
|
|
||||||
|
stepData.provider.selectedProvider = fetchedProvider.metadata.name;
|
||||||
|
stepData.provider.providerName = fetchedProvider.metadata.name;
|
||||||
|
stepData.provider.url = fetchedProvider.spec?.url || '';
|
||||||
|
stepData.provider.createdProvider = fetchedProvider;
|
||||||
|
|
||||||
|
const secretRef = fetchedProvider.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef) {
|
||||||
|
const allSecrets = await store.dispatch(`${ inStore }/findAll`, {
|
||||||
|
type: SECRET,
|
||||||
|
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secret = allSecrets.find(
|
||||||
|
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secret?.data) {
|
||||||
|
stepData.provider.username = decodeSecretValue(secret.data.user);
|
||||||
|
stepData.provider.password = decodeSecretValue(secret.data.password);
|
||||||
|
stepData.provider.skipTlsVerify = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
|
||||||
|
stepData.provider.createdSecret = secret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing default maps for this provider
|
||||||
|
try {
|
||||||
|
const allNetworkMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
|
||||||
|
const allStorageMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
|
||||||
|
|
||||||
|
existingNetworkMap.value = allNetworkMaps.find(
|
||||||
|
(nm) => nm.metadata.name === `${ fetchedProvider.metadata.name }-network-map-default`
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
existingStorageMap.value = allStorageMaps.find(
|
||||||
|
(sm) => sm.metadata.name === `${ fetchedProvider.metadata.name }-storage-map-default`
|
||||||
|
) || null;
|
||||||
|
} catch (e) {
|
||||||
|
// Maps may not exist yet
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || err })];
|
||||||
|
}
|
||||||
|
|
||||||
|
initialLoading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="initialLoading" />
|
||||||
|
<CruResource
|
||||||
|
v-else
|
||||||
|
ref="cruRef"
|
||||||
|
:resource="dummyResource"
|
||||||
|
:mode="isEditMode ? 'edit' : 'create'"
|
||||||
|
:steps="steps"
|
||||||
|
:errors="errors"
|
||||||
|
:validation-passed="true"
|
||||||
|
:can-yaml="false"
|
||||||
|
:cancel-event="true"
|
||||||
|
:title="wizardTitle"
|
||||||
|
finish-mode="finish"
|
||||||
|
class="wizard"
|
||||||
|
@cancel="onCancel"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<template #configure-provider>
|
||||||
|
<ConfigureProviderStep
|
||||||
|
ref="providerStepRef"
|
||||||
|
:step-data="stepData.provider"
|
||||||
|
:create-only="true"
|
||||||
|
:edit-mode="isEditMode"
|
||||||
|
@complete="onProviderComplete"
|
||||||
|
@ready="onProviderReady"
|
||||||
|
@form-valid="onProviderFormValid"
|
||||||
|
@testing="onProviderTesting"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #configure-mappings>
|
||||||
|
<ConfigureMappingsStep
|
||||||
|
ref="mappingsStepRef"
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:step-data="stepData.mappings"
|
||||||
|
:use-all-provider-data="true"
|
||||||
|
:existing-network-map="existingNetworkMap"
|
||||||
|
:existing-storage-map="existingStorageMap"
|
||||||
|
@ready="onMappingsReady"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CruResource>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref, computed, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import CruResource from '@shell/components/CruResource';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
|
||||||
|
import SelectVmsStep from '@pkg/harvester/components/vm-migration/SelectVmsStep.vue';
|
||||||
|
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
|
||||||
|
import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue';
|
||||||
|
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
|
||||||
|
import { currentRouter } from '@pkg/harvester/utils/router';
|
||||||
|
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const cruRef = ref(null);
|
||||||
|
const providerStepRef = ref(null);
|
||||||
|
const reviewStepRef = ref(null);
|
||||||
|
|
||||||
|
const providerName = ref('');
|
||||||
|
const provider = ref(null);
|
||||||
|
const selectedVMs = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
|
||||||
|
const providerReady = ref(false);
|
||||||
|
const providerFormValid = ref(false);
|
||||||
|
const providerTesting = ref(false);
|
||||||
|
const vmsReady = ref(false);
|
||||||
|
const vmsLoading = ref(true);
|
||||||
|
const mappingsReady = ref(false);
|
||||||
|
const reviewReady = ref(false);
|
||||||
|
|
||||||
|
const dummyResource = ref({ save: () => Promise.resolve() });
|
||||||
|
|
||||||
|
const stepData = reactive({
|
||||||
|
provider: {
|
||||||
|
selectedProvider: '__create_new__',
|
||||||
|
providerName: '',
|
||||||
|
url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
skipTlsVerify: false,
|
||||||
|
testPassed: false,
|
||||||
|
testResult: null,
|
||||||
|
testError: null,
|
||||||
|
createdProvider: null,
|
||||||
|
createdSecret: null,
|
||||||
|
},
|
||||||
|
vms: {
|
||||||
|
discoveredVMs: [],
|
||||||
|
selectedVMIds: new Set(),
|
||||||
|
tableRows: [],
|
||||||
|
},
|
||||||
|
mappings: {
|
||||||
|
networkEntries: [],
|
||||||
|
storageEntries: [],
|
||||||
|
},
|
||||||
|
review: { planName: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = reactive([
|
||||||
|
{
|
||||||
|
name: 'configure-provider',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'select-vms',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.selectVms.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.selectVms.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'configure-mappings',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'review-migration',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
watch([providerFormValid, providerTesting], () => {
|
||||||
|
steps[0].ready = providerFormValid.value && !providerTesting.value;
|
||||||
|
}, { immediate: true });
|
||||||
|
watch([vmsReady, vmsLoading], () => {
|
||||||
|
steps[1].ready = vmsReady.value && !vmsLoading.value;
|
||||||
|
});
|
||||||
|
watch(mappingsReady, (val) => {
|
||||||
|
steps[2].ready = val;
|
||||||
|
});
|
||||||
|
watch(reviewReady, (val) => {
|
||||||
|
steps[3].ready = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
|
||||||
|
// "test connection before advancing" handshake, since the provider must be verified
|
||||||
|
// before the user can leave the first step.
|
||||||
|
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
|
||||||
|
|
||||||
|
const pendingProceed = ref(false);
|
||||||
|
|
||||||
|
// Handshake to gate step 1 -> 2 on a successful connection test:
|
||||||
|
// 1. When the user tries to advance off the provider step before it's ready,
|
||||||
|
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
|
||||||
|
// programmatically trigger the async "Test connection" button.
|
||||||
|
// 2. Once the test flips `providerReady` (watcher below), mark the step ready and
|
||||||
|
// advance to the next step (goToStep(2)). If the test fails, `providerTesting`
|
||||||
|
// watcher clears `pendingProceed` and the user stays put.
|
||||||
|
watch(
|
||||||
|
() => wizardComponent.value?.activeStepIndex,
|
||||||
|
(newIdx, oldIdx) => {
|
||||||
|
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
|
||||||
|
wizardComponent.value.goToStep(1);
|
||||||
|
pendingProceed.value = true;
|
||||||
|
providerStepRef.value.clickTestButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(providerReady, (val) => {
|
||||||
|
if (val && pendingProceed.value) {
|
||||||
|
pendingProceed.value = false;
|
||||||
|
steps[0].ready = true;
|
||||||
|
wizardComponent.value.goToStep(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(providerTesting, (testing) => {
|
||||||
|
if (!testing && pendingProceed.value && !providerReady.value) {
|
||||||
|
pendingProceed.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onProviderComplete = (data) => {
|
||||||
|
providerName.value = data.providerName;
|
||||||
|
provider.value = data.provider;
|
||||||
|
providerReady.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderFormValid = (valid) => {
|
||||||
|
providerFormValid.value = valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderTesting = (testing) => {
|
||||||
|
providerTesting.value = testing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderReady = (ready) => {
|
||||||
|
providerReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVmsComplete = (data) => {
|
||||||
|
selectedVMs.value = data.selectedVMs;
|
||||||
|
vmsReady.value = data.selectedVMs.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVmsLoading = (val) => {
|
||||||
|
vmsLoading.value = val;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMappingsReady = (ready) => {
|
||||||
|
mappingsReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReviewReady = (ready) => {
|
||||||
|
reviewReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear downstream data when provider changes
|
||||||
|
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
|
||||||
|
if (oldVal && newVal !== oldVal) {
|
||||||
|
stepData.vms.discoveredVMs = [];
|
||||||
|
stepData.vms.selectedVMIds = new Set();
|
||||||
|
stepData.vms.tableRows = [];
|
||||||
|
selectedVMs.value = [];
|
||||||
|
vmsReady.value = false;
|
||||||
|
|
||||||
|
stepData.mappings.networkEntries = [];
|
||||||
|
stepData.mappings.storageEntries = [];
|
||||||
|
mappingsReady.value = false;
|
||||||
|
|
||||||
|
stepData.review.planName = '';
|
||||||
|
reviewReady.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear mappings and review when VM selection changes
|
||||||
|
watch(selectedVMs, (newVal, oldVal) => {
|
||||||
|
const newIds = new Set(newVal.map((v) => v.id));
|
||||||
|
const oldIds = new Set(oldVal.map((v) => v.id));
|
||||||
|
const changed = newIds.size !== oldIds.size || [...newIds].some((id) => !oldIds.has(id));
|
||||||
|
|
||||||
|
if (oldVal.length > 0 && changed) {
|
||||||
|
stepData.mappings.networkEntries = [];
|
||||||
|
stepData.mappings.storageEntries = [];
|
||||||
|
mappingsReady.value = false;
|
||||||
|
|
||||||
|
stepData.review.planName = '';
|
||||||
|
reviewReady.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const migrationListLocation = {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async(buttonCb) => {
|
||||||
|
try {
|
||||||
|
await reviewStepRef.value.startMigration();
|
||||||
|
buttonCb(true);
|
||||||
|
currentRouter().push(migrationListLocation);
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = exceptionToErrorsArray(err).map((e) => {
|
||||||
|
if (typeof e === 'string') {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringify(e);
|
||||||
|
});
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
currentRouter().push(migrationListLocation);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CruResource
|
||||||
|
ref="cruRef"
|
||||||
|
:resource="dummyResource"
|
||||||
|
:mode="'create'"
|
||||||
|
:steps="steps"
|
||||||
|
:errors="errors"
|
||||||
|
:validation-passed="true"
|
||||||
|
:can-yaml="false"
|
||||||
|
:cancel-event="true"
|
||||||
|
finish-mode="finish"
|
||||||
|
finish-button-mode="createAndStart"
|
||||||
|
class="wizard"
|
||||||
|
@cancel="onCancel"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<template #configure-provider>
|
||||||
|
<ConfigureProviderStep
|
||||||
|
ref="providerStepRef"
|
||||||
|
:step-data="stepData.provider"
|
||||||
|
@complete="onProviderComplete"
|
||||||
|
@ready="onProviderReady"
|
||||||
|
@form-valid="onProviderFormValid"
|
||||||
|
@testing="onProviderTesting"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #select-vms>
|
||||||
|
<SelectVmsStep
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:step-data="stepData.vms"
|
||||||
|
@complete="onVmsComplete"
|
||||||
|
@loading="onVmsLoading"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #configure-mappings>
|
||||||
|
<ConfigureMappingsStep
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:selected-vms="selectedVMs"
|
||||||
|
:step-data="stepData.mappings"
|
||||||
|
@ready="onMappingsReady"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #review-migration>
|
||||||
|
<ReviewMigrationStep
|
||||||
|
ref="reviewStepRef"
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:selected-vms="selectedVMs"
|
||||||
|
:mapping-entries="stepData.mappings"
|
||||||
|
:step-data="stepData.review"
|
||||||
|
@ready="onReviewReady"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CruResource>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
<script>
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { mapState, mapGetters } from 'vuex';
|
||||||
|
import { resourceNames } from '@shell/utils/string';
|
||||||
|
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||||
|
import { HCI } from '../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'PromptRemoveForkliftPlanDialog',
|
||||||
|
|
||||||
|
emits: ['errors'],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
|
||||||
|
names: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
|
||||||
|
close: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
|
||||||
|
doneLocation: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
errors: [],
|
||||||
|
removing: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState('action-menu', ['toRemove']),
|
||||||
|
...mapGetters({ t: 'i18n/t' }),
|
||||||
|
|
||||||
|
existingRelatedResources() {
|
||||||
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
return {
|
||||||
|
[HCI.FORKLIFT_MIGRATION]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_MIGRATION) || [],
|
||||||
|
[HCI.FORKLIFT_NETWORK_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [],
|
||||||
|
[HCI.FORKLIFT_STORAGE_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
resourceType() {
|
||||||
|
return this.t('typeLabel."forklift.konveyor.io.plan"', { count: this.names?.length || 1 });
|
||||||
|
},
|
||||||
|
|
||||||
|
relatedSummary() {
|
||||||
|
const existing = this.existingRelatedResources;
|
||||||
|
|
||||||
|
return (this.value || []).map((plan) => {
|
||||||
|
const planName = plan?.metadata?.name || '-';
|
||||||
|
const namespace = plan?.metadata?.namespace || '';
|
||||||
|
const networkMap = plan?.spec?.map?.network;
|
||||||
|
const storageMap = plan?.spec?.map?.storage;
|
||||||
|
const history = plan?.status?.migration?.history || [];
|
||||||
|
|
||||||
|
const migrationsMap = new Map();
|
||||||
|
|
||||||
|
history
|
||||||
|
.map((entry) => ({
|
||||||
|
name: entry?.migration?.name,
|
||||||
|
namespace: entry?.migration?.namespace || namespace,
|
||||||
|
}))
|
||||||
|
.filter((entry) => !!entry.name)
|
||||||
|
.forEach((entry) => {
|
||||||
|
const matched = existing[HCI.FORKLIFT_MIGRATION].find((resource) => resource?.metadata?.name === entry.name && resource?.metadata?.namespace === entry.namespace);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
migrationsMap.set(`${ entry.namespace }/${ entry.name }`, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchedNetworkMap = networkMap?.name && existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => resource?.metadata?.name === networkMap.name && resource?.metadata?.namespace === (networkMap.namespace || namespace));
|
||||||
|
const matchedStorageMap = storageMap?.name && existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => resource?.metadata?.name === storageMap.name && resource?.metadata?.namespace === (storageMap.namespace || namespace));
|
||||||
|
|
||||||
|
return {
|
||||||
|
planName,
|
||||||
|
namespace,
|
||||||
|
migrations: [...migrationsMap.values()],
|
||||||
|
networkMap: matchedNetworkMap ? {
|
||||||
|
namespace: networkMap.namespace || namespace,
|
||||||
|
name: networkMap.name,
|
||||||
|
} : null,
|
||||||
|
storageMap: matchedStorageMap ? {
|
||||||
|
namespace: storageMap.namespace || namespace,
|
||||||
|
name: storageMap.name,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
|
||||||
|
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
|
||||||
|
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: the dialog can still function without this prefetch.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
resourceNames,
|
||||||
|
|
||||||
|
buildDeleteTargets() {
|
||||||
|
const targets = new Map();
|
||||||
|
|
||||||
|
for (const plan of this.value || []) {
|
||||||
|
const namespace = plan?.metadata?.namespace || '';
|
||||||
|
const networkMap = plan?.spec?.map?.network;
|
||||||
|
const storageMap = plan?.spec?.map?.storage;
|
||||||
|
const history = plan?.status?.migration?.history || [];
|
||||||
|
|
||||||
|
if (networkMap?.name) {
|
||||||
|
const ns = networkMap.namespace || namespace;
|
||||||
|
const key = `${ HCI.FORKLIFT_NETWORK_MAP }|${ ns }|${ networkMap.name }`;
|
||||||
|
|
||||||
|
targets.set(key, {
|
||||||
|
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
name: networkMap.name,
|
||||||
|
namespace: ns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageMap?.name) {
|
||||||
|
const ns = storageMap.namespace || namespace;
|
||||||
|
const key = `${ HCI.FORKLIFT_STORAGE_MAP }|${ ns }|${ storageMap.name }`;
|
||||||
|
|
||||||
|
targets.set(key, {
|
||||||
|
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
name: storageMap.name,
|
||||||
|
namespace: ns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of history) {
|
||||||
|
const migrationName = entry?.migration?.name;
|
||||||
|
|
||||||
|
if (!migrationName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationNs = entry?.migration?.namespace || namespace;
|
||||||
|
const key = `${ HCI.FORKLIFT_MIGRATION }|${ migrationNs }|${ migrationName }`;
|
||||||
|
|
||||||
|
targets.set(key, {
|
||||||
|
type: HCI.FORKLIFT_MIGRATION,
|
||||||
|
name: migrationName,
|
||||||
|
namespace: migrationNs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...targets.values()];
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRelatedResources() {
|
||||||
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||||
|
const targets = this.buildDeleteTargets();
|
||||||
|
|
||||||
|
if (!targets.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cache = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
cache = {
|
||||||
|
[HCI.FORKLIFT_MIGRATION]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
|
||||||
|
[HCI.FORKLIFT_NETWORK_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
|
||||||
|
[HCI.FORKLIFT_STORAGE_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort cleanup; don't block closing the dialog if listing related resources fails.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const matched = (cache[target.type] || []).find((resource) => resource?.metadata?.name === target.name && resource?.metadata?.namespace === target.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await matched.remove({ params: { propagationPolicy: 'Background' } });
|
||||||
|
} catch (err) {
|
||||||
|
// Continue deleting remaining resources and let final plan deletion fail if needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(buttonDone) {
|
||||||
|
this.errors = [];
|
||||||
|
this.removing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled((this.value || []).map((resource) => resource.remove({ params: { propagationPolicy: 'Background' } })));
|
||||||
|
|
||||||
|
await this.deleteRelatedResources();
|
||||||
|
const rejected = results.filter((r) => r.status === 'rejected');
|
||||||
|
|
||||||
|
if (rejected.length) {
|
||||||
|
throw rejected[0].reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.close(buttonDone);
|
||||||
|
} catch (err) {
|
||||||
|
this.errors = exceptionToErrorsArray(err);
|
||||||
|
this.$emit('errors', err);
|
||||||
|
if (buttonDone) {
|
||||||
|
buttonDone(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.removing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-10">
|
||||||
|
<div class="mb-10">
|
||||||
|
{{ t('promptRemove.attemptingToRemove', { type: resourceType }) }}
|
||||||
|
<span v-clean-html="resourceNames(names, null, t)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="mb-10">
|
||||||
|
|
||||||
|
<div class="mb-10 text-warning">
|
||||||
|
<i class="icon icon-warning icon-lg mr-5" />
|
||||||
|
{{ t('harvester.addons.vmMigration.deleteDialog.warning') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="summary in relatedSummary"
|
||||||
|
:key="`${ summary.namespace }/${ summary.planName }`"
|
||||||
|
class="mb-10"
|
||||||
|
>
|
||||||
|
<div>• {{ t('harvester.addons.vmMigration.labels.migration') }}:</div>
|
||||||
|
<template v-if="summary.migrations.length">
|
||||||
|
<div
|
||||||
|
v-for="migration in summary.migrations"
|
||||||
|
:key="`${ migration.namespace }/${ migration.name }`"
|
||||||
|
>
|
||||||
|
<div class="ml-20">
|
||||||
|
- {{ migration.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="ml-20"
|
||||||
|
>
|
||||||
|
- -
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>• {{ t('harvester.addons.vmMigration.labels.networkMap') }}:</div>
|
||||||
|
<template v-if="summary.networkMap">
|
||||||
|
<div class="ml-20">
|
||||||
|
- {{ summary.networkMap.name }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="ml-20"
|
||||||
|
>
|
||||||
|
- -
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>• {{ t('harvester.addons.vmMigration.labels.storageMap') }}:</div>
|
||||||
|
<template v-if="summary.storageMap">
|
||||||
|
<div class="ml-20">
|
||||||
|
- {{ summary.storageMap.name }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="ml-20"
|
||||||
|
>
|
||||||
|
- -
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Banner
|
||||||
|
v-for="(error, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -16,6 +16,9 @@ import HarvesterMembers from '../pages/c/_cluster/members/index.vue';
|
|||||||
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
|
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
|
||||||
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
|
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
|
||||||
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
|
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
|
||||||
|
import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue';
|
||||||
|
import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.vue';
|
||||||
|
import ForkliftProviderWizard from '../pages/c/_cluster/vm-migration/provider-wizard.vue';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -83,6 +86,18 @@ const routes = [
|
|||||||
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
|
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
|
||||||
path: `/:product/c/:cluster/projectsnamespaces`,
|
path: `/:product/c/:cluster/projectsnamespaces`,
|
||||||
component: ProjectNamespaces,
|
component: ProjectNamespaces,
|
||||||
|
}, {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
path: `/:product/c/:cluster/vm-migration`,
|
||||||
|
component: ForkliftDashboard,
|
||||||
|
}, {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
||||||
|
path: `/:product/c/:cluster/vm-migration/wizard`,
|
||||||
|
component: ForkliftVmMigrationWizard,
|
||||||
|
}, {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
path: `/:product/c/:cluster/vm-migration/provider-wizard`,
|
||||||
|
component: ForkliftProviderWizard,
|
||||||
}, {
|
}, {
|
||||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
path: `/:product/c/:cluster/:resource`,
|
path: `/:product/c/:cluster/:resource`,
|
||||||
|
|||||||
+76
-71
@@ -5,77 +5,82 @@
|
|||||||
// 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group.
|
// 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group.
|
||||||
|
|
||||||
export const HCI = {
|
export const HCI = {
|
||||||
VM: 'kubevirt.io.virtualmachine',
|
VM: 'kubevirt.io.virtualmachine',
|
||||||
VMI: 'kubevirt.io.virtualmachineinstance',
|
VMI: 'kubevirt.io.virtualmachineinstance',
|
||||||
VMIM: 'kubevirt.io.virtualmachineinstancemigration',
|
VMIM: 'kubevirt.io.virtualmachineinstancemigration',
|
||||||
VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate',
|
VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate',
|
||||||
VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion',
|
VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion',
|
||||||
IMAGE: 'harvesterhci.io.virtualmachineimage',
|
IMAGE: 'harvesterhci.io.virtualmachineimage',
|
||||||
SSH: 'harvesterhci.io.keypair',
|
SSH: 'harvesterhci.io.keypair',
|
||||||
VOLUME: 'harvesterhci.io.volume',
|
VOLUME: 'harvesterhci.io.volume',
|
||||||
USER: 'harvesterhci.io.user',
|
USER: 'harvesterhci.io.user',
|
||||||
SETTING: 'harvesterhci.io.setting',
|
SETTING: 'harvesterhci.io.setting',
|
||||||
UPGRADE: 'harvesterhci.io.upgrade',
|
UPGRADE: 'harvesterhci.io.upgrade',
|
||||||
UPGRADE_LOG: 'harvesterhci.io.upgradelog',
|
UPGRADE_LOG: 'harvesterhci.io.upgradelog',
|
||||||
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
|
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
|
||||||
BACKUP: 'harvesterhci.io.virtualmachinebackup',
|
BACKUP: 'harvesterhci.io.virtualmachinebackup',
|
||||||
RESTORE: 'harvesterhci.io.virtualmachinerestore',
|
RESTORE: 'harvesterhci.io.virtualmachinerestore',
|
||||||
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
|
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
|
||||||
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
|
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
|
||||||
HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig',
|
HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig',
|
||||||
SUBNET: 'kubeovn.io.subnet',
|
SUBNET: 'kubeovn.io.subnet',
|
||||||
VPC: 'kubeovn.io.vpc',
|
VPC: 'kubeovn.io.vpc',
|
||||||
IP: 'kubeovn.io.ip',
|
IP: 'kubeovn.io.ip',
|
||||||
VLAN: 'kubeovn.io.vlan',
|
VLAN: 'kubeovn.io.vlan',
|
||||||
IPTABLES_EIP: 'kubeovn.io.iptableseip',
|
IPTABLES_EIP: 'kubeovn.io.iptableseip',
|
||||||
IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule',
|
IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule',
|
||||||
IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule',
|
IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule',
|
||||||
PROVIDER_NETWORK: 'kubeovn.io.providernetwork',
|
PROVIDER_NETWORK: 'kubeovn.io.providernetwork',
|
||||||
VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway',
|
VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway',
|
||||||
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
|
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
|
||||||
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
|
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
|
||||||
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
|
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
|
||||||
CLUSTER: 'harvesterhci.io.management.cluster',
|
CLUSTER: 'harvesterhci.io.management.cluster',
|
||||||
DASHBOARD: 'harvesterhci.io.dashboard',
|
DASHBOARD: 'harvesterhci.io.dashboard',
|
||||||
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
|
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
|
||||||
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
|
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
|
||||||
HOST: 'harvesterhci.io.host',
|
HOST: 'harvesterhci.io.host',
|
||||||
VERSION: 'harvesterhci.io.version',
|
VERSION: 'harvesterhci.io.version',
|
||||||
SNAPSHOT: 'harvesterhci.io.volumesnapshot',
|
SNAPSHOT: 'harvesterhci.io.volumesnapshot',
|
||||||
VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot',
|
VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot',
|
||||||
ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig',
|
ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig',
|
||||||
CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow',
|
CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow',
|
||||||
CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput',
|
CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput',
|
||||||
FLOW: 'harvesterhci.io.logging.flow',
|
FLOW: 'harvesterhci.io.logging.flow',
|
||||||
OUTPUT: 'harvesterhci.io.logging.output',
|
OUTPUT: 'harvesterhci.io.logging.output',
|
||||||
STORAGE: 'harvesterhci.io.storage',
|
STORAGE: 'harvesterhci.io.storage',
|
||||||
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
|
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
|
||||||
KSTUNED: 'node.harvesterhci.io.ksmtuned',
|
KSTUNED: 'node.harvesterhci.io.ksmtuned',
|
||||||
HUGEPAGES: 'node.harvesterhci.io.hugepage',
|
HUGEPAGES: 'node.harvesterhci.io.hugepage',
|
||||||
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
|
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
|
||||||
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
|
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
|
||||||
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
|
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
|
||||||
VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice',
|
VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice',
|
||||||
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
|
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
|
||||||
USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
|
USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
|
||||||
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
|
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
|
||||||
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
|
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
|
||||||
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
|
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
|
||||||
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
|
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
|
||||||
ADD_ONS: 'harvesterhci.io.addon',
|
ADD_ONS: 'harvesterhci.io.addon',
|
||||||
LINK_MONITOR: 'network.harvesterhci.io.linkmonitor',
|
LINK_MONITOR: 'network.harvesterhci.io.linkmonitor',
|
||||||
SECRET: 'harvesterhci.io.secret',
|
SECRET: 'harvesterhci.io.secret',
|
||||||
INVENTORY: 'metal.harvesterhci.io.inventory',
|
INVENTORY: 'metal.harvesterhci.io.inventory',
|
||||||
LB: 'loadbalancer.harvesterhci.io.loadbalancer',
|
LB: 'loadbalancer.harvesterhci.io.loadbalancer',
|
||||||
IP_POOL: 'loadbalancer.harvesterhci.io.ippool',
|
IP_POOL: 'loadbalancer.harvesterhci.io.ippool',
|
||||||
HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig',
|
HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig',
|
||||||
LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup',
|
LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup',
|
||||||
VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource',
|
VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource',
|
||||||
VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource',
|
VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource',
|
||||||
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
|
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
|
||||||
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
|
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
|
||||||
MIGRATION: 'migration.harvesterhci.io',
|
MIGRATION: 'migration.harvesterhci.io',
|
||||||
|
// Forklift CRDs
|
||||||
|
FORKLIFT_PROVIDER: 'forklift.konveyor.io.provider',
|
||||||
|
FORKLIFT_NETWORK_MAP: 'forklift.konveyor.io.networkmap',
|
||||||
|
FORKLIFT_STORAGE_MAP: 'forklift.konveyor.io.storagemap',
|
||||||
|
FORKLIFT_PLAN: 'forklift.konveyor.io.plan',
|
||||||
|
FORKLIFT_MIGRATION: 'forklift.konveyor.io.migration',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';
|
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export const DEFAULT_FS_FREEZE_DEADLINE = '1s';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate filesystem freeze deadline options with i18n translations
|
||||||
|
* @param {Function} t - i18n translation function
|
||||||
|
* @returns {Array} Array of deadline options
|
||||||
|
*/
|
||||||
|
export function getFsFreezeDeadlineOptions(t) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('generic.duration.infinite'),
|
||||||
|
value: '0s'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('generic.duration.1s'),
|
||||||
|
value: '1s'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('generic.duration.5s'),
|
||||||
|
value: '5s'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('generic.duration.10s'),
|
||||||
|
value: '10s'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('generic.duration.30s'),
|
||||||
|
value: '30s'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('generic.duration.1m'),
|
||||||
|
value: '1m'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('generic.duration.3m'),
|
||||||
|
value: '3m'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('generic.duration.5m'),
|
||||||
|
value: '5m'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@
|
|||||||
* @param {String} config.resourceType - The schema ID for addons.
|
* @param {String} config.resourceType - The schema ID for addons.
|
||||||
* @param {String} config.navGroup - The group name in the side nav.
|
* @param {String} config.navGroup - The group name in the side nav.
|
||||||
* @param {Array<String>} config.types - Array of Resource IDs to show/hide.
|
* @param {Array<String>} config.types - Array of Resource IDs to show/hide.
|
||||||
|
* @param {Boolean} [config.requireSchema=true] - When true, only types with an
|
||||||
|
* accessible schema are shown. Set false for schema-less virtual types.
|
||||||
*/
|
*/
|
||||||
export function registerAddonSideNav(store, productName, {
|
export function registerAddonSideNav(store, productName, {
|
||||||
addonName, resourceType, navGroup, types
|
addonName, resourceType, navGroup, types, requireSchema = true
|
||||||
}) {
|
}) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -57,7 +59,7 @@ export function registerAddonSideNav(store, productName, {
|
|||||||
|
|
||||||
// Adds or removes the resource IDs from the product visibility whitelist.
|
// Adds or removes the resource IDs from the product visibility whitelist.
|
||||||
const setMenuVisibility = (visible) => {
|
const setMenuVisibility = (visible) => {
|
||||||
const accessibleTypes = visible ? types.filter(hasAccessibleSchema) : [];
|
const accessibleTypes = visible ? (requireSchema ? types.filter(hasAccessibleSchema) : types) : [];
|
||||||
|
|
||||||
// Always clear first to remove any previously-registered types that are
|
// Always clear first to remove any previously-registered types that are
|
||||||
// no longer accessible (e.g. partial permission changes like types=[A,B] where B is dropped).
|
// no longer accessible (e.g. partial permission changes like types=[A,B] where B is dropped).
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Shared helpers for the Forklift VM-migration feature.
|
||||||
|
// Keep the map-spec builders here so the wizard step and the review step
|
||||||
|
// (and any future edit flow) stay in sync instead of drifting apart.
|
||||||
|
|
||||||
|
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a base64 value stored in a k8s Secret, tolerating malformed input.
|
||||||
|
*/
|
||||||
|
export function decodeSecretValue(val) {
|
||||||
|
try {
|
||||||
|
return atob(val || '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes → whole GiB (rounded). Returns 0 for falsy input.
|
||||||
|
*/
|
||||||
|
export function bytesToGB(bytes) {
|
||||||
|
return Math.round((bytes || 0) / (1024 * 1024 * 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MiB → whole GiB (rounded). Returns 0 for falsy input.
|
||||||
|
*/
|
||||||
|
export function mbToGB(mb) {
|
||||||
|
return Math.round((mb || 0) / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `spec.map` entries for a Forklift NetworkMap from wizard entries.
|
||||||
|
* Entries without a chosen target are dropped so we never emit an invalid
|
||||||
|
* multus destination with an empty name.
|
||||||
|
*
|
||||||
|
* @param {Array} entries network mapping entries ({ name, id, target })
|
||||||
|
* @param {string} defaultNamespace namespace to use when the target has no `ns/` prefix
|
||||||
|
*/
|
||||||
|
export function buildNetworkMapEntries(entries = [], defaultNamespace) {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => !!entry.target)
|
||||||
|
.map((entry) => {
|
||||||
|
const source = { name: entry.name, id: entry.id };
|
||||||
|
|
||||||
|
if (entry.target === 'pod') {
|
||||||
|
return { source, destination: { type: 'pod' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.target === 'ignored') {
|
||||||
|
return { source, destination: { type: 'ignored' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = entry.target.split('/');
|
||||||
|
const name = parts.length > 1 ? parts[1] : parts[0];
|
||||||
|
const namespace = parts.length > 1 ? parts[0] : defaultNamespace;
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
destination: {
|
||||||
|
type: 'multus', name, namespace
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `spec.map` entries for a Forklift StorageMap from wizard entries.
|
||||||
|
* Entries without a chosen target are dropped.
|
||||||
|
*
|
||||||
|
* @param {Array} entries storage mapping entries ({ name, id, target })
|
||||||
|
*/
|
||||||
|
export function buildStorageMapEntries(entries = []) {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => !!entry.target)
|
||||||
|
.map((entry) => ({
|
||||||
|
source: { name: entry.name, id: entry.id },
|
||||||
|
destination: { storageClass: entry.target },
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Mirrors Resource class methods from @shell/plugins/dashboard-store/resource-class.js
|
||||||
|
// useRouter()/useRoute() from vue-router don't work in extension mode,
|
||||||
|
// so we use the same global access pattern the shell uses internally.
|
||||||
|
|
||||||
|
export function currentRouter() {
|
||||||
|
return window.$globalApp.$router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentRoute() {
|
||||||
|
return window.$globalApp.$route;
|
||||||
|
}
|
||||||
@@ -10377,12 +10377,13 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0, punycode@^2.3.1:
|
|||||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
qs@6.15.2, qs@6.7.0, qs@^6.12.3, qs@~6.14.0:
|
qs@6.15.3, qs@6.7.0, qs@^6.12.3, qs@~6.14.0:
|
||||||
version "6.15.2"
|
version "6.15.3"
|
||||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9"
|
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b"
|
||||||
integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==
|
integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel "^1.1.0"
|
es-define-property "^1.0.1"
|
||||||
|
side-channel "^1.1.1"
|
||||||
|
|
||||||
querystring-es3@^0.2.1:
|
querystring-es3@^0.2.1:
|
||||||
version "0.2.1"
|
version "0.2.1"
|
||||||
@@ -11026,7 +11027,7 @@ shell-quote@^1.8.3:
|
|||||||
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b"
|
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b"
|
||||||
integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==
|
integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==
|
||||||
|
|
||||||
side-channel-list@^1.0.0:
|
side-channel-list@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127"
|
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127"
|
||||||
integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==
|
integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==
|
||||||
@@ -11055,14 +11056,14 @@ side-channel-weakmap@^1.0.2:
|
|||||||
object-inspect "^1.13.3"
|
object-inspect "^1.13.3"
|
||||||
side-channel-map "^1.0.1"
|
side-channel-map "^1.0.1"
|
||||||
|
|
||||||
side-channel@^1.1.0:
|
side-channel@^1.1.0, side-channel@^1.1.1:
|
||||||
version "1.1.0"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9"
|
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab"
|
||||||
integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==
|
integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
object-inspect "^1.13.3"
|
object-inspect "^1.13.4"
|
||||||
side-channel-list "^1.0.0"
|
side-channel-list "^1.0.1"
|
||||||
side-channel-map "^1.0.1"
|
side-channel-map "^1.0.1"
|
||||||
side-channel-weakmap "^1.0.2"
|
side-channel-weakmap "^1.0.2"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user