mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-10-03 20:56:06 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66aedf00d4 | ||
|
|
fbb4b7b41b | ||
|
|
3817269c0a | ||
|
|
facddcb9a7 | ||
|
|
7db6c81c5c | ||
|
|
74ef7b3772 | ||
|
|
b87f367583 | ||
|
|
92aa8ec603 | ||
|
|
e013534373 | ||
|
|
d23c3d3b72 | ||
|
|
719c33fa12 | ||
|
|
a6bd7ec00e | ||
|
|
ac26fe2f0e | ||
|
|
790cade04d | ||
|
|
2903e86c02 | ||
|
|
2d747e435a | ||
|
|
eadda2a18b | ||
|
|
a178302f73 | ||
|
|
d376bfb3a5 | ||
|
|
7a84e08f99 | ||
|
|
3d39626b93 | ||
|
|
1c3b40b631 | ||
|
|
87baf75301 | ||
|
|
a10d651827 | ||
|
|
11ff7c8a7c | ||
|
|
f6dffe9816 | ||
|
|
b813bc578e | ||
|
|
7c3b2730ab | ||
|
|
429d9e18ad | ||
|
|
dc166d8fc9 | ||
|
|
3ff4534294 | ||
|
|
d4223ad2ae | ||
|
|
44297ada16 | ||
|
|
aea53fdd65 | ||
|
|
9640c87947 | ||
|
|
2cefd7cb78 | ||
|
|
fb6ffa3c0f | ||
|
|
e0436bff28 | ||
|
|
219f39ae52 | ||
|
|
41cf44d231 | ||
|
|
7d09cb7142 | ||
|
|
560643d299 | ||
|
|
c3bb0ea4be | ||
|
|
3c37f04ef6 | ||
|
|
d98db6ba49 | ||
|
|
531ed03796 | ||
|
|
59f260eab8 |
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
- 'release-harvester-v*'
|
||||
- '*-dev'
|
||||
- access_volume_mode_demo
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@@ -28,8 +29,4 @@ jobs:
|
||||
packages: write
|
||||
id-token: write
|
||||
with:
|
||||
<<<<<<< HEAD
|
||||
CI_BRANCH: ${{github.ref_name}}
|
||||
=======
|
||||
CI_BRANCH: ${{ github.event_name == 'push' && github.ref_name || '' }}
|
||||
>>>>>>> 719c33f (ci: fix CI_BRANCH ternary so PR builds skip upload gate (#1062))
|
||||
|
||||
@@ -36,7 +36,7 @@ module.exports = {
|
||||
'body-leading-blank': [2, 'always'],
|
||||
'body-max-line-length': [0, 'always', 100],
|
||||
'footer-leading-blank': [2, 'always'],
|
||||
'footer-max-line-length': [2, 'always', 100],
|
||||
'footer-max-line-length': [0, 'always', 100],
|
||||
},
|
||||
// Ignore merge commits and revert commits
|
||||
ignores: [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harvester-ui-extension",
|
||||
"version": "1.9.0-rc3",
|
||||
"version": "1.10.0-dev",
|
||||
"private": false,
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
|
||||
@@ -7,10 +7,15 @@ 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 { VOLUME_MODE, ACCESS_MODE } from '../../config/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';
|
||||
import StorageDefaultsModal from './StorageDefaultsModal.vue';
|
||||
|
||||
const DEFAULT_VOLUME_MODE = VOLUME_MODE.FILE_SYSTEM;
|
||||
const DEFAULT_ACCESS_MODES = [ACCESS_MODE.READ_WRITE_MANY];
|
||||
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
@@ -35,6 +40,10 @@ const allStorageMaps = ref([]);
|
||||
const errors = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
// Storage defaults edit modal state.
|
||||
const showStorageDefaultsModal = ref(false);
|
||||
const editingStorageEntry = ref(null);
|
||||
|
||||
const { networkEntries, storageEntries } = toRefs(props.stepData);
|
||||
|
||||
const NAMESPACE = FORKLIFT_NAMESPACE;
|
||||
@@ -105,7 +114,7 @@ const applyNetworkMapTargets = (mapSpec) => {
|
||||
});
|
||||
};
|
||||
|
||||
const applyStorageMapTargets = (mapSpec) => {
|
||||
const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
|
||||
if (!mapSpec) {
|
||||
return;
|
||||
}
|
||||
@@ -117,6 +126,30 @@ const applyStorageMapTargets = (mapSpec) => {
|
||||
|
||||
if (match?.destination?.storageClass) {
|
||||
entry.target = match.destination.storageClass;
|
||||
|
||||
if (captureInherited) {
|
||||
entry.inheritedFromProvider = true;
|
||||
}
|
||||
|
||||
if (match.destination.volumeMode) {
|
||||
entry.volumeMode = match.destination.volumeMode;
|
||||
|
||||
if (captureInherited) {
|
||||
entry.inheritedVolumeMode = match.destination.volumeMode;
|
||||
}
|
||||
}
|
||||
|
||||
if (match.destination.accessMode) {
|
||||
entry.accessModes = [match.destination.accessMode];
|
||||
|
||||
if (captureInherited) {
|
||||
entry.inheritedAccessModes = [match.destination.accessMode];
|
||||
}
|
||||
}
|
||||
|
||||
if (markOverridden) {
|
||||
entry.overridden = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -134,12 +167,39 @@ const applyDefaultStorageMap = () => {
|
||||
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
|
||||
);
|
||||
|
||||
applyStorageMapTargets(defaultMap?.spec?.map);
|
||||
applyStorageMapTargets(defaultMap?.spec?.map, { captureInherited: true });
|
||||
};
|
||||
|
||||
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));
|
||||
|
||||
// The "Inherited from provider" hint only appears in the migration plan wizard
|
||||
// (where mappings inherit from the provider default), not on the provider creation page.
|
||||
const inheritedProviderName = computed(() => (props.useAllProviderData ? '' : props.providerName));
|
||||
|
||||
const openStorageDefaults = (entry) => {
|
||||
editingStorageEntry.value = entry;
|
||||
showStorageDefaultsModal.value = true;
|
||||
};
|
||||
|
||||
const closeStorageDefaults = () => {
|
||||
showStorageDefaultsModal.value = false;
|
||||
editingStorageEntry.value = null;
|
||||
};
|
||||
|
||||
const applyStorageDefaults = ({ volumeMode, accessModes }) => {
|
||||
if (editingStorageEntry.value) {
|
||||
editingStorageEntry.value.volumeMode = volumeMode;
|
||||
editingStorageEntry.value.accessModes = accessModes;
|
||||
|
||||
const inheritedVolumeMode = editingStorageEntry.value.inheritedVolumeMode;
|
||||
const inheritedAccessMode = editingStorageEntry.value.inheritedAccessModes?.[0];
|
||||
const selectedAccessMode = accessModes?.[0];
|
||||
|
||||
editingStorageEntry.value.overridden = volumeMode !== inheritedVolumeMode || selectedAccessMode !== inheritedAccessMode;
|
||||
}
|
||||
};
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (props.useAllProviderData) {
|
||||
return true;
|
||||
@@ -202,13 +262,19 @@ const buildStorageEntries = () => {
|
||||
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 }`,
|
||||
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
|
||||
id: ds.id,
|
||||
type: ds.type || '',
|
||||
capacity: 0,
|
||||
target: '',
|
||||
volumeMode: DEFAULT_VOLUME_MODE,
|
||||
accessModes: [...DEFAULT_ACCESS_MODES],
|
||||
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
|
||||
inheritedFromProvider: false,
|
||||
overridden: false,
|
||||
usedBy: [],
|
||||
_key: `stor-${ ds.id }`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,13 +304,19 @@ const buildNetworkEntriesFromProvider = (networksData) => {
|
||||
|
||||
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 }`,
|
||||
name: ds.name || ds.id,
|
||||
id: ds.id || '',
|
||||
type: ds.type || '',
|
||||
capacity: ds.capacity || 0,
|
||||
target: '',
|
||||
volumeMode: DEFAULT_VOLUME_MODE,
|
||||
accessModes: [...DEFAULT_ACCESS_MODES],
|
||||
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
|
||||
inheritedFromProvider: false,
|
||||
overridden: false,
|
||||
usedBy: [],
|
||||
_key: `stor-${ ds.id || ds.name }`,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -461,7 +533,7 @@ const init = async() => {
|
||||
|
||||
if (!hasExistingStorageTargets) {
|
||||
if (props.existingStorageMap?.spec?.map) {
|
||||
applyStorageMapTargets(props.existingStorageMap.spec.map);
|
||||
applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true });
|
||||
} else {
|
||||
applyDefaultStorageMap();
|
||||
}
|
||||
@@ -511,6 +583,9 @@ init();
|
||||
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
|
||||
:show-used-by="!useAllProviderData"
|
||||
:clearable="useAllProviderData"
|
||||
:show-volume-settings="true"
|
||||
:inherited-provider-name="inheritedProviderName"
|
||||
@edit-defaults="openStorageDefaults"
|
||||
>
|
||||
<template #source-detail="{ entry }">
|
||||
<span
|
||||
@@ -520,6 +595,19 @@ init();
|
||||
</template>
|
||||
</MappingColumn>
|
||||
</div>
|
||||
|
||||
<StorageDefaultsModal
|
||||
v-if="showStorageDefaultsModal && editingStorageEntry"
|
||||
:storage-class-name="editingStorageEntry.target"
|
||||
:provider-name="providerName"
|
||||
:show-inherited="!!inheritedProviderName"
|
||||
:volume-mode="editingStorageEntry.volumeMode"
|
||||
:access-modes="editingStorageEntry.accessModes"
|
||||
:inherited-volume-mode="editingStorageEntry.inheritedVolumeMode"
|
||||
:inherited-access-modes="editingStorageEntry.inheritedAccessModes"
|
||||
@apply="applyStorageDefaults"
|
||||
@close="closeStorageDefaults"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,20 +3,27 @@ import { useStore } from 'vuex';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { RcItemCard } from '@components/RcItemCard';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { VOLUME_MODE } from '../../config/types';
|
||||
|
||||
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 },
|
||||
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 },
|
||||
// Storage-specific: show the volume/access mode defaults row with an Edit action.
|
||||
showVolumeSettings: { type: Boolean, default: false },
|
||||
// When set, the defaults row shows an "Inherited from provider" hint (migration plan wizard only).
|
||||
inheritedProviderName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit-defaults']);
|
||||
|
||||
// Only offer "Remove Map" for entries that already have a target selected;
|
||||
// entries without a selection just show the regular options.
|
||||
const optionsFor = (entry) => {
|
||||
@@ -38,6 +45,13 @@ const optionsFor = (entry) => {
|
||||
...props.options,
|
||||
];
|
||||
};
|
||||
|
||||
const formatModes = (entry) => {
|
||||
const volumeMode = entry.volumeMode || VOLUME_MODE.FILE_SYSTEM;
|
||||
const accessModes = (entry.accessModes || []).join(', ');
|
||||
|
||||
return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -87,6 +101,29 @@ const optionsFor = (entry) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showVolumeSettings && entry.target"
|
||||
class="storage-defaults-row"
|
||||
>
|
||||
<div class="storage-defaults">
|
||||
<div class="storage-defaults-info">
|
||||
<span class="storage-defaults-summary">{{ formatModes(entry) }}</span>
|
||||
<span
|
||||
v-if="inheritedProviderName && entry.inheritedFromProvider && !entry.overridden"
|
||||
class="text-deemphasized storage-defaults-inherited"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.inherited', { provider: inheritedProviderName }) }}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
role="button"
|
||||
class="storage-defaults-edit"
|
||||
@click.prevent="emit('edit-defaults', entry)"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.edit') }}
|
||||
</a>
|
||||
</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>
|
||||
@@ -173,6 +210,41 @@ const optionsFor = (entry) => {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.storage-defaults-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.storage-defaults {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--body-bg);
|
||||
|
||||
.storage-defaults-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.storage-defaults-summary {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.storage-defaults-inherited {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.storage-defaults-edit {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-light-gray {
|
||||
background-color: var(--category-active) !important;
|
||||
border: 0;
|
||||
|
||||
@@ -25,8 +25,9 @@ const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
|
||||
|
||||
const selectedVMs = ref([]);
|
||||
const loading = ref(true);
|
||||
const networkMap = ref({});
|
||||
const datastoreMap = ref({});
|
||||
// Restored from stepData so friendly names survive step navigation / remount.
|
||||
const networkMap = ref(props.stepData.networkMap || {});
|
||||
const datastoreMap = ref(props.stepData.datastoreMap || {});
|
||||
const sortableTableRef = ref(null);
|
||||
const allVMsSelected = ref(false);
|
||||
const errors = ref([]);
|
||||
@@ -94,7 +95,7 @@ const showSelectAllBanner = computed(() => {
|
||||
|
||||
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
|
||||
|
||||
const headers = [
|
||||
const headers = ref([
|
||||
{
|
||||
name: 'vmName',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
|
||||
@@ -139,7 +140,7 @@ const headers = [
|
||||
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||
width: 200,
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const buildTableRows = () => {
|
||||
return discoveredVMs.value.map((vm) => {
|
||||
@@ -190,6 +191,7 @@ const buildTableRows = () => {
|
||||
return {
|
||||
_original: vm,
|
||||
_key: vm.id || vm.vmId || vm.metadata?.name,
|
||||
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
|
||||
vmName: vm.name || vm.metadata?.name || '-',
|
||||
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
|
||||
os: vm.guestName || vm.guestOS || vm.os || '-',
|
||||
@@ -239,6 +241,10 @@ const clearSelection = () => {
|
||||
|
||||
if (table) {
|
||||
table.clearSelection();
|
||||
|
||||
if (typeof table.setPage === 'function') {
|
||||
table.setPage(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -249,6 +255,49 @@ const selectAllVMs = () => {
|
||||
selectedVMs.value = discoveredVMs.value.slice();
|
||||
};
|
||||
|
||||
// Moves currently-selected VMs to the first page(s) and re-checks them.
|
||||
const sortSelectedToFront = () => {
|
||||
if (selectedVMIds.value.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
tableRows.value.forEach((row) => {
|
||||
row.selectedSort = selectedVMIds.value.has(row._original?.id) ? 0 : 1;
|
||||
});
|
||||
|
||||
headers.value = headers.value.map((h) => (
|
||||
h.name === 'vmName' ? { ...h, sort: ['selectedSort', 'vmName'] } : h
|
||||
));
|
||||
|
||||
skipNextSelectionEvent = true;
|
||||
|
||||
nextTick(() => {
|
||||
const table = sortableTableRef.value;
|
||||
|
||||
if (!table) {
|
||||
skipNextSelectionEvent = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof table.changeSort === 'function') {
|
||||
table.changeSort('vmName', false);
|
||||
} else if (typeof table.setPage === 'function') {
|
||||
table.setPage(1);
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||
|
||||
if (rowsToReselect.length > 0) {
|
||||
table.update(rowsToReselect, []);
|
||||
}
|
||||
|
||||
skipNextSelectionEvent = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => sortableTableRef.value?.page,
|
||||
() => {
|
||||
@@ -318,6 +367,9 @@ const fetchVMs = async() => {
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
props.stepData.networkMap = networkMap.value;
|
||||
props.stepData.datastoreMap = datastoreMap.value;
|
||||
|
||||
lastFetchedAt.value = Date.now();
|
||||
tableRows.value = buildTableRows();
|
||||
};
|
||||
@@ -374,6 +426,10 @@ const init = async() => {
|
||||
tableRows.value = buildTableRows();
|
||||
loading.value = false;
|
||||
|
||||
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
|
||||
await nextTick();
|
||||
sortSelectedToFront();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -441,7 +497,6 @@ init();
|
||||
:row-actions="false"
|
||||
:groupable="false"
|
||||
:paging="true"
|
||||
:rows-per-page="20"
|
||||
key-field="_key"
|
||||
@selection="onSelect"
|
||||
>
|
||||
@@ -450,7 +505,20 @@ init();
|
||||
<h3 class="m-0">
|
||||
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
|
||||
</h3>
|
||||
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
|
||||
<span class="selected-actions">
|
||||
<span class="text-deemphasized">
|
||||
{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}
|
||||
</span>
|
||||
<template v-if="selectedCount > 0">
|
||||
<span class="text-deemphasized">|</span>
|
||||
<a
|
||||
role="button"
|
||||
@click.prevent="clearSelection"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.selectVms.clearAll') }}
|
||||
</a>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell:vmName="{ row }">
|
||||
@@ -466,7 +534,7 @@ init();
|
||||
<template #cell:powerState="{ row }">
|
||||
<BadgeState
|
||||
:label="row.powerState"
|
||||
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
|
||||
:color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'"
|
||||
/>
|
||||
</template>
|
||||
<template #cell:network="{ row }">
|
||||
@@ -533,6 +601,23 @@ init();
|
||||
.text-deemphasized {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.selected-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vm-name-cell {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { Card } from '@components/Card';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import AppModal from '@shell/components/AppModal';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
|
||||
|
||||
const VOLUME_MODE_OPTIONS = [VOLUME_MODE.FILE_SYSTEM, VOLUME_MODE.BLOCK];
|
||||
const ACCESS_MODE_OPTIONS = [ACCESS_MODE.READ_WRITE_ONCE, ACCESS_MODE.READ_WRITE_MANY, ACCESS_MODE.READ_ONLY_MANY];
|
||||
|
||||
const props = defineProps({
|
||||
storageClassName: { type: String, default: '' },
|
||||
providerName: { type: String, default: '' },
|
||||
showInherited: { type: Boolean, default: false },
|
||||
volumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
|
||||
accessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
|
||||
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
|
||||
inheritedAccessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['apply', 'close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const localVolumeMode = ref(props.volumeMode || VOLUME_MODE.FILE_SYSTEM);
|
||||
const localAccessMode = ref(props.accessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY);
|
||||
|
||||
const volumeModeOptions = VOLUME_MODE_OPTIONS.map((value) => ({ label: value, value }));
|
||||
const accessModeOptions = ACCESS_MODE_OPTIONS.map((value) => ({ label: value, value }));
|
||||
|
||||
const apply = () => {
|
||||
emit('apply', {
|
||||
volumeMode: localVolumeMode.value,
|
||||
accessModes: [localAccessMode.value],
|
||||
});
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// Repopulate the dropdowns with the provider default values; the user then applies.
|
||||
const reset = () => {
|
||||
localVolumeMode.value = props.inheritedVolumeMode || VOLUME_MODE.FILE_SYSTEM;
|
||||
localAccessMode.value = props.inheritedAccessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY;
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<app-modal
|
||||
class="storage-defaults-modal"
|
||||
name="storageDefaultsDialog"
|
||||
:width="620"
|
||||
height="auto"
|
||||
:click-to-close="false"
|
||||
@close="cancel"
|
||||
>
|
||||
<Card
|
||||
class="storage-defaults-card"
|
||||
:show-highlight-border="false"
|
||||
>
|
||||
<template #title>
|
||||
<h4 class="text-default-text">
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.title', { name: storageClassName }) }}
|
||||
</h4>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<p class="text-deemphasized description">
|
||||
<template v-if="showInherited">
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }}
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="settings-box">
|
||||
<LabeledSelect
|
||||
v-model:value="localVolumeMode"
|
||||
class="volume-mode"
|
||||
:label="t('harvester.addons.vmMigration.storageDefaults.volumeMode')"
|
||||
:options="volumeModeOptions"
|
||||
:searchable="false"
|
||||
:clearable="false"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-model:value="localAccessMode"
|
||||
class="access-mode"
|
||||
:label="t('harvester.addons.vmMigration.storageDefaults.accessMode')"
|
||||
:options="accessModeOptions"
|
||||
:searchable="false"
|
||||
:clearable="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="btn role-secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.cancel') }}
|
||||
</button>
|
||||
<div class="right-buttons">
|
||||
<button
|
||||
v-if="showInherited"
|
||||
class="btn role-secondary mr-10"
|
||||
@click="reset"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.resetToProviderDefault') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn role-primary"
|
||||
:disabled="!localAccessMode"
|
||||
@click="apply"
|
||||
>
|
||||
{{ showInherited ? t('harvester.addons.vmMigration.storageDefaults.applyOverride') : t('harvester.addons.vmMigration.storageDefaults.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</app-modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.storage-defaults-modal {
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.storage-defaults-card {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 16px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.settings-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--category-active);
|
||||
}
|
||||
|
||||
.volume-mode {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.access-mode {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
.right-buttons {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,13 @@ export const VOLUME_MODE = {
|
||||
FILE_SYSTEM: 'Filesystem'
|
||||
};
|
||||
|
||||
export const ACCESS_MODE = {
|
||||
READ_WRITE_MANY: 'ReadWriteMany',
|
||||
READ_WRITE_ONCE: 'ReadWriteOnce',
|
||||
READ_ONLY_MANY: 'ReadOnlyMany',
|
||||
READ_WRITE_ONCE_POD: 'ReadWriteOncePod',
|
||||
};
|
||||
|
||||
export const NETWORK_PROTOCOL = {
|
||||
IPv4: 'IPv4',
|
||||
IPv6: 'IPv6',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { VOLUME_MODE } from '@pkg/harvester/config/types';
|
||||
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import ArrayList from '@shell/components/form/ArrayList';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
@@ -10,6 +10,10 @@ import { HCI_SETTING } from '../../config/settings';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { _EDIT, _CREATE } from '@shell/config/query-params';
|
||||
|
||||
const {
|
||||
READ_WRITE_ONCE, READ_ONLY_MANY, READ_WRITE_MANY, READ_WRITE_ONCE_POD
|
||||
} = ACCESS_MODE;
|
||||
|
||||
export default {
|
||||
name: 'CDISettings',
|
||||
|
||||
@@ -90,10 +94,10 @@ export default {
|
||||
|
||||
accessModeOptions() {
|
||||
return [
|
||||
{ label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
|
||||
{ label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
|
||||
{ label: 'ReadWriteMany', value: 'ReadWriteMany' },
|
||||
{ label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
|
||||
{ label: READ_WRITE_ONCE, value: READ_WRITE_ONCE },
|
||||
{ label: READ_ONLY_MANY, value: READ_ONLY_MANY },
|
||||
{ label: READ_WRITE_MANY, value: READ_WRITE_MANY },
|
||||
{ label: READ_WRITE_ONCE_POD, value: READ_WRITE_ONCE_POD },
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ import { HCI, VOLUME_SNAPSHOT } from '../types';
|
||||
import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
|
||||
import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim';
|
||||
import { GIBIBYTE } from '../utils/unit';
|
||||
import { VOLUME_MODE } from '@pkg/harvester/config/types';
|
||||
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
|
||||
import { isInternalStorageClass } from '../utils/storage-class';
|
||||
|
||||
const { READ_WRITE_MANY, READ_WRITE_ONCE, READ_ONLY_MANY } = ACCESS_MODE;
|
||||
|
||||
export default {
|
||||
name: 'HarvesterVolume',
|
||||
|
||||
@@ -83,7 +85,7 @@ export default {
|
||||
if (this.mode === _CREATE) {
|
||||
// default volumeMode to Block
|
||||
this.value.spec.volumeMode = VOLUME_MODE.BLOCK;
|
||||
this.value.spec.accessModes = ['ReadWriteMany'];
|
||||
this.value.spec.accessModes = [READ_WRITE_MANY];
|
||||
}
|
||||
|
||||
const storage = this.value?.spec?.resources?.requests?.storage || null;
|
||||
@@ -160,7 +162,7 @@ export default {
|
||||
},
|
||||
|
||||
accessModeOptions() {
|
||||
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
|
||||
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY];
|
||||
},
|
||||
|
||||
imageOption() {
|
||||
@@ -355,7 +357,7 @@ export default {
|
||||
|
||||
getAccessMode() {
|
||||
if (!this.longhornV2LVMSupport) {
|
||||
return ['ReadWriteMany'];
|
||||
return [READ_WRITE_MANY];
|
||||
}
|
||||
|
||||
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
|
||||
@@ -371,7 +373,7 @@ export default {
|
||||
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.value.thirdPartyStorageFeatureEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
|
||||
}
|
||||
|
||||
return readWriteOnce ? ['ReadWriteOnce'] : ['ReadWriteMany'];
|
||||
return readWriteOnce ? [READ_WRITE_ONCE] : [READ_WRITE_MANY];
|
||||
},
|
||||
buildDataVolumeObj() {
|
||||
const storage = {
|
||||
|
||||
-6
@@ -68,12 +68,6 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
osType(neu) {
|
||||
if (neu === 'windows') {
|
||||
this.id = '';
|
||||
}
|
||||
},
|
||||
|
||||
value(neu) {
|
||||
this.yamlScript = neu;
|
||||
},
|
||||
|
||||
@@ -16,10 +16,12 @@ import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
|
||||
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
|
||||
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
|
||||
import { HCI } from '../../../types';
|
||||
import { VOLUME_MODE } from '@pkg/harvester/config/types';
|
||||
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
|
||||
import { OFF } from '../../../models/kubevirt.io.virtualmachine';
|
||||
import { EMPTY_IMAGE } from '../../../utils/vm';
|
||||
|
||||
const { READ_WRITE_MANY } = ACCESS_MODE;
|
||||
|
||||
export default {
|
||||
emits: ['update:value'],
|
||||
|
||||
@@ -69,7 +71,7 @@ export default {
|
||||
|
||||
customAccessMode: {
|
||||
type: String,
|
||||
default: 'ReadWriteMany'
|
||||
default: READ_WRITE_MANY
|
||||
},
|
||||
|
||||
resourceType: {
|
||||
|
||||
@@ -14,7 +14,9 @@ import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storagec
|
||||
import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim';
|
||||
import { GIBIBYTE } from '../../../../utils/unit';
|
||||
import { isInternalStorageClass } from '../../../../utils/storage-class';
|
||||
import { VOLUME_MODE } from '@pkg/harvester/config/types';
|
||||
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
|
||||
|
||||
const { READ_WRITE_MANY, READ_WRITE_ONCE } = ACCESS_MODE;
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditVolume',
|
||||
@@ -205,7 +207,7 @@ export default {
|
||||
|
||||
getAccessMode(storageClassName) {
|
||||
if (!this.longhornV2LVMSupport) {
|
||||
return 'ReadWriteMany';
|
||||
return READ_WRITE_MANY;
|
||||
}
|
||||
|
||||
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
|
||||
@@ -216,7 +218,7 @@ export default {
|
||||
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.thirdPartyStorageClassEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
|
||||
}
|
||||
|
||||
return readWriteOnce ? 'ReadWriteOnce' : 'ReadWriteMany';
|
||||
return readWriteOnce ? READ_WRITE_ONCE : READ_WRITE_MANY;
|
||||
},
|
||||
|
||||
update() {
|
||||
|
||||
@@ -1062,7 +1062,6 @@ export default {
|
||||
</div>
|
||||
|
||||
<CloudConfig
|
||||
v-if="!isWindows"
|
||||
ref="yamlEditor"
|
||||
:user-script="userScript"
|
||||
:mode="mode"
|
||||
|
||||
@@ -132,16 +132,28 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showIP">
|
||||
<span
|
||||
<div
|
||||
v-if="showIP"
|
||||
class="ip-list"
|
||||
>
|
||||
<div
|
||||
v-for="{ ip, name, isCustom } in ips"
|
||||
:key="`${ip}-${name}`"
|
||||
class="ip-item"
|
||||
>
|
||||
<CopyToClipboardText
|
||||
v-clean-tooltip="isCustom ? t('harvester.formatters.harvesterIpAddress.customIpTooltip') : name"
|
||||
:text="ip"
|
||||
:plain="isCustom"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ip-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1922,6 +1922,7 @@ harvester:
|
||||
refreshNow: Refresh now
|
||||
availableVms: Available Virtual Machines
|
||||
selected: selected
|
||||
clearAll: Clear All
|
||||
saveSelection: Save Selection and Continue
|
||||
selectAllBanner:
|
||||
pageOnly: "Your current selection includes only VMs currently shown on the page."
|
||||
@@ -1982,6 +1983,19 @@ harvester:
|
||||
description: Map VMware datastores to Harvester storage classes
|
||||
placeholder: Choose a Harvester Storage Class
|
||||
template: Use existing storage mapping as template
|
||||
storageDefaults:
|
||||
title: 'Storage defaults — {name}'
|
||||
description: 'Set the volume mode and access modes applied to every datastore in this storage map that maps to {name}.'
|
||||
descriptionInherited: 'Applies to every datastore in this storage map that maps to {name}. The provider default for {provider} stays unchanged.'
|
||||
summary: 'Volume mode: {volumeMode} · Access mode: {accessModes}'
|
||||
inherited: 'Inherited from provider {provider}'
|
||||
volumeMode: Volume Mode
|
||||
accessMode: Access Mode
|
||||
edit: Edit
|
||||
cancel: Cancel
|
||||
apply: Apply
|
||||
resetToProviderDefault: Reset to Provider Default
|
||||
applyOverride: Apply Override
|
||||
reviewMigration:
|
||||
title: Review Migration Plan
|
||||
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
|
||||
|
||||
@@ -25,7 +25,9 @@ import { HCI } from '../../types';
|
||||
import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm';
|
||||
import impl, { QGA_JSON, USB_TABLET } from './impl';
|
||||
import { GIBIBYTE } from '../../utils/unit';
|
||||
import { VOLUME_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
|
||||
import { VOLUME_MODE, ACCESS_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
|
||||
|
||||
const { READ_WRITE_MANY } = ACCESS_MODE;
|
||||
|
||||
const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine';
|
||||
|
||||
@@ -276,7 +278,7 @@ export default {
|
||||
},
|
||||
|
||||
customAccessMode() {
|
||||
return this.storageClassSetting.accessModes || 'ReadWriteMany';
|
||||
return this.storageClassSetting.accessModes || READ_WRITE_MANY;
|
||||
},
|
||||
|
||||
isWindows() {
|
||||
@@ -504,7 +506,7 @@ export default {
|
||||
id: randomStr(5),
|
||||
source: SOURCE_TYPE.IMAGE,
|
||||
name: 'disk-0',
|
||||
accessMode: 'ReadWriteMany', // root disk only support LHv1 volume, should be RWX
|
||||
accessMode: READ_WRITE_MANY, // root disk only support LHv1 volume, should be RWX
|
||||
bus,
|
||||
volumeName: '',
|
||||
size,
|
||||
@@ -570,7 +572,7 @@ export default {
|
||||
const pvcResource = allPVCs.find( (O) => O.id === `${ namespace }/${ volume?.persistentVolumeClaim?.claimName }`);
|
||||
|
||||
source = SOURCE_TYPE.ATTACH_VOLUME;
|
||||
accessMode = pvcResource?.spec?.accessModes?.[0] || 'ReadWriteMany';
|
||||
accessMode = pvcResource?.spec?.accessModes?.[0] || READ_WRITE_MANY;
|
||||
size = pvcResource?.spec?.resources?.requests?.storage || '10Gi';
|
||||
storageClassName = pvcResource?.spec?.storageClassName;
|
||||
volumeMode = pvcResource?.spec?.volumeMode || VOLUME_MODE.BLOCK;
|
||||
@@ -789,18 +791,41 @@ export default {
|
||||
}
|
||||
|
||||
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
|
||||
const annotations = vm.metadata.annotations;
|
||||
|
||||
Object.keys(vm.metadata.annotations).forEach((key) => {
|
||||
if (key.startsWith(staticIpPrefix)) {
|
||||
delete vm.metadata.annotations[key];
|
||||
}
|
||||
});
|
||||
const desired = {};
|
||||
|
||||
this.networkRows.forEach((row) => {
|
||||
if (row.name && row.staticIp) {
|
||||
vm.metadata.annotations[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
|
||||
desired[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
|
||||
}
|
||||
});
|
||||
|
||||
const current = {};
|
||||
|
||||
Object.keys(annotations).forEach((key) => {
|
||||
if (key.startsWith(staticIpPrefix)) {
|
||||
current[key] = annotations[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Skip mutation when already in sync to avoid triggering a reactive update loop.
|
||||
const desiredKeys = Object.keys(desired);
|
||||
const currentKeys = Object.keys(current);
|
||||
const isSame = desiredKeys.length === currentKeys.length &&
|
||||
desiredKeys.every((key) => current[key] === desired[key]);
|
||||
|
||||
if (isSame) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentKeys.forEach((key) => {
|
||||
delete annotations[key];
|
||||
});
|
||||
|
||||
Object.entries(desired).forEach(([key, value]) => {
|
||||
annotations[key] = value;
|
||||
});
|
||||
},
|
||||
|
||||
setCPUAndMemory() {
|
||||
@@ -915,32 +940,30 @@ export default {
|
||||
}
|
||||
|
||||
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) {
|
||||
if (!this.isWindows) {
|
||||
disks.push({
|
||||
name: 'cloudinitdisk',
|
||||
disk: { bus: 'virtio' }
|
||||
});
|
||||
disks.push({
|
||||
name: 'cloudinitdisk',
|
||||
disk: { bus: 'virtio' }
|
||||
});
|
||||
|
||||
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
|
||||
const cloudinitdisk = {
|
||||
name: 'cloudinitdisk',
|
||||
cloudInitNoCloud: {}
|
||||
};
|
||||
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
|
||||
const cloudinitdisk = {
|
||||
name: 'cloudinitdisk',
|
||||
cloudInitNoCloud: {}
|
||||
};
|
||||
|
||||
if (this.saveUserDataAsClearText) {
|
||||
cloudinitdisk.cloudInitNoCloud.userData = userData;
|
||||
} else {
|
||||
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
|
||||
}
|
||||
|
||||
if (this.saveNetworkDataAsClearText) {
|
||||
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
|
||||
} else {
|
||||
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
|
||||
}
|
||||
|
||||
volumes.push(cloudinitdisk);
|
||||
if (this.saveUserDataAsClearText) {
|
||||
cloudinitdisk.cloudInitNoCloud.userData = userData;
|
||||
} else {
|
||||
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
|
||||
}
|
||||
|
||||
if (this.saveNetworkDataAsClearText) {
|
||||
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
|
||||
} else {
|
||||
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
|
||||
}
|
||||
|
||||
volumes.push(cloudinitdisk);
|
||||
}
|
||||
|
||||
const specDisks = this.spec?.template?.spec?.domain?.devices?.disks;
|
||||
@@ -1142,6 +1165,16 @@ export default {
|
||||
},
|
||||
|
||||
getInitUserData(config) {
|
||||
// Windows guests don't use qemu-guest-agent via systemd (VMDP installs
|
||||
// the QGA as a Windows service), and the Linux `runcmd` recipe would
|
||||
// fail on Cloudbase-Init. Return an empty string so `this.userData`
|
||||
// stays falsy on Windows until the user actually enters cloud-config
|
||||
// content — this prevents emitting a spurious cloudinitdisk volume
|
||||
// and Secret for every Windows VM (Copilot review comment on #984).
|
||||
if (config.osType === 'windows') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const _QGA_JSON = this.getMatchQGA(config.osType);
|
||||
|
||||
const out = jsyaml.dump(_QGA_JSON);
|
||||
@@ -1344,13 +1377,22 @@ export default {
|
||||
*/
|
||||
deleteYamlDocProp(doc, paths) {
|
||||
try {
|
||||
const item = doc.getIn([])?.items[0];
|
||||
const key = item?.key;
|
||||
const hasCloudConfigComment = !!key?.commentBefore?.includes('cloud-config');
|
||||
const isMatchProp = key.source === paths[paths.length - 1];
|
||||
const items = doc.getIn([])?.items;
|
||||
const firstKey = items?.[0]?.key;
|
||||
const hasCloudConfigComment = !!firstKey?.commentBefore?.includes('cloud-config');
|
||||
const isFirstProp = firstKey?.source === paths[paths.length - 1];
|
||||
|
||||
if (key && hasCloudConfigComment && isMatchProp) {
|
||||
// Comments are mounted on the next node and we should not delete the node containing cloud-config
|
||||
if (firstKey && hasCloudConfigComment && isFirstProp) {
|
||||
const comment = firstKey.commentBefore;
|
||||
|
||||
doc.deleteIn(paths);
|
||||
|
||||
// Move the comment to the new first key; if no keys remain the comment is lost.
|
||||
const newFirstKey = doc.getIn([])?.items?.[0]?.key;
|
||||
|
||||
if (newFirstKey) {
|
||||
newFirstKey.commentBefore = comment;
|
||||
}
|
||||
} else {
|
||||
doc.deleteIn(paths);
|
||||
}
|
||||
@@ -1401,7 +1443,6 @@ export default {
|
||||
if (packages.length > 0) {
|
||||
userDataDoc.setIn(['packages'], packages);
|
||||
} else {
|
||||
userDataDoc.setIn(['packages'], []); // It needs to be set empty first, as it is possible that cloud-init comments are mounted on this node
|
||||
this.deleteYamlDocProp(userDataDoc, ['packages']);
|
||||
this.deleteYamlDocProp(userDataDoc, ['package_update']);
|
||||
}
|
||||
@@ -1416,7 +1457,7 @@ export default {
|
||||
},
|
||||
|
||||
deleteQGA(config) {
|
||||
const { osType, userDataDoc, deletePackage = false } = config;
|
||||
const { osType, userDataDoc } = config;
|
||||
|
||||
const userDataTemplateValue = this.$store.getters['harvester/byId'](CONFIG_MAP, this.userDataTemplateId)?.data?.cloudInit || '';
|
||||
|
||||
@@ -1425,6 +1466,15 @@ export default {
|
||||
const packages = userDataJSON?.packages || [];
|
||||
const runcmd = userDataJSON?.runcmd || [];
|
||||
|
||||
// Special handling of OS types.
|
||||
let deletePackage = config.deletePackage ?? false;
|
||||
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
deletePackage = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (Array.isArray(packages) && deletePackage) {
|
||||
const templateHasQGAPackage = this.convertToJson(userDataTemplateValue);
|
||||
|
||||
@@ -1450,7 +1500,6 @@ export default {
|
||||
if (packages.length > 0) {
|
||||
userDataDoc.setIn(['packages'], packages);
|
||||
} else {
|
||||
userDataDoc.setIn(['packages'], []);
|
||||
this.deleteYamlDocProp(userDataDoc, ['packages']);
|
||||
this.deleteYamlDocProp(userDataDoc, ['package_update']);
|
||||
}
|
||||
@@ -1483,7 +1532,7 @@ export default {
|
||||
},
|
||||
|
||||
async saveSecret(vm) {
|
||||
if (!vm?.spec || !this.secretName || this.isWindows) {
|
||||
if (!vm?.spec || !this.secretName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1904,8 +1953,6 @@ export default {
|
||||
isWindows(val) {
|
||||
if (val) {
|
||||
this['sshKey'] = [];
|
||||
this['userScript'] = undefined;
|
||||
this['networkScript'] = undefined;
|
||||
this['installAgent'] = false;
|
||||
}
|
||||
},
|
||||
@@ -1965,9 +2012,8 @@ export default {
|
||||
|
||||
osType(neu, old) {
|
||||
this.installAgent = old === 'windows' ? true : this.installAgent;
|
||||
const out = old === 'windows' ? this.getInitUserData({ osType: neu }) : this.getUserData({ installAgent: this.installAgent, osType: neu });
|
||||
this.userScript = this.getUserData({ installAgent: this.installAgent, osType: neu });
|
||||
|
||||
this['userScript'] = out;
|
||||
this.refreshYamlEditor();
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "harvester",
|
||||
"description": "Rancher UI Extension for Harvester",
|
||||
"version": "1.9.0-rc3",
|
||||
"version": "1.10.0-dev",
|
||||
"private": false,
|
||||
"rancher": {
|
||||
"annotations": {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -163,7 +164,7 @@ const onFinish = async(buttonCb) => {
|
||||
buttonCb(true);
|
||||
currentRouter().push(providerListLocation);
|
||||
} catch (err) {
|
||||
errors.value = [err instanceof Error ? err.message : String(err)];
|
||||
errors.value = exceptionToErrorsArray(err).map((e) => (typeof e === 'string' ? e : stringify(e)));
|
||||
buttonCb(false);
|
||||
}
|
||||
};
|
||||
@@ -233,7 +234,7 @@ const init = async() => {
|
||||
// Maps may not exist yet
|
||||
}
|
||||
} catch (err) {
|
||||
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || err })];
|
||||
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || stringify(err) })];
|
||||
}
|
||||
|
||||
initialLoading.value = false;
|
||||
|
||||
@@ -24,13 +24,14 @@ export function registerAddonSideNav(store, productName, {
|
||||
const kickSideNav = () => {
|
||||
const TRIGGER = 'ui.refresh.trigger';
|
||||
|
||||
store.dispatch('type-map/addFavorite', TRIGGER);
|
||||
|
||||
// SideNav component seem to ignore rapid state changes.
|
||||
// Wait 600ms to ensure the toggle event triggers a re-render.
|
||||
setTimeout(() => {
|
||||
store.dispatch('type-map/removeFavorite', TRIGGER);
|
||||
}, 600);
|
||||
// Toggle the trigger a few times so an early kick (fired before the SideNav
|
||||
// has mounted on first login) is retried once the component is listening.
|
||||
[0, 600, 1500].forEach((delay) => {
|
||||
setTimeout(() => {
|
||||
store.dispatch('type-map/addFavorite', TRIGGER);
|
||||
setTimeout(() => store.dispatch('type-map/removeFavorite', TRIGGER), 300);
|
||||
}, delay);
|
||||
});
|
||||
};
|
||||
|
||||
const hasAccessibleSchema = (t) => {
|
||||
@@ -92,16 +93,25 @@ export function registerAddonSideNav(store, productName, {
|
||||
// Store is ready. Stop polling.
|
||||
clearInterval(waitForStore);
|
||||
|
||||
// Watch the specific addon resource for changes to its enabled status.
|
||||
// Watch the addon's enabled status together with the schema availability
|
||||
// of the gated types. Schemas (e.g. forklift CRDs) can load after the
|
||||
// addon is already enabled, so the watcher must also re-run when they
|
||||
// become accessible; otherwise the menu never updates until a refresh.
|
||||
store.watch(
|
||||
(state, getters) => {
|
||||
const addons = getters[`${ productName }/all`](resourceType);
|
||||
const addon = addons.find((a) => a.metadata.name === addonName);
|
||||
const isEnabled = addon?.spec?.enabled === true;
|
||||
|
||||
return addon?.spec?.enabled === true;
|
||||
const schemaReady = requireSchema ? types.every(hasAccessibleSchema) : true;
|
||||
|
||||
return `${ isEnabled }:${ schemaReady }`;
|
||||
},
|
||||
(isEnabled) => {
|
||||
setMenuVisibility(isEnabled);
|
||||
() => {
|
||||
const addons = store.getters[`${ productName }/all`](resourceType);
|
||||
const addon = addons.find((a) => a.metadata.name === addonName);
|
||||
|
||||
setMenuVisibility(addon?.spec?.enabled === true);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
@@ -73,8 +73,21 @@ export function buildNetworkMapEntries(entries = [], defaultNamespace) {
|
||||
export function buildStorageMapEntries(entries = []) {
|
||||
return entries
|
||||
.filter((entry) => !!entry.target)
|
||||
.map((entry) => ({
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { storageClass: entry.target },
|
||||
}));
|
||||
.map((entry) => {
|
||||
const destination = { storageClass: entry.target };
|
||||
|
||||
if (entry.volumeMode) {
|
||||
destination.volumeMode = entry.volumeMode;
|
||||
}
|
||||
|
||||
// Forklift StorageMap destination expects a single `accessMode` value.
|
||||
if (Array.isArray(entry.accessModes) && entry.accessModes.length) {
|
||||
destination.accessMode = entry.accessModes[0];
|
||||
}
|
||||
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user