feat(forklift): Fixed config based on new figma changes

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto 2026-05-06 21:41:48 +02:00
parent 2259910be1
commit 63d726aa91
No known key found for this signature in database
GPG Key ID: 1CA12189625C2543
7 changed files with 1216 additions and 1382 deletions

View File

@ -0,0 +1,16 @@
import HarvesterResource from './harvester';
export default class ForkliftPlan extends HarvesterResource {
/**
* Deleting a Plan cascades via ownerReferences set at creation time.
* Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap, Provider ( Secret).
* Use foreground propagation to ensure children are deleted before the parent.
*/
remove() {
const opt = { ...arguments };
opt.params = { propagationPolicy: 'Foreground' };
return this._remove(opt);
}
}

View File

@ -0,0 +1,16 @@
import HarvesterResource from './harvester';
export default class ForkliftProvider extends HarvesterResource {
/**
* 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() {
const opt = { ...arguments };
opt.params = { propagationPolicy: 'Foreground' };
return this._remove(opt);
}
}

View File

@ -1,5 +1,5 @@
<script> <script setup>
import { defineComponent, ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
@ -23,35 +23,23 @@ const schema = {
metadata: { name: HCI.FORKLIFT_NETWORK_MAP }, metadata: { name: HCI.FORKLIFT_NETWORK_MAP },
}; };
export default defineComponent({ const store = useStore();
name: 'ForkliftConfigureMappings', const route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
components: { const vms = ref([]);
Loading, const harvesterNetworks = ref([]);
Masthead, const storageClasses = ref([]);
AsyncButton, const networkEntries = ref([]);
LabeledSelect, const storageEntries = ref([]);
RcItemCard, const errors = ref([]);
}, const loading = ref(true);
setup() { const NAMESPACE = 'forklift';
const store = useStore(); const providerName = computed(() => route.query.provider || 'vsphere');
const route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
const vms = ref([]); const harvesterNetworkOptions = computed(() => {
const harvesterNetworks = ref([]);
const storageClasses = ref([]);
const networkEntries = ref([]);
const storageEntries = ref([]);
const errors = ref([]);
const loading = ref(true);
const providerName = computed(() => route.query.provider || 'vsphere');
const namespace = computed(() => route.query.namespace || 'default');
const harvesterNetworkOptions = computed(() => {
const options = [ const options = [
{ label: 'Pod Networking', value: 'pod' }, { label: 'Pod Networking', value: 'pod' },
{ label: 'Ignored', value: 'ignored' }, { label: 'Ignored', value: 'ignored' },
@ -66,9 +54,9 @@ export default defineComponent({
}); });
return options; return options;
}); });
const storageClassOptions = computed(() => { const storageClassOptions = computed(() => {
const options = storageClasses.value.map((sc) => ({ const options = storageClasses.value.map((sc) => ({
label: sc.metadata?.name || sc.id, label: sc.metadata?.name || sc.id,
value: sc.metadata?.name || sc.id, value: sc.metadata?.name || sc.id,
@ -79,90 +67,82 @@ export default defineComponent({
} }
return options; return options;
}); });
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target)); 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 allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
const canSave = computed(() => { const canSave = computed(() => {
return networkEntries.value.length > 0 && storageEntries.value.length > 0 && return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
allNetworksMapped.value && allStorageMapped.value; allNetworksMapped.value && allStorageMapped.value;
}); });
const buildNetworkEntries = () => { const buildNetworkEntries = () => {
const entries = []; const networkMap = {};
vms.value.forEach((vm) => { vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.networks && vm.networks.length > 0) { if (vm.networks && vm.networks.length > 0) {
vm.networks.forEach((net) => { vm.networks.forEach((net) => {
entries.push({ const netKey = net.id || net.name || 'default';
vmId: vm.id,
vmName: vm.name || vm.id, if (!networkMap[netKey]) {
networkMap[netKey] = {
name: net.name || 'Unknown', name: net.name || 'Unknown',
id: net.id || '', id: net.id || '',
vlanId: net.vlanId || '', vlanId: net.vlanId || '',
target: 'pod', target: '',
_key: `net-${ vm.id }-${ net.id || net.name }`, usedBy: [],
}); _key: `net-${ netKey }`,
}); };
} else { }
entries.push({
vmId: vm.id, if (!networkMap[netKey].usedBy.includes(vmName)) {
vmName: vm.name || vm.id, networkMap[netKey].usedBy.push(vmName);
name: 'Default Network', }
id: '',
vlanId: '',
target: 'pod',
_key: `net-${ vm.id }-default`,
}); });
} }
}); });
networkEntries.value = entries; networkEntries.value = Object.values(networkMap);
}; };
const buildStorageEntries = () => { const buildStorageEntries = () => {
const entries = []; const datastoreMap = {};
vms.value.forEach((vm) => { vms.value.forEach((vm) => {
if (vm.disks && vm.disks.length > 0) { const vmName = vm.name || vm.id;
const seen = new Set();
if (vm.disks && vm.disks.length > 0) {
vm.disks.forEach((disk) => { vm.disks.forEach((disk) => {
const ds = disk.datastore; const ds = disk.datastore;
if (ds && ds.id && !seen.has(ds.id)) { if (ds && ds.id) {
seen.add(ds.id); if (!datastoreMap[ds.id]) {
entries.push({ datastoreMap[ds.id] = {
vmId: vm.id,
vmName: vm.name || vm.id,
name: ds.name || 'Unknown', name: ds.name || 'Unknown',
id: ds.id || '', id: ds.id,
type: ds.type || '', type: ds.type || '',
capacity: disk.capacity || 0, capacity: ds.capacity || 0,
target: 'harvester-longhorn', target: '',
_key: `stor-${ vm.id }-${ ds.id }`, usedBy: [],
}); _key: `stor-${ ds.id }`,
}
});
} else {
entries.push({
vmId: vm.id,
vmName: vm.name || vm.id,
name: 'Default Datastore',
id: '',
type: '',
capacity: 0,
target: 'harvester-longhorn',
_key: `stor-${ vm.id }-default`,
});
}
});
storageEntries.value = entries;
}; };
}
const formatStorageDetail = (entry) => { if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
datastoreMap[ds.id].usedBy.push(vmName);
}
}
});
}
});
storageEntries.value = Object.values(datastoreMap);
};
const formatStorageDetail = (entry) => {
const parts = []; const parts = [];
if (entry.type) { if (entry.type) {
@ -180,9 +160,17 @@ export default defineComponent({
} }
return parts.join(' \u2022 '); return parts.join(' \u2022 ');
}; };
const cancel = async() => {
const inStore = store.getters['currentProduct'].inStore;
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
if (provider) {
await provider.remove();
}
const cancel = () => {
router.push({ router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`, name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: { params: {
@ -190,9 +178,9 @@ export default defineComponent({
cluster: route.params.cluster, cluster: route.params.cluster,
} }
}); });
}; };
const saveMappings = async(buttonCb) => { const saveMappings = async(buttonCb) => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
const providerRef = { const providerRef = {
@ -200,13 +188,13 @@ export default defineComponent({
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider', kind: 'Provider',
name: providerName.value, name: providerName.value,
namespace: namespace.value, namespace: NAMESPACE,
}, },
destination: { destination: {
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider', kind: 'Provider',
name: 'host', name: 'host',
namespace: 'forklift', namespace: NAMESPACE,
}, },
}; };
@ -233,18 +221,31 @@ export default defineComponent({
destination: { destination: {
type: 'multus', type: 'multus',
name: entry.target, name: entry.target,
namespace: namespace.value, namespace: NAMESPACE,
}, },
}; };
}), }),
provider: providerRef, provider: providerRef,
}; };
// Fetch the Provider to build ownerReference
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
const providerOwnerRef = provider ? [{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: provider.metadata.name,
uid: provider.metadata.uid,
blockOwnerDeletion: true,
}] : [];
const networkMap = await store.dispatch(`${ inStore }/create`, { const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP, type: HCI.FORKLIFT_NETWORK_MAP,
metadata: { metadata: {
name: `${ providerName.value }-network-map-${ Math.random().toString(36).substring(2, 7) }`, name: `${ providerName.value }-network-map-${ Math.random().toString(36).substring(2, 7) }`,
namespace: namespace.value, namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
}, },
spec: networkMapSpec, spec: networkMapSpec,
}); });
@ -264,7 +265,8 @@ export default defineComponent({
type: HCI.FORKLIFT_STORAGE_MAP, type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { metadata: {
name: `${ providerName.value }-storage-map-${ Math.random().toString(36).substring(2, 7) }`, name: `${ providerName.value }-storage-map-${ Math.random().toString(36).substring(2, 7) }`,
namespace: namespace.value, namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
}, },
spec: storageMapSpec, spec: storageMapSpec,
}); });
@ -273,15 +275,15 @@ export default defineComponent({
// Navigate to review page with all mapping data // Navigate to review page with all mapping data
const networkMappingsData = networkEntries.value.map((entry) => ({ const networkMappingsData = networkEntries.value.map((entry) => ({
vmId: entry.vmId,
source: entry.name, source: entry.name,
target: entry.target === 'pod' ? 'Pod Networking' : entry.target === 'ignored' ? 'Ignored' : entry.target, target: entry.target === 'pod' ? 'Pod Networking' : entry.target === 'ignored' ? 'Ignored' : entry.target,
usedBy: entry.usedBy,
})); }));
const storageMappingsData = storageEntries.value.map((entry) => ({ const storageMappingsData = storageEntries.value.map((entry) => ({
vmId: entry.vmId,
source: entry.name, source: entry.name,
target: entry.target, target: entry.target,
usedBy: entry.usedBy,
})); }));
router.push({ router.push({
@ -292,7 +294,6 @@ export default defineComponent({
}, },
query: { query: {
provider: providerName.value, provider: providerName.value,
namespace: namespace.value,
vms: route.query.vms, vms: route.query.vms,
networkMap: networkMap.metadata.name, networkMap: networkMap.metadata.name,
storageMap: storageMap.metadata.name, storageMap: storageMap.metadata.name,
@ -306,9 +307,9 @@ export default defineComponent({
errors.value = [err.message || err]; errors.value = [err.message || err];
buttonCb(false); buttonCb(false);
} }
}; };
const init = async() => { const init = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
try { try {
@ -323,6 +324,12 @@ export default defineComponent({
storageClasses.value = []; storageClasses.value = [];
} }
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
} catch (e) {
// Provider may already be in store from previous step
}
const vmsParam = route.query.vms; const vmsParam = route.query.vms;
if (vmsParam) { if (vmsParam) {
@ -344,26 +351,9 @@ export default defineComponent({
buildNetworkEntries(); buildNetworkEntries();
buildStorageEntries(); buildStorageEntries();
loading.value = false; loading.value = false;
}; };
init(); init();
return {
schema,
loading,
networkEntries,
storageEntries,
errors,
harvesterNetworkOptions,
storageClassOptions,
canSave,
t,
formatStorageDetail,
cancel,
saveMappings,
};
},
});
</script> </script>
<template> <template>
@ -402,17 +392,17 @@ export default defineComponent({
:id="entry._key" :id="entry._key"
:key="entry._key" :key="entry._key"
:variant="'small'" :variant="'small'"
:header="{ title: { text: entry.vmId }, statuses: entry.target ? [{ icon: 'icon-notify-tick', color: 'text-success' }] : [] }" :header="{}"
> >
<template #item-card-content> <template #item-card-content>
<div class="card-content-column">
<div class="card-content-row"> <div class="card-content-row">
<div class="mapping-source"> <div class="mapping-source">
<span class="source-name">{{ entry.name }}</span> <span class="source-name">{{ entry.name }}</span>
<span class="source-id text-muted">{{ entry.id }}</span>
<span class="source-detail text-muted">VLAN {{ entry.vlanId || '0' }}</span> <span class="source-detail text-muted">VLAN {{ entry.vlanId || '0' }}</span>
</div> </div>
<div class="mapping-arrow"> <div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-muted']">
<i class="icon icon-upgrade-alt" />
</div> </div>
<div class="mapping-target"> <div class="mapping-target">
<LabeledSelect <LabeledSelect
@ -423,6 +413,12 @@ export default defineComponent({
/> />
</div> </div>
</div> </div>
<div>
<span class="used-by">
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template> </template>
</RcItemCard> </RcItemCard>
</div> </div>
@ -443,20 +439,20 @@ export default defineComponent({
:id="entry._key" :id="entry._key"
:key="entry._key" :key="entry._key"
:variant="'small'" :variant="'small'"
:header="{ title: { text: entry.vmId }, statuses: entry.target ? [{ icon: 'icon-notify-tick', color: 'text-success' }] : [] }" :header="{}"
> >
<template #item-card-content> <template #item-card-content>
<div class="card-content-column">
<div class="card-content-row"> <div class="card-content-row">
<div class="mapping-source"> <div class="mapping-source">
<span class="source-name">{{ entry.name }}</span> <span class="source-name">{{ entry.name }}</span>
<span class="source-id text-muted">{{ entry.id }}</span>
<span <span
v-if="entry.type || entry.capacity" v-if="entry.type || entry.capacity"
class="source-detail text-muted" class="source-detail text-muted"
>{{ formatStorageDetail(entry) }}</span> >{{ formatStorageDetail(entry) }}</span>
</div> </div>
<div class="mapping-arrow"> <div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-muted']">
<i class="icon icon-upgrade-alt" />
</div> </div>
<div class="mapping-target"> <div class="mapping-target">
<LabeledSelect <LabeledSelect
@ -467,6 +463,12 @@ export default defineComponent({
/> />
</div> </div>
</div> </div>
<div>
<span class="used-by">
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template> </template>
</RcItemCard> </RcItemCard>
</div> </div>
@ -506,6 +508,10 @@ export default defineComponent({
display: flex; display: flex;
gap: 12px; gap: 12px;
flex-direction: column; flex-direction: column;
:deep(.item-card-header) {
display: none;
}
} }
.mapping-section-title { .mapping-section-title {
@ -521,6 +527,13 @@ export default defineComponent({
width: 100%; width: 100%;
} }
.card-content-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mapping-source { .mapping-source {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -530,13 +543,20 @@ export default defineComponent({
.source-name { .source-name {
font-weight: 600; font-weight: 600;
font-size: 16px;
}
.used-by {
margin-top: 4px;
font-size: 14px;
line-height: 24px;
} }
} }
.mapping-arrow { .mapping-arrow {
color: var(--muted); font-size: 22px;
font-size: 16px;
flex-shrink: 0; flex-shrink: 0;
transform: rotate(90deg);
} }
.mapping-target { .mapping-target {
@ -553,4 +573,5 @@ export default defineComponent({
.line-height-20 { .line-height-20 {
line-height: 20px; line-height: 20px;
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<script> <script setup>
import { defineComponent, ref, computed } from 'vue'; import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
@ -23,39 +23,42 @@ const schema = {
metadata: { name: HCI.FORKLIFT_PROVIDER }, metadata: { name: HCI.FORKLIFT_PROVIDER },
}; };
export default defineComponent({ const store = useStore();
name: 'ForkliftConfigureProvider', const route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
components: { const allSecrets = ref([]);
LabeledInput, const providerName = ref('');
Checkbox, const url = ref('');
Banner, const username = ref('');
Masthead, const password = ref('');
AsyncButton, const skipTlsVerify = ref(false);
}, const testResult = ref(null);
const testError = ref(null);
const errors = ref([]);
const createdProvider = ref(null);
const createdSecret = ref(null);
const loading = ref(true);
const testPassed = ref(false);
setup() { const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
const allSecrets = ref([]); // Reset test state when any field changes
const providerName = ref(''); watch([providerName, url, username, password], () => {
const url = ref(''); testPassed.value = false;
const username = ref(''); testResult.value = null;
const password = ref(''); });
const skipTlsVerify = ref(false);
const testResult = ref(null);
const testError = ref(null);
const errors = ref([]);
const createdProvider = ref(null);
const createdSecret = ref(null);
const loading = ref(true);
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value); const cancel = async() => {
if (createdProvider.value) {
await createdProvider.value.remove();
createdProvider.value = null;
} else if (createdSecret.value) {
await createdSecret.value.remove();
createdSecret.value = null;
}
const cancel = () => {
router.push({ router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`, name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: { params: {
@ -63,9 +66,9 @@ export default defineComponent({
cluster: route.params.cluster, cluster: route.params.cluster,
} }
}); });
}; };
const testConnection = async(buttonCb) => { const testConnection = async(buttonCb) => {
testResult.value = null; testResult.value = null;
testError.value = null; testError.value = null;
@ -79,41 +82,20 @@ export default defineComponent({
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
try { try {
// Delete previous provider (cascades to secret via ownerReferences)
if (createdProvider.value) { if (createdProvider.value) {
await createdProvider.value.remove(); await createdProvider.value.remove();
createdProvider.value = null; createdProvider.value = null;
} createdSecret.value = null;
if (createdSecret.value) { } else if (createdSecret.value) {
await createdSecret.value.remove(); await createdSecret.value.remove();
createdSecret.value = null; createdSecret.value = null;
} }
const namespace = 'default'; const namespace = 'forklift';
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`; const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const newSecret = await store.dispatch(`${ inStore }/create`, { // Create Provider first so we have its UID for the ownerReference on the Secret
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: {
createdForProviderType: 'vsphere',
createdForResourceType: 'providers',
}
}
});
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 provider = await store.dispatch(`${ inStore }/create`, { const provider = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PROVIDER, type: HCI.FORKLIFT_PROVIDER,
metadata: { metadata: {
@ -133,6 +115,39 @@ export default defineComponent({
await provider.save(); await provider.save();
createdProvider.value = provider; createdProvider.value = provider;
// Create Secret with ownerReference already set (avoids an extra PUT)
const newSecret = await store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: {
createdForProviderType: 'vsphere',
createdForResourceType: 'providers',
},
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 maxAttempts = 15; const maxAttempts = 15;
let attempts = 0; let attempts = 0;
let connected = false; let connected = false;
@ -174,14 +189,15 @@ export default defineComponent({
} }
if (connected) { if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess'); testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess');
buttonCb(true); buttonCb(true);
} else { } else {
if (createdProvider.value) { if (createdProvider.value) {
await createdProvider.value.remove(); await createdProvider.value.remove();
createdProvider.value = null; createdProvider.value = null;
} createdSecret.value = null;
if (createdSecret.value) { } else if (createdSecret.value) {
await createdSecret.value.remove(); await createdSecret.value.remove();
createdSecret.value = null; createdSecret.value = null;
} }
@ -206,119 +222,49 @@ export default defineComponent({
testError.value = err.message || t('harvester.addons.forklift.configureProvider.testFailed'); testError.value = err.message || t('harvester.addons.forklift.configureProvider.testFailed');
buttonCb(false); buttonCb(false);
} }
}; };
const saveProvider = async(buttonCb) => {
const inStore = store.getters['currentProduct'].inStore;
const saveProvider = async(buttonCb) => {
errors.value = []; errors.value = [];
try { if (!testPassed.value) {
if (createdProvider.value) { // Test hasn't passed yet run it first
await testConnection((success) => {
if (success) {
router.push({ router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`, name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
params: { params: {
product: route.params.product, product: route.params.product,
cluster: route.params.cluster, cluster: route.params.cluster,
}, },
query: { query: { provider: providerName.value }
provider: providerName.value, });
namespace: createdProvider.value.metadata.namespace, } else {
buttonCb(false);
} }
}); });
return; return;
} }
const namespace = 'default';
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const newSecret = await store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: {
createdForProviderType: 'vsphere',
createdForResourceType: 'providers',
}
}
});
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();
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();
router.push({ router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`, name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
params: { params: {
product: route.params.product, product: route.params.product,
cluster: route.params.cluster, cluster: route.params.cluster,
}, },
query: { query: { provider: providerName.value }
provider: providerName.value,
namespace,
}
}); });
} catch (err) { };
errors.value = [err.message || err];
buttonCb(false);
}
};
const init = async() => { const init = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, { type: SECRET }); allSecrets.value = await store.dispatch(`${ inStore }/findAll`, { type: SECRET });
loading.value = false; loading.value = false;
}; };
init(); init();
return {
schema,
allSecrets,
providerName,
url,
username,
password,
skipTlsVerify,
testResult,
testError,
errors,
loading,
isFormValid,
t,
cancel,
testConnection,
saveProvider,
};
},
});
</script> </script>
<template> <template>
@ -402,8 +348,17 @@ export default defineComponent({
</div> </div>
<div class="mb-20"> <div class="mb-20">
<button
v-if="testPassed"
class="btn role-secondary test-passed-btn"
disabled
>
<i class="icon icon-checkmark mr-10" /> {{ t('harvester.addons.forklift.configureProvider.testConnection') }}
</button>
<AsyncButton <AsyncButton
v-else
mode="test" mode="test"
:disabled="!isFormValid"
:action-label="t('harvester.addons.forklift.configureProvider.testConnection')" :action-label="t('harvester.addons.forklift.configureProvider.testConnection')"
:waiting-label="t('harvester.addons.forklift.configureProvider.testConnection')" :waiting-label="t('harvester.addons.forklift.configureProvider.testConnection')"
:success-label="t('harvester.addons.forklift.configureProvider.testConnection')" :success-label="t('harvester.addons.forklift.configureProvider.testConnection')"
@ -474,4 +429,9 @@ export default defineComponent({
gap: 10px; gap: 10px;
margin-top: 30px; margin-top: 30px;
} }
.test-passed-btn {
color: var(--success) !important;
border-color: var(--success) !important;
}
</style> </style>

View File

@ -1,5 +1,5 @@
<script> <script setup>
import { defineComponent, ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
@ -24,31 +24,19 @@ const schema = {
metadata: { name: HCI.FORKLIFT_PLAN }, metadata: { name: HCI.FORKLIFT_PLAN },
}; };
export default defineComponent({ const store = useStore();
name: 'ForkliftMigrationDashboard', const route = useRoute();
const { t } = useI18n(store);
components: { const loading = ref(true);
Loading,
Masthead,
MappingsCell,
PercentageBar,
ResourceTable,
},
setup() { const inStore = computed(() => store.getters['currentProduct'].inStore);
const store = useStore();
const route = useRoute();
const { t } = useI18n(store);
const loading = ref(true); 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 inStore = computed(() => store.getters['currentProduct'].inStore); const rows = computed(() => {
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 rows = computed(() => {
return allPlans.value.map((plan) => { return allPlans.value.map((plan) => {
const netMapName = plan.spec?.map?.network?.name; const netMapName = plan.spec?.map?.network?.name;
const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace; const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace;
@ -69,46 +57,57 @@ export default defineComponent({
plan.vmProgress = vms.map((vm) => { plan.vmProgress = vms.map((vm) => {
const pipeline = vm.pipeline || []; const pipeline = vm.pipeline || [];
const totalSteps = pipeline.length; const totalSteps = pipeline.length || 1;
const completedSteps = pipeline.filter((s) => s.phase === 'Completed').length; 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 || `Step ${ idx + 1 }`;
}
if (step.error) {
errorMsg = (step.error.reasons || []).join('; ') || 'Error';
}
}
});
overallProgress = Math.round(overallProgress);
return { return {
name: vm.id || vm.name || 'Unknown', vmName: vm.name || vm.id || 'Unknown',
steps: pipeline.map((step) => ({ vmId: vm.id || '',
name: step.name || 'Unknown', progress: overallProgress,
progress: step.phase === 'Completed' ? 100 : (step.progress?.completed && step.progress?.total ? Math.round((step.progress.completed / step.progress.total) * 100) : 0), currentStep,
hasError: !!step.error, errorMsg,
reason: (step.error?.reasons || []).join('; ') || '',
})),
progress: totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0,
}; };
}); });
let totalSteps = 0; plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length) : 0;
let completedSteps = 0;
vms.forEach((vm) => {
const pipeline = vm.pipeline || [];
totalSteps += pipeline.length;
completedSteps += pipeline.filter((s) => s.phase === 'Completed').length;
});
plan.progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
return plan; return plan;
}); });
}); });
const createLocation = computed(() => ({ const createLocation = computed(() => ({
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`, name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`,
params: { params: {
product: route.params.product, product: route.params.product,
cluster: route.params.cluster, cluster: route.params.cluster,
} }
})); }));
const headers = [ const headers = [
{ ...STATE, labelKey: 'harvester.addons.forklift.dashboard.columns.status' }, { ...STATE, labelKey: 'harvester.addons.forklift.dashboard.columns.status' },
{ {
...NAME_COL, ...NAME_COL,
@ -120,18 +119,17 @@ export default defineComponent({
name: 'progress', name: 'progress',
labelKey: 'harvester.addons.forklift.dashboard.columns.progress', labelKey: 'harvester.addons.forklift.dashboard.columns.progress',
value: 'progress', value: 'progress',
width: 299, width: 500,
}, },
{ {
name: 'mappings', name: 'mappings',
labelKey: 'harvester.addons.forklift.dashboard.columns.mappings', labelKey: 'harvester.addons.forklift.dashboard.columns.mappings',
value: 'mappingsDisplay', value: 'mappingsDisplay',
width: 387,
}, },
{ ...AGE }, { ...AGE },
]; ];
const init = async() => { const init = async() => {
await Promise.all([ await Promise.all([
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }), 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_NETWORK_MAP }),
@ -139,20 +137,9 @@ export default defineComponent({
]); ]);
loading.value = false; loading.value = false;
}; };
init(); init();
return {
schema,
loading,
rows,
createLocation,
headers,
t,
};
},
});
</script> </script>
<template> <template>
@ -210,41 +197,43 @@ export default defineComponent({
<template #cell:progress="{ row }"> <template #cell:progress="{ row }">
<div <div
v-if="row.vmProgress && row.vmProgress.length" v-if="row.vmProgress && row.vmProgress.length"
class="progress-steps" class="progress-cells"
> >
<div <div
v-for="vm in row.vmProgress" v-for="vm in row.vmProgress"
:key="vm.name" :key="vm.vmId"
class="vm-progress" class="vm-progress"
> >
<div class="vm-name"> <div class="vm-progress-header">
{{ vm.name }} <div class="vm-name-block">
<span class="vm-name">{{ vm.vmName }}</span>
<span class="text-muted vm-id">id: {{ vm.vmId }}</span>
</div>
<span class="vm-pct">{{ vm.progress }}%</span>
</div> </div>
<div
v-for="step in vm.steps"
:key="step.name"
class="progress-step"
>
<span
class="step-name"
:class="{ 'text-error': step.hasError }"
>
{{ step.name }}
</span>
<PercentageBar <PercentageBar
:model-value="step.progress" :model-value="vm.progress"
:color-stops="step.hasError ? { 0: '--error' } : { 99: '--primary', 100: '--success' }" :color-stops="vm.errorMsg ? { 0: '--error' } : { 99: '--primary', 100: '--success' }"
preferred-direction="MORE" preferred-direction="MORE"
class="step-bar" class="vm-bar"
/> />
<span class="step-pct">{{ step.progress }}%</span> <div
v-if="vm.progress === 100"
class="step-label text-muted"
>
Finished Successfully
</div> </div>
<div <div
v-for="step in vm.steps.filter(s => s.hasError)" v-else-if="vm.errorMsg"
:key="`error-${step.name}`" class="step-label text-error"
class="step-error-msg"
> >
<span class="text-error">{{ step.reason }}</span> {{ vm.errorMsg }}
</div>
<div
v-else-if="vm.currentStep"
class="step-label text-muted"
>
{{ vm.currentStep }}
</div> </div>
</div> </div>
</div> </div>
@ -274,50 +263,54 @@ export default defineComponent({
} }
} }
.progress-steps { .progress-cells {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 10px;
} }
.vm-progress { .vm-progress {
&:not(:last-child) { &:not(:last-child) {
padding-bottom: 6px; padding-bottom: 8px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
} }
.vm-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.vm-name { .vm-name {
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
margin-bottom: 4px;
} }
.progress-step { .vm-name-block {
display: flex; display: flex;
align-items: center; flex-direction: row;
gap: 12px; gap: 8px;
margin-bottom: 4px;
padding-right: 13px;
} }
.step-error-msg { .vm-id {
font-size: 11px;
}
.vm-pct {
font-size: 12px;
font-weight: 600;
}
.vm-bar {
width: 100%;
}
.step-label {
font-size: 12px;
margin-top: 4px; margin-top: 4px;
font-size: 12px; line-height: 20px;
.text-error {
color: var(--error);
}
}
.step-name {
font-size: 12px;
width: 110px;
min-width: 110px;
max-width: 110px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.text-error { &.text-error {
color: var(--error); color: var(--error);
@ -325,17 +318,6 @@ export default defineComponent({
} }
} }
.step-bar {
flex: 1;
min-width: 80px;
}
.step-pct {
font-size: 12px;
min-width: 35px;
text-align: left;
}
.table-title { .table-title {
font-weight: 600; font-weight: 600;
} }

View File

@ -1,5 +1,5 @@
<script> <script setup>
import { defineComponent, ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
@ -24,44 +24,32 @@ const schema = {
metadata: { name: HCI.FORKLIFT_PLAN }, metadata: { name: HCI.FORKLIFT_PLAN },
}; };
export default defineComponent({ const store = useStore();
name: 'ForkliftReviewMigration', const route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
components: { const vms = ref([]);
Loading, const networkMappings = ref([]);
Masthead, const storageMappings = ref([]);
AsyncButton, const errors = ref([]);
Banner, const loading = ref(true);
MappingsCell,
RcItemCard,
},
setup() { const NAMESPACE = 'forklift';
const store = useStore(); const TARGET_NAMESPACE = 'default';
const route = useRoute(); const providerName = computed(() => route.query.provider || 'vsphere');
const router = useRouter(); const networkMapName = computed(() => route.query.networkMap || '');
const { t } = useI18n(store); const storageMapName = computed(() => route.query.storageMap || '');
const vms = ref([]); const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const networkMappings = ref([]);
const storageMappings = ref([]);
const errors = ref([]);
const loading = ref(true);
const providerName = computed(() => route.query.provider || 'vsphere'); const totalMemoryGB = computed(() => {
const namespace = computed(() => route.query.namespace || 'default');
const networkMapName = computed(() => route.query.networkMap || '');
const storageMapName = computed(() => route.query.storageMap || '');
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); const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
return Math.round(totalMB / 1024); return Math.round(totalMB / 1024);
}); });
const totalStorageGB = computed(() => { const totalStorageGB = computed(() => {
let totalBytes = 0; let totalBytes = 0;
vms.value.forEach((vm) => { vms.value.forEach((vm) => {
@ -71,9 +59,9 @@ export default defineComponent({
}); });
return Math.round(totalBytes / (1024 * 1024 * 1024)); return Math.round(totalBytes / (1024 * 1024 * 1024));
}); });
const vmCards = computed(() => { const vmCards = computed(() => {
return vms.value.map((vm) => { return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0; const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0; const memMB = vm.memoryMB || vm.memory || 0;
@ -88,8 +76,8 @@ export default defineComponent({
const diskDisplay = totalDiskBytes ? `${ Math.round(totalDiskBytes / (1024 * 1024 * 1024)) } GB` : '-'; const diskDisplay = totalDiskBytes ? `${ Math.round(totalDiskBytes / (1024 * 1024 * 1024)) } GB` : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-'; const os = vm.guestName || vm.guestOS || vm.os || '-';
const vmNetworkMappings = networkMappings.value.filter((m) => m.vmId === vm.id); const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const vmStorageMappings = storageMappings.value.filter((m) => m.vmId === vm.id); const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
return { return {
id: vm.id, id: vm.id,
@ -102,9 +90,17 @@ export default defineComponent({
storageMappings: vmStorageMappings, storageMappings: vmStorageMappings,
}; };
}); });
}); });
const cancel = async() => {
const inStore = store.getters['currentProduct'].inStore;
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
if (provider) {
await provider.remove();
}
const cancel = () => {
router.push({ router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`, name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: { params: {
@ -112,9 +108,9 @@ export default defineComponent({
cluster: route.params.cluster, cluster: route.params.cluster,
} }
}); });
}; };
const startMigration = async(buttonCb) => { const startMigration = async(buttonCb) => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
try { try {
@ -126,13 +122,13 @@ export default defineComponent({
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider', kind: 'Provider',
name: providerName.value, name: providerName.value,
namespace: namespace.value, namespace: NAMESPACE,
}, },
destination: { destination: {
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider', kind: 'Provider',
name: 'host', name: 'host',
namespace: 'forklift', namespace: NAMESPACE,
}, },
}, },
map: { map: {
@ -140,16 +136,16 @@ export default defineComponent({
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'NetworkMap', kind: 'NetworkMap',
name: networkMapName.value, name: networkMapName.value,
namespace: namespace.value, namespace: NAMESPACE,
}, },
storage: { storage: {
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'StorageMap', kind: 'StorageMap',
name: storageMapName.value, name: storageMapName.value,
namespace: namespace.value, namespace: NAMESPACE,
}, },
}, },
targetNamespace: namespace.value, targetNamespace: 'default',
vms: vms.value.map((vm) => ({ vms: vms.value.map((vm) => ({
id: vm.id, id: vm.id,
name: vm.name || vm.id, name: vm.name || vm.id,
@ -161,33 +157,56 @@ export default defineComponent({
type: HCI.FORKLIFT_PLAN, type: HCI.FORKLIFT_PLAN,
metadata: { metadata: {
name: planName, name: planName,
namespace: namespace.value, namespace: NAMESPACE,
}, },
spec: planSpec, spec: planSpec,
}); });
await plan.save(); await plan.save();
// Build ownerReference pointing to the Plan
const planOwnerRef = {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: plan.metadata.name,
uid: plan.metadata.uid,
blockOwnerDeletion: true,
};
// Create Migration owned by Plan
const migrationName = `${ planName }-migration-${ Math.random().toString(36).substring(2, 7) }`; const migrationName = `${ planName }-migration-${ Math.random().toString(36).substring(2, 7) }`;
const migration = await store.dispatch(`${ inStore }/create`, { const migration = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_MIGRATION, type: HCI.FORKLIFT_MIGRATION,
metadata: { metadata: {
name: migrationName, name: migrationName,
namespace: namespace.value, namespace: NAMESPACE,
ownerReferences: [planOwnerRef],
}, },
spec: { spec: {
plan: { plan: {
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan', kind: 'Plan',
name: planName, name: planName,
namespace: namespace.value, namespace: NAMESPACE,
}, },
}, },
}); });
await migration.save(); await migration.save();
// Set Plan as owner of Provider (Provider already owns NetworkMap, StorageMap, Secret)
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
if (provider) {
provider.metadata.ownerReferences = [
...(provider.metadata.ownerReferences || []),
planOwnerRef,
];
await provider.save();
}
router.push({ router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`, name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: { params: {
@ -201,9 +220,9 @@ export default defineComponent({
errors.value = [err.message || err]; errors.value = [err.message || err];
buttonCb(false); buttonCb(false);
} }
}; };
const init = async() => { const init = async() => {
const vmsParam = route.query.vms; const vmsParam = route.query.vms;
if (vmsParam) { if (vmsParam) {
@ -243,27 +262,9 @@ export default defineComponent({
} }
loading.value = false; loading.value = false;
}; };
init(); init();
return {
schema,
loading,
vms,
errors,
providerName,
namespace,
totalVCpu,
totalMemoryGB,
totalStorageGB,
vmCards,
t,
cancel,
startMigration,
};
},
});
</script> </script>
<template> <template>
@ -310,7 +311,7 @@ export default defineComponent({
<span class="detail-label">{{ t('harvester.addons.forklift.reviewMigration.targetNamespace') }}</span> <span class="detail-label">{{ t('harvester.addons.forklift.reviewMigration.targetNamespace') }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-value">{{ namespace }}</span> <span class="detail-value">{{ TARGET_NAMESPACE }}</span>
</div> </div>
</div> </div>
<div class="detail-column"> <div class="detail-column">
@ -334,9 +335,6 @@ export default defineComponent({
<div class="detail-item"> <div class="detail-item">
<span class="detail-value"> <span class="detail-value">
{{ t('harvester.addons.forklift.reviewMigration.coldMigration') }} {{ t('harvester.addons.forklift.reviewMigration.coldMigration') }}
<span
class="ml-10 small-text"
>{{ t('harvester.addons.forklift.reviewMigration.vmsWillShutDown') }}</span>
</span> </span>
</div> </div>
</div> </div>

View File

@ -1,11 +1,11 @@
<script> <script setup>
import { defineComponent, ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead'; import Masthead from '@shell/components/ResourceList/Masthead';
import SortableTable from '@shell/components/SortableTable'; import SortableTable from '@shell/components/SortableTable';
import { LabeledInput } from '@components/Form/LabeledInput'; import { BadgeState } from '@components/BadgeState';
import { SCHEMA } from '@shell/config/types'; import { SCHEMA } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types'; import { HCI } from '../../../../types';
@ -22,55 +22,35 @@ const schema = {
metadata: { name: HCI.FORKLIFT_PROVIDER }, metadata: { name: HCI.FORKLIFT_PROVIDER },
}; };
export default defineComponent({ const store = useStore();
name: 'ForkliftSelectVms', const route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
components: { const allProviders = ref([]);
Loading, const provider = ref(null);
Masthead, const discoveredVMs = ref([]);
SortableTable, const selectedVMs = ref([]);
LabeledInput, const tableRows = ref([]);
}, const loading = ref(true);
setup() { const providerName = computed(() => provider.value?.metadata?.name || route.query.provider || '');
const store = useStore(); const vmCount = computed(() => discoveredVMs.value.length);
const route = useRoute(); const selectedCount = computed(() => selectedVMs.value.length);
const router = useRouter();
const { t } = useI18n(store);
const allProviders = ref([]); const headers = [
const provider = ref(null);
const discoveredVMs = ref([]);
const selectedVMs = ref([]);
const manualVMs = ref([]);
const newVmId = ref('');
const tableRows = ref([]);
const loading = ref(true);
const providerName = computed(() => provider.value?.metadata?.name || route.query.provider || '');
const vmCount = computed(() => discoveredVMs.value.length);
const showManualEntry = computed(() => discoveredVMs.value.length === 0);
const selectedCount = computed(() => showManualEntry.value ? manualVMs.value.length : selectedVMs.value.length);
const manualHeaders = [
{
name: 'id',
labelKey: 'harvester.addons.forklift.selectVms.columns.vmId',
value: 'id',
},
];
const headers = [
{ {
name: 'vmName', name: 'vmName',
labelKey: 'harvester.addons.forklift.selectVms.columns.vmName', labelKey: 'harvester.addons.forklift.selectVms.columns.vmName',
value: 'vmName', value: 'vmName',
sort: ['vmName'],
subLabel: 'Identifier', subLabel: 'Identifier',
}, },
{ {
name: 'os', name: 'os',
labelKey: 'harvester.addons.forklift.selectVms.columns.os', labelKey: 'harvester.addons.forklift.selectVms.columns.os',
value: 'os', value: 'os',
sort: ['os'],
}, },
{ {
name: 'resources', name: 'resources',
@ -82,22 +62,25 @@ export default defineComponent({
name: 'powerState', name: 'powerState',
labelKey: 'harvester.addons.forklift.selectVms.columns.powerState', labelKey: 'harvester.addons.forklift.selectVms.columns.powerState',
value: 'powerState', value: 'powerState',
sort: ['powerState'],
}, },
{ {
name: 'network', name: 'network',
labelKey: 'harvester.addons.forklift.selectVms.columns.network', labelKey: 'harvester.addons.forklift.selectVms.columns.network',
value: 'network', value: 'network',
sort: ['network'],
subLabel: 'Identifier', subLabel: 'Identifier',
}, },
{ {
name: 'datastore', name: 'datastore',
labelKey: 'harvester.addons.forklift.selectVms.columns.datastore', labelKey: 'harvester.addons.forklift.selectVms.columns.datastore',
value: 'datastore', value: 'datastore',
sort: ['datastore'],
subLabel: 'Identifier', subLabel: 'Identifier',
}, },
]; ];
const buildTableRows = () => { const buildTableRows = () => {
return discoveredVMs.value.map((vm) => { return discoveredVMs.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || '-'; const cpus = vm.cpuCount || vm.numCPU || '-';
const memMB = vm.memoryMB || vm.memory || 0; const memMB = vm.memoryMB || vm.memory || 0;
@ -110,7 +93,8 @@ export default defineComponent({
} }
const diskDisplay = totalDiskBytes ? `${ Math.round(totalDiskBytes / (1024 * 1024 * 1024)) } GB` : '-'; const diskDisplay = totalDiskBytes ? `${ Math.round(totalDiskBytes / (1024 * 1024 * 1024)) } GB` : '-';
const powerState = vm.powerState || vm.status?.phase || '-'; const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
let networks = []; let networks = [];
@ -158,31 +142,21 @@ export default defineComponent({
datastore: datastores.map((d) => d.name).join(', ') || '-', datastore: datastores.map((d) => d.name).join(', ') || '-',
}; };
}); });
}; };
const addManualVm = () => { const onSelect = (rows) => {
if (!newVmId.value) { selectedVMs.value = rows.map((r) => r._original);
return; };
const cancel = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providers = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
const found = providers.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === 'forklift');
if (found) {
await found.remove();
} }
manualVMs.value.push({
id: newVmId.value.trim(),
name: newVmId.value.trim(),
_key: `manual-${ newVmId.value.trim() }`,
});
newVmId.value = '';
};
const removeManualVm = (index) => {
manualVMs.value.splice(index, 1);
};
const onSelect = (rows) => {
selectedVMs.value = rows.map((r) => r._original);
};
const cancel = () => {
router.push({ router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`, name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: { params: {
@ -190,16 +164,10 @@ export default defineComponent({
cluster: route.params.cluster, cluster: route.params.cluster,
} }
}); });
}; };
const saveSelection = () => { const saveSelection = () => {
let vmIds; const vmIds = selectedVMs.value.map((vm) => vm.id || vm.vmId || vm.metadata?.name);
if (showManualEntry.value) {
vmIds = manualVMs.value.map((vm) => vm.id);
} else {
vmIds = selectedVMs.value.map((vm) => vm.id || vm.vmId || vm.metadata?.name);
}
router.push({ router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`, name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`,
@ -209,23 +177,21 @@ export default defineComponent({
}, },
query: { query: {
provider: providerName.value, provider: providerName.value,
namespace: provider.value?.metadata?.namespace || 'default',
vms: JSON.stringify(vmIds), vms: JSON.stringify(vmIds),
} }
}); });
}; };
const init = async() => { const init = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER }); allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
const queryProvider = route.query.provider; const queryProvider = route.query.provider;
const namespace = route.query.namespace || 'default';
if (queryProvider) { if (queryProvider) {
provider.value = allProviders.value.find( provider.value = allProviders.value.find(
(p) => p.metadata.name === queryProvider && p.metadata.namespace === namespace (p) => p.metadata.name === queryProvider && p.metadata.namespace === 'forklift'
); );
} }
@ -245,34 +211,9 @@ export default defineComponent({
tableRows.value = buildTableRows(); tableRows.value = buildTableRows();
loading.value = false; loading.value = false;
}; };
init(); init();
return {
schema,
loading,
provider,
providerName,
discoveredVMs,
selectedVMs,
manualVMs,
newVmId,
tableRows,
vmCount,
showManualEntry,
selectedCount,
manualHeaders,
headers,
t,
addManualVm,
removeManualVm,
onSelect,
cancel,
saveSelection,
};
},
});
</script> </script>
<template> <template>
@ -299,66 +240,8 @@ export default defineComponent({
</template> </template>
</Masthead> </Masthead>
<!-- Manual VM entry when no discovered VMs -->
<div v-if="showManualEntry">
<h3 class="m-0 mb-10">
{{ t('harvester.addons.forklift.selectVms.manualEntry.title') }}
</h3>
<p class="text-muted mb-20">
{{ t('harvester.addons.forklift.selectVms.manualEntry.description') }}
</p>
<div class="manual-vm-form">
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="newVmId"
:label="t('harvester.addons.forklift.selectVms.manualEntry.vmId')"
:placeholder="t('harvester.addons.forklift.selectVms.manualEntry.vmIdPlaceholder')"
/>
</div>
<div class="col span-2 add-btn-col">
<button
class="btn role-primary"
:disabled="!newVmId"
@click="addManualVm"
>
{{ t('harvester.addons.forklift.selectVms.manualEntry.add') }}
</button>
</div>
</div>
</div>
<SortableTable
:rows="manualVMs"
:headers="manualHeaders"
:search="false"
:table-actions="false"
:row-actions="false"
:groupable="false"
key-field="_key"
class="mt-20"
>
<template #header-left>
<span class="text-muted">{{ manualVMs.length }} {{ t('harvester.addons.forklift.selectVms.manualEntry.added') }}</span>
</template>
<template #cell:id="{ row }">
<div class="manual-vm-row">
<span>{{ row.id }}</span>
<button
class="btn btn-sm role-link"
@click="removeManualVm(manualVMs.indexOf(row))"
>
<i class="icon icon-close" />
</button>
</div>
</template>
</SortableTable>
</div>
<!-- Discovered VMs table --> <!-- Discovered VMs table -->
<SortableTable <SortableTable
v-else
:rows="tableRows" :rows="tableRows"
:headers="headers" :headers="headers"
:search="true" :search="true"
@ -387,10 +270,10 @@ export default defineComponent({
<span class="text-muted">{{ row.resourcesSub }}</span> <span class="text-muted">{{ row.resourcesSub }}</span>
</template> </template>
<template #cell:powerState="{ row }"> <template #cell:powerState="{ row }">
<span <BadgeState
class="power-badge" :label="row.powerState"
:class="row.powerStateClass" :color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
>{{ row.powerState }}</span> />
</template> </template>
<template #cell:network="{ row }"> <template #cell:network="{ row }">
<div> <div>
@ -456,59 +339,17 @@ export default defineComponent({
.vm-name-cell { .vm-name-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: 14px;
.vm-name { .vm-name {
font-weight: 600;
line-height: 20px; line-height: 20px;
} }
.vm-id { .vm-id {
font-size: 12px;
line-height: 20px; line-height: 20px;
} }
} }
.power-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
&.power-on {
background-color: #d4edda;
color: #155724;
}
&.power-off {
background-color: #fff3cd;
color: #856404;
}
}
.manual-vm-form {
.add-btn-col {
display: flex;
align-items: flex-end;
}
}
.manual-vm-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.multi-line-cell {
display: flex;
flex-direction: column;
line-height: 20px;
}
.mt-4 {
margin-top: 4px;
}
.actions-footer { .actions-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;