mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
* fix: VM migration tab not appear when first login * feat: add access/ volume mode model * feat: add selected all | clear all actions for selected VM * fix: bugs in VM selection table * refactor: copilot review --------- (cherry picked from commit afe632b723bf6efc6dfdaec983413e25fa481a6c) Signed-off-by: Andy Lee <andy.lee@suse.com> Co-authored-by: Andy Lee <andy.lee@suse.com>
290 lines
8.9 KiB
Vue
290 lines
8.9 KiB
Vue
<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 { exceptionToErrorsArray, stringify } from '@shell/utils/error';
|
|
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,
|
|
vddkInitImage: '',
|
|
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 = exceptionToErrorsArray(err).map((e) => (typeof e === 'string' ? e : stringify(e)));
|
|
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.vddkInitImage = fetchedProvider.spec?.settings?.vddkInitImage || '';
|
|
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 || stringify(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>
|