mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
feat: Added some final fixes to structure
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
parent
f270a7b1f8
commit
a11369ad6d
@ -8,6 +8,7 @@ import { useI18n } from '@shell/composables/useI18n';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { HCI } from '../../types';
|
||||
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
|
||||
import MappingColumn from './MappingColumn.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@ -254,49 +255,15 @@ const formatStorageDetail = (entry) => {
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const buildNetworkMapSpec = (providerRef) => {
|
||||
return {
|
||||
map: networkEntries.value.filter((entry) => !!entry.target).map((entry) => {
|
||||
if (entry.target === 'pod') {
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { type: 'pod' },
|
||||
};
|
||||
}
|
||||
const buildNetworkMapSpec = (providerRef) => ({
|
||||
map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
|
||||
provider: providerRef,
|
||||
});
|
||||
|
||||
if (entry.target === 'ignored') {
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { type: 'ignored' },
|
||||
};
|
||||
}
|
||||
|
||||
const parts = entry.target.split('/');
|
||||
const netName = parts.length > 1 ? parts[1] : parts[0];
|
||||
const netNamespace = parts.length > 1 ? parts[0] : NAMESPACE;
|
||||
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: {
|
||||
type: 'multus',
|
||||
name: netName,
|
||||
namespace: netNamespace,
|
||||
},
|
||||
};
|
||||
}),
|
||||
provider: providerRef,
|
||||
};
|
||||
};
|
||||
|
||||
const buildStorageMapSpec = (providerRef) => {
|
||||
return {
|
||||
map: storageEntries.value.filter((entry) => !!entry.target).map((entry) => ({
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { storageClass: entry.target },
|
||||
})),
|
||||
provider: providerRef,
|
||||
};
|
||||
};
|
||||
const buildStorageMapSpec = (providerRef) => ({
|
||||
map: buildStorageMapEntries(storageEntries.value),
|
||||
provider: providerRef,
|
||||
});
|
||||
|
||||
const saveAndReturn = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
@ -12,6 +12,7 @@ import { randomStr } from '@shell/utils/string';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../types';
|
||||
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||
import { decodeSecretValue } from '../../utils/forklift';
|
||||
|
||||
const CREATE_NEW = '__create_new__';
|
||||
|
||||
@ -114,17 +115,9 @@ watch(selectedProvider, (val) => {
|
||||
);
|
||||
|
||||
if (secret?.data) {
|
||||
const decode = (val) => {
|
||||
try {
|
||||
return atob(val || '');
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
username.value = decode(secret.data.user);
|
||||
password.value = decode(secret.data.password);
|
||||
skipTlsVerify.value = decode(secret.data.insecureSkipVerify) === 'true';
|
||||
username.value = decodeSecretValue(secret.data.user);
|
||||
password.value = decodeSecretValue(secret.data.password);
|
||||
skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,9 +7,11 @@ import { RcItemCard } from '@components/RcItemCard';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import MappingsCell from '../MappingsCell';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { HCI } from '../../types';
|
||||
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||
import {
|
||||
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
|
||||
} from '../../utils/forklift';
|
||||
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
@ -47,7 +49,7 @@ const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCoun
|
||||
const totalMemoryGB = computed(() => {
|
||||
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
|
||||
|
||||
return Math.round(totalMB / 1024);
|
||||
return mbToGB(totalMB);
|
||||
});
|
||||
|
||||
const totalStorageGB = computed(() => {
|
||||
@ -59,14 +61,14 @@ const totalStorageGB = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
return Math.round(totalBytes / (1024 * 1024 * 1024));
|
||||
return bytesToGB(totalBytes);
|
||||
});
|
||||
|
||||
const vmCards = computed(() => {
|
||||
return vms.value.map((vm) => {
|
||||
const cpus = vm.cpuCount || vm.numCPU || 0;
|
||||
const memMB = vm.memoryMB || vm.memory || 0;
|
||||
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(memMB / 1024) }) : '-';
|
||||
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||
|
||||
let totalDiskBytes = 0;
|
||||
|
||||
@ -74,7 +76,7 @@ const vmCards = computed(() => {
|
||||
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||
}
|
||||
|
||||
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(totalDiskBytes / (1024 * 1024 * 1024)) }) : '-';
|
||||
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||
const os = vm.guestName || vm.guestOS || vm.os || '-';
|
||||
|
||||
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
|
||||
@ -93,52 +95,18 @@ const vmCards = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const buildNetworkMapSpec = () => {
|
||||
const entries = props.mappingEntries?.networkEntries || [];
|
||||
|
||||
return entries.map((entry) => {
|
||||
if (entry.target === 'pod') {
|
||||
return { source: { name: entry.name, id: entry.id }, destination: { type: 'pod' } };
|
||||
}
|
||||
|
||||
if (entry.target === 'ignored') {
|
||||
return { source: { name: entry.name, id: entry.id }, destination: { type: 'ignored' } };
|
||||
}
|
||||
|
||||
const parts = entry.target.split('/');
|
||||
const netName = parts.length > 1 ? parts[1] : parts[0];
|
||||
const netNamespace = parts.length > 1 ? parts[0] : NAMESPACE;
|
||||
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: {
|
||||
type: 'multus', name: netName, namespace: netNamespace
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const buildStorageMapSpec = () => {
|
||||
const entries = props.mappingEntries?.storageEntries || [];
|
||||
|
||||
return entries.map((entry) => ({
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { storageClass: entry.target },
|
||||
}));
|
||||
};
|
||||
|
||||
const startMigrationAction = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
const providerRef = {
|
||||
source: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'Provider',
|
||||
name: props.providerName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
destination: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'Provider',
|
||||
name: 'host',
|
||||
namespace: NAMESPACE,
|
||||
@ -151,7 +119,7 @@ const startMigrationAction = async() => {
|
||||
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||
metadata: { name: networkMapName, namespace: NAMESPACE },
|
||||
spec: { map: buildNetworkMapSpec(), provider: providerRef },
|
||||
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NAMESPACE), provider: providerRef },
|
||||
});
|
||||
|
||||
await networkMap.save();
|
||||
@ -159,7 +127,7 @@ const startMigrationAction = async() => {
|
||||
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||
metadata: { name: storageMapName, namespace: NAMESPACE },
|
||||
spec: { map: buildStorageMapSpec(), provider: providerRef },
|
||||
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
|
||||
});
|
||||
|
||||
await storageMap.save();
|
||||
@ -171,19 +139,19 @@ const startMigrationAction = async() => {
|
||||
provider: providerRef,
|
||||
map: {
|
||||
network: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'NetworkMap',
|
||||
name: networkMapName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
storage: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'StorageMap',
|
||||
name: storageMapName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
},
|
||||
targetNamespace: 'default',
|
||||
targetNamespace: TARGET_NAMESPACE,
|
||||
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
|
||||
warm: false,
|
||||
},
|
||||
@ -192,36 +160,21 @@ const startMigrationAction = async() => {
|
||||
await plan.save();
|
||||
|
||||
const planOwnerRef = {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'Plan',
|
||||
name: plan.metadata.name,
|
||||
uid: plan.metadata.uid,
|
||||
blockOwnerDeletion: true,
|
||||
};
|
||||
|
||||
const migration = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_MIGRATION,
|
||||
metadata: {
|
||||
name: `${ planName.value }-migration-${ randomStr(5).toLowerCase() }`,
|
||||
namespace: NAMESPACE,
|
||||
ownerReferences: [planOwnerRef],
|
||||
},
|
||||
spec: {
|
||||
plan: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Plan',
|
||||
name: planName.value,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration.save();
|
||||
|
||||
networkMap.metadata.ownerReferences = [planOwnerRef];
|
||||
await networkMap.save();
|
||||
storageMap.metadata.ownerReferences = [planOwnerRef];
|
||||
await storageMap.save();
|
||||
|
||||
// Kick off the first migration through the model so the Migration payload
|
||||
// lives in a single place (also reused by the dashboard start/restart action).
|
||||
await plan.startMigration();
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
@ -464,32 +417,6 @@ defineExpose({ startMigration: startMigrationAction });
|
||||
}
|
||||
}
|
||||
|
||||
.vm-card-mappings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.mapping-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
.icon {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.small-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #973C00;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import SortableTable from '@shell/components/SortableTable';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { BadgeState } from '@components/BadgeState';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { bytesToGB, mbToGB } from '../../utils/forklift';
|
||||
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
@ -139,7 +140,7 @@ const buildTableRows = () => {
|
||||
return discoveredVMs.value.map((vm) => {
|
||||
const cpus = vm.cpuCount || vm.numCPU || '-';
|
||||
const memMB = vm.memoryMB || vm.memory || 0;
|
||||
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(memMB / 1024) }) : '-';
|
||||
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||
|
||||
let totalDiskBytes = 0;
|
||||
|
||||
@ -147,7 +148,7 @@ const buildTableRows = () => {
|
||||
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||
}
|
||||
|
||||
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(totalDiskBytes / (1024 * 1024 * 1024)) }) : '-';
|
||||
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||
const rawPowerState = vm.powerState || vm.status?.phase || '-';
|
||||
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
@ -524,11 +525,6 @@ init();
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vm-table-title {
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-deemphasized {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@ -250,9 +250,7 @@ export default class ForkliftPlan extends HarvesterResource {
|
||||
* 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 };
|
||||
|
||||
remove(opt = {}) {
|
||||
opt.params = { propagationPolicy: 'Foreground' };
|
||||
|
||||
return this._remove(opt);
|
||||
|
||||
@ -28,9 +28,7 @@ export default class ForkliftProvider extends HarvesterResource {
|
||||
* Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap.
|
||||
* Use foreground propagation to ensure children are deleted before the parent.
|
||||
*/
|
||||
remove() {
|
||||
const opt = { ...arguments };
|
||||
|
||||
remove(opt = {}) {
|
||||
opt.params = { propagationPolicy: 'Foreground' };
|
||||
|
||||
return this._remove(opt);
|
||||
|
||||
@ -150,7 +150,6 @@ const headers = [
|
||||
{
|
||||
name: 'mappings',
|
||||
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
|
||||
value: 'mappingsDisplay',
|
||||
},
|
||||
{ ...AGE },
|
||||
];
|
||||
|
||||
@ -9,6 +9,7 @@ import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/Config
|
||||
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();
|
||||
@ -79,10 +80,19 @@ 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) => {
|
||||
@ -198,17 +208,9 @@ const init = async() => {
|
||||
);
|
||||
|
||||
if (secret?.data) {
|
||||
const decode = (val) => {
|
||||
try {
|
||||
return atob(val || '');
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
stepData.provider.username = decode(secret.data.user);
|
||||
stepData.provider.password = decode(secret.data.password);
|
||||
stepData.provider.skipTlsVerify = decode(secret.data.insecureSkipVerify) === 'true';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,10 +98,20 @@ watch(reviewReady, (val) => {
|
||||
steps[3].ready = val;
|
||||
});
|
||||
|
||||
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
|
||||
// "test connection before advancing" handshake, since the provider must be verified
|
||||
// before the user can leave the first step.
|
||||
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
|
||||
|
||||
const pendingProceed = ref(false);
|
||||
|
||||
// Handshake to gate step 1 -> 2 on a successful connection test:
|
||||
// 1. When the user tries to advance off the provider step before it's ready,
|
||||
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
|
||||
// programmatically trigger the async "Test connection" button.
|
||||
// 2. Once the test flips `providerReady` (watcher below), mark the step ready and
|
||||
// advance to the next step (goToStep(2)). If the test fails, `providerTesting`
|
||||
// watcher clears `pendingProceed` and the user stays put.
|
||||
watch(
|
||||
() => wizardComponent.value?.activeStepIndex,
|
||||
(newIdx, oldIdx) => {
|
||||
|
||||
80
pkg/harvester/utils/forklift.js
Normal file
80
pkg/harvester/utils/forklift.js
Normal file
@ -0,0 +1,80 @@
|
||||
// Shared helpers for the Forklift VM-migration feature.
|
||||
// Keep the map-spec builders here so the wizard step and the review step
|
||||
// (and any future edit flow) stay in sync instead of drifting apart.
|
||||
|
||||
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
|
||||
|
||||
/**
|
||||
* Decode a base64 value stored in a k8s Secret, tolerating malformed input.
|
||||
*/
|
||||
export function decodeSecretValue(val) {
|
||||
try {
|
||||
return atob(val || '');
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes → whole GiB (rounded). Returns 0 for falsy input.
|
||||
*/
|
||||
export function bytesToGB(bytes) {
|
||||
return Math.round((bytes || 0) / (1024 * 1024 * 1024));
|
||||
}
|
||||
|
||||
/**
|
||||
* MiB → whole GiB (rounded). Returns 0 for falsy input.
|
||||
*/
|
||||
export function mbToGB(mb) {
|
||||
return Math.round((mb || 0) / 1024);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `spec.map` entries for a Forklift NetworkMap from wizard entries.
|
||||
* Entries without a chosen target are dropped so we never emit an invalid
|
||||
* multus destination with an empty name.
|
||||
*
|
||||
* @param {Array} entries network mapping entries ({ name, id, target })
|
||||
* @param {string} defaultNamespace namespace to use when the target has no `ns/` prefix
|
||||
*/
|
||||
export function buildNetworkMapEntries(entries = [], defaultNamespace) {
|
||||
return entries
|
||||
.filter((entry) => !!entry.target)
|
||||
.map((entry) => {
|
||||
const source = { name: entry.name, id: entry.id };
|
||||
|
||||
if (entry.target === 'pod') {
|
||||
return { source, destination: { type: 'pod' } };
|
||||
}
|
||||
|
||||
if (entry.target === 'ignored') {
|
||||
return { source, destination: { type: 'ignored' } };
|
||||
}
|
||||
|
||||
const parts = entry.target.split('/');
|
||||
const name = parts.length > 1 ? parts[1] : parts[0];
|
||||
const namespace = parts.length > 1 ? parts[0] : defaultNamespace;
|
||||
|
||||
return {
|
||||
source,
|
||||
destination: {
|
||||
type: 'multus', name, namespace
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `spec.map` entries for a Forklift StorageMap from wizard entries.
|
||||
* Entries without a chosen target are dropped.
|
||||
*
|
||||
* @param {Array} entries storage mapping entries ({ name, id, target })
|
||||
*/
|
||||
export function buildStorageMapEntries(entries = []) {
|
||||
return entries
|
||||
.filter((entry) => !!entry.target)
|
||||
.map((entry) => ({
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { storageClass: entry.target },
|
||||
}));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user