mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
formatDuration returned an empty value when a migration was in progress (started set but completed missing), causing the duration column to show an em-dash for all running plans. It now computes elapsed time up to Date.now() when completed is absent, only returning the placeholder when started is unset or timestamps are invalid. Related issue: harvester/harvester#10417 Signed-off-by: Andy Lee <andy.lee@suse.com>
553 lines
18 KiB
Vue
553 lines
18 KiB
Vue
<script setup>
|
|
import { ref, computed } from 'vue';
|
|
import { useStore } from 'vuex';
|
|
import Loading from '@shell/components/Loading';
|
|
import Masthead from '@shell/components/ResourceList/Masthead';
|
|
import ResourceTable from '@shell/components/ResourceTable';
|
|
import PercentageBar from '@shell/components/PercentageBar';
|
|
import { Banner } from '@components/Banner';
|
|
import MappingsCell from '../../../../components/MappingsCell';
|
|
import { SCHEMA } from '@shell/config/types';
|
|
import { useI18n } from '@shell/composables/useI18n';
|
|
import { HCI } from '../../../../types';
|
|
import { PRODUCT_NAME } from '../../../../config/harvester';
|
|
import { ADD_ONS } from '../../../../config/harvester-map';
|
|
import { currentRouter } from '../../../../utils/router';
|
|
import { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers';
|
|
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
|
|
|
|
const schema = {
|
|
id: HCI.FORKLIFT_PLAN,
|
|
type: SCHEMA,
|
|
attributes: {
|
|
kind: HCI.FORKLIFT_PLAN,
|
|
namespaced: true
|
|
},
|
|
metadata: { name: HCI.FORKLIFT_PLAN },
|
|
};
|
|
|
|
const store = useStore();
|
|
const { t } = useI18n(store);
|
|
|
|
const loading = ref(true);
|
|
const errors = ref([]);
|
|
|
|
const inStore = computed(() => store.getters['currentProduct'].inStore);
|
|
|
|
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
|
|
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
|
|
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
|
|
const allVMs = computed(() => store.getters[`${ inStore.value }/all`](HCI.VM));
|
|
|
|
const escapeHtml = (value = '') => String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
|
|
const getPipelineStepMeta = (step = {}) => {
|
|
const phase = String(step.phase || '').toLowerCase();
|
|
const hasProgress = typeof step.progress?.completed === 'number' && typeof step.progress?.total === 'number' && step.progress.total > 0;
|
|
const progressPct = hasProgress ? Math.round((step.progress.completed / step.progress.total) * 1000) / 10 : null;
|
|
|
|
if (phase === 'completed' || phase === 'succeeded') {
|
|
return {
|
|
icon: '✓',
|
|
color: 'var(--success)',
|
|
text: ''
|
|
};
|
|
}
|
|
|
|
if (phase === 'running' || phase === 'inprogress' || phase === 'progressing' || phase === 'started' || (hasProgress && progressPct > 0)) {
|
|
return {
|
|
icon: '↻',
|
|
color: 'var(--primary)',
|
|
text: progressPct !== null ? `${ step.phase || t('generic.inProgress') } (${ progressPct }%)` : (step.phase || t('generic.inProgress'))
|
|
};
|
|
}
|
|
|
|
if (phase === 'failed' || phase === 'error') {
|
|
return {
|
|
icon: '✕',
|
|
color: 'var(--error)',
|
|
text: step.phase || t('harvester.addons.vmMigration.dashboard.progress.failed')
|
|
};
|
|
}
|
|
|
|
return {
|
|
icon: '○',
|
|
color: 'var(--muted)',
|
|
text: ''
|
|
};
|
|
};
|
|
|
|
const formatPipelineTooltip = (pipeline = []) => {
|
|
if (!pipeline.length) {
|
|
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:var(--muted);font-weight:700;">○</span><span>${ escapeHtml(t('harvester.addons.vmMigration.dashboard.progress.initializingMigration')) }</span></div>`;
|
|
}
|
|
|
|
return pipeline
|
|
.map((step, idx) => {
|
|
const stepName = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
|
|
const meta = getPipelineStepMeta(step);
|
|
const lineText = meta.text ? `${ escapeHtml(stepName) } : ${ escapeHtml(meta.text) }` : escapeHtml(stepName);
|
|
|
|
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:${ meta.color };font-weight:700;">${ meta.icon }</span><span>${ lineText }</span></div>`;
|
|
})
|
|
.join('');
|
|
};
|
|
|
|
const formatDuration = (started, completed) => {
|
|
if (!started) {
|
|
return { display: null, seconds: -1 };
|
|
}
|
|
|
|
const startMs = new Date(started).getTime();
|
|
const endMs = completed ? new Date(completed).getTime() : Date.now();
|
|
|
|
if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) {
|
|
return { display: null, seconds: -1 };
|
|
}
|
|
|
|
const totalSeconds = Math.floor((endMs - startMs) / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
const display = hours > 0 ? `${ hours }h ${ minutes }m` : `${ minutes }m ${ seconds }s`;
|
|
|
|
return { display, seconds: totalSeconds };
|
|
};
|
|
|
|
const rows = computed(() => {
|
|
return allPlans.value.map((plan) => {
|
|
const netMapName = plan.spec?.map?.network?.name;
|
|
const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace;
|
|
const storMapName = plan.spec?.map?.storage?.name;
|
|
const storMapNs = plan.spec?.map?.storage?.namespace || plan.metadata.namespace;
|
|
|
|
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
|
|
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
|
|
|
|
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking') : (e.destination?.name || 'Ignored') }`);
|
|
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' } → ${ e.destination?.storageClass || 'Ignored' }`);
|
|
|
|
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
|
|
|
|
const vms = plan.status?.migration?.vms || [];
|
|
|
|
plan.vmProgress = vms.map((vm) => {
|
|
const pipeline = vm.pipeline || [];
|
|
const totalSteps = pipeline.length || 1;
|
|
let overallProgress = 0;
|
|
let currentStep = '';
|
|
let errorMsg = '';
|
|
|
|
pipeline.forEach((step, idx) => {
|
|
const stepWeight = 100 / totalSteps;
|
|
|
|
if (step.phase === 'Completed') {
|
|
overallProgress += stepWeight;
|
|
} else {
|
|
const stepPct = (step.progress?.completed && step.progress?.total) ? (step.progress.completed / step.progress.total) * 100 : 0;
|
|
|
|
overallProgress += (stepPct / 100) * stepWeight;
|
|
|
|
if (!currentStep) {
|
|
currentStep = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
|
|
}
|
|
|
|
if (step.error && !errorMsg) {
|
|
const reasons = (step.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.plan.states.error');
|
|
|
|
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ reasons }`;
|
|
}
|
|
|
|
if (step.phase === 'Failed' && !errorMsg) {
|
|
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Fallback to VM-level error if no step-level error found
|
|
if (!errorMsg && vm.error) {
|
|
const reasons = (vm.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.dashboard.progress.failed');
|
|
|
|
errorMsg = `${ currentStep || vm.error.phase || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ reasons }`;
|
|
}
|
|
|
|
overallProgress = Math.round(overallProgress * 10) / 10;
|
|
|
|
// If no step-level error but the plan itself is failed, surface it
|
|
if (!errorMsg && plan.planFailed) {
|
|
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
|
|
}
|
|
|
|
const vmNamespace = vm.namespace || plan.spec?.targetNamespace || plan.metadata?.namespace;
|
|
const routeParams = currentRouter().currentRoute?.value?.params || {};
|
|
const product = routeParams.product || store.getters['productId'];
|
|
const cluster = routeParams.cluster || store.getters['clusterId'];
|
|
const vmNameCandidates = [vm.targetName, vm.name, vm.id].filter(Boolean);
|
|
const targetVm = allVMs.value.find((item) => vmNameCandidates.includes(item.metadata?.name) && (!vmNamespace || item.metadata?.namespace === vmNamespace));
|
|
const canNavigateToVm = overallProgress >= 100 && !errorMsg && !plan.planFailed && !plan.planCanceled && !!targetVm && !!product && !!cluster;
|
|
const vmDetailLocation = canNavigateToVm ? {
|
|
name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`,
|
|
params: {
|
|
product,
|
|
cluster,
|
|
resource: HCI.VM,
|
|
namespace: targetVm.metadata.namespace,
|
|
id: targetVm.metadata.name,
|
|
}
|
|
} : null;
|
|
|
|
return {
|
|
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
|
|
vmId: vm.id || '',
|
|
progress: overallProgress,
|
|
currentStep,
|
|
pipelineTooltip: formatPipelineTooltip(pipeline),
|
|
errorMsg,
|
|
canceled: plan.planCanceled,
|
|
vmDetailLocation,
|
|
};
|
|
});
|
|
|
|
// If no migration progress but we have VMs in the spec, show them at 0%
|
|
if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) {
|
|
plan.vmProgress = plan.spec.vms.map((vm) => ({
|
|
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
|
|
vmId: vm.id || '',
|
|
progress: 0,
|
|
currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'),
|
|
pipelineTooltip: formatPipelineTooltip([]),
|
|
errorMsg: '',
|
|
canceled: false,
|
|
}));
|
|
}
|
|
|
|
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0;
|
|
|
|
const durationResult = formatDuration(plan.status?.migration?.started, plan.status?.migration?.completed);
|
|
|
|
plan.duration = durationResult.display;
|
|
plan.durationSeconds = durationResult.seconds;
|
|
|
|
return plan;
|
|
});
|
|
});
|
|
|
|
const createLocation = computed(() => ({
|
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
|
params: {
|
|
product: store.getters['productId'],
|
|
cluster: store.getters['clusterId'],
|
|
}
|
|
}));
|
|
|
|
const headers = [
|
|
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
|
|
{
|
|
...NAME_COL,
|
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
|
|
},
|
|
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
|
|
{
|
|
name: 'progress',
|
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
|
|
value: 'progress',
|
|
width: 450,
|
|
},
|
|
{
|
|
name: 'mappings',
|
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
|
|
},
|
|
{
|
|
name: 'duration',
|
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration',
|
|
value: 'duration',
|
|
sort: 'durationSeconds',
|
|
width: 90,
|
|
},
|
|
{ ...AGE },
|
|
];
|
|
|
|
const init = async() => {
|
|
try {
|
|
// Guard: the Forklift dashboard is only available when the Forklift addon is
|
|
// enabled. Redirect back to the Harvester dashboard otherwise (e.g. direct URL).
|
|
let forkliftEnabled = false;
|
|
|
|
if (store.getters[`${ inStore.value }/schemaFor`](HCI.ADD_ONS)) {
|
|
const addons = await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.ADD_ONS });
|
|
|
|
forkliftEnabled = addons.find((a) => a.metadata?.name === ADD_ONS.FORKLIFT_OPERATOR)?.spec?.enabled === true;
|
|
}
|
|
|
|
if (!forkliftEnabled) {
|
|
await currentRouter().replace({
|
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
|
params: {
|
|
product: store.getters['productId'],
|
|
cluster: store.getters['clusterId'],
|
|
resource: HCI.DASHBOARD,
|
|
},
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
await Promise.all([
|
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }),
|
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
|
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
|
|
]);
|
|
|
|
// VMs are only needed to conditionally show the post-migration VM-detail
|
|
// link, so load them separately and non-fatally: a failure here (or the
|
|
// cost of findAll on large clusters) should not block plans/maps from
|
|
// rendering.
|
|
try {
|
|
await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.VM });
|
|
} catch (e) {
|
|
// Intentionally ignored: the dashboard still works without the VM list,
|
|
// the migrated VM-detail link simply won't be shown.
|
|
}
|
|
} catch (e) {
|
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
init();
|
|
</script>
|
|
|
|
<template>
|
|
<Loading v-if="loading" />
|
|
<div v-else>
|
|
<Banner
|
|
v-for="(err, i) in errors"
|
|
:key="i"
|
|
color="error"
|
|
:label="err"
|
|
/>
|
|
<Masthead
|
|
:schema="schema"
|
|
:resource="schema.id"
|
|
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
|
|
>
|
|
<template #subHeader>
|
|
<div class="mmt-5">
|
|
<p
|
|
v-clean-html="t('harvester.addons.vmMigration.dashboard.description')"
|
|
class="text-muted"
|
|
></p>
|
|
</div>
|
|
</template>
|
|
<template #createButton>
|
|
<router-link
|
|
:to="createLocation"
|
|
class="btn role-primary"
|
|
>
|
|
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
|
|
</router-link>
|
|
</template>
|
|
</Masthead>
|
|
|
|
<ResourceTable
|
|
:schema="schema"
|
|
:rows="rows"
|
|
:headers="headers"
|
|
:groupable="false"
|
|
:table-actions="false"
|
|
:search="false"
|
|
key-field="_key"
|
|
>
|
|
<template #cell:name="{ row }">
|
|
<div class="plan-name-cell">
|
|
<router-link
|
|
v-if="row.detailLocation"
|
|
:to="row.detailLocation"
|
|
class="plan-name"
|
|
>
|
|
{{ row.metadata.name }}
|
|
</router-link>
|
|
<div
|
|
v-else
|
|
class="plan-name"
|
|
>
|
|
{{ row.metadata.name }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #cell:vmCount="{ row }">
|
|
{{ t('harvester.addons.vmMigration.dashboard.progress.vmCount', { count: (row.spec.vms || []).length }) }}
|
|
</template>
|
|
<template #cell:progress="{ row }">
|
|
<div
|
|
v-if="row.vmProgress && row.vmProgress.length"
|
|
class="progress-cells"
|
|
>
|
|
<div
|
|
v-for="vm in row.vmProgress"
|
|
:key="vm.vmId"
|
|
class="vm-progress"
|
|
>
|
|
<div class="vm-progress-header">
|
|
<div class="vm-name-block">
|
|
<span class="vm-name">
|
|
{{ vm.vmName }}
|
|
<router-link
|
|
v-if="vm.vmDetailLocation"
|
|
v-clean-tooltip="t('harvester.addons.vmMigration.dashboard.progress.checkMigratedVm')"
|
|
:to="vm.vmDetailLocation"
|
|
class="vm-detail-link"
|
|
>
|
|
<i class="icon icon-external-link" />
|
|
</router-link>
|
|
</span>
|
|
<span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="vm-pct-block">
|
|
<div
|
|
v-clean-tooltip="{ content: vm.pipelineTooltip, html: true }"
|
|
class="vm-bar"
|
|
>
|
|
<PercentageBar
|
|
:model-value="vm.progress"
|
|
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
|
|
preferred-direction="MORE"
|
|
/>
|
|
</div>
|
|
<span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span>
|
|
</div>
|
|
<div
|
|
v-if="vm.progress >= 100"
|
|
class="step-label text-muted"
|
|
>
|
|
{{ t('harvester.addons.vmMigration.dashboard.progress.finishedSuccessfully') }}
|
|
</div>
|
|
<div
|
|
v-else-if="vm.errorMsg"
|
|
class="step-label text-error"
|
|
>
|
|
{{ vm.errorMsg }}
|
|
</div>
|
|
<div
|
|
v-else-if="vm.canceled"
|
|
class="step-label text-muted"
|
|
>
|
|
{{ t('harvester.addons.vmMigration.plan.states.canceled') }}
|
|
</div>
|
|
<div
|
|
v-else-if="vm.currentStep"
|
|
class="step-label text-muted"
|
|
>
|
|
{{ vm.currentStep }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span v-if="!row.vmProgress.length">-</span>
|
|
</template>
|
|
<template #cell:mappings="{ row }">
|
|
<MappingsCell
|
|
:network-entries="row.networkEntries"
|
|
:storage-entries="row.storageEntries"
|
|
/>
|
|
</template>
|
|
<template #cell:duration="{ row }">
|
|
<span v-if="row.duration">{{ row.duration }}</span>
|
|
<span
|
|
v-else
|
|
class="text-muted"
|
|
>—</span>
|
|
</template>
|
|
</ResourceTable>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.plan-name-cell {
|
|
.plan-name {
|
|
font-weight: 500;
|
|
line-height: 20px;
|
|
}
|
|
|
|
.plan-vms {
|
|
font-size: 12px;
|
|
line-height: 20px;
|
|
color: var(--muted);
|
|
}
|
|
}
|
|
|
|
.progress-cells {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.vm-progress-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.vm-name {
|
|
font-size: 14px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.vm-detail-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.vm-name-block {
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.vm-id {
|
|
font-size: 11px;
|
|
}
|
|
|
|
.vm-pct {
|
|
font-size: 14px;
|
|
min-width: 40px;
|
|
text-align: left;
|
|
}
|
|
|
|
.vm-pct-block {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.vm-bar {
|
|
width: 100%;
|
|
}
|
|
|
|
.step-label {
|
|
font-size: 13px;
|
|
margin-top: 4px;
|
|
line-height: 20px;
|
|
|
|
&.text-error {
|
|
color: var(--error);
|
|
}
|
|
}
|
|
|
|
.table-title {
|
|
font-weight: 600;
|
|
}
|
|
</style>
|