perf: speed up the VM list with many VMs (#1189)

* perf: speed up the VM list with many VMs

The VM list scanned whole store lists for every row and re-evaluated the same values per row on every render and sort. With several hundred VMs this blocked the browser on sorting and on every VM update.

Performance:
- look up launcher pods and migrations through shared indexes (namespace/name -> resource) instead of scanning per row; the indexes are computed per store list, so they stay in sync with in-place store updates
- use the same migration lookup (getLatestMigration) in the VM model and on the VM detail page
- look up PVCs, PVs, VMIs and restore backups by id
- merge VMs and VMIs with a Set
- evaluate the VM state once when sorting by state
- check the unschedulable condition before the pod based state checks
- cache feature flag results and per-schema/per-setting values that are the same for every row

Bug fixes:
- the launcher pod map was never rebuilt, because the store keeps the same array
- pods, volumes and migrations of a VM with the same name in another namespace could be picked up

The order of the volumes of a VM (vm.volumes) now follows the VM spec instead of the store order. Nothing depends on it.

Measured in a synthetic setup, 100 rows per page, sorting by state (Chrome, dev build):
- ~490 VMs, ~500 PVCs/PVs: 1.9 s -> 1.0 s
- ~490 VMs, 600 PVs, 200 migrations: 6.0 s -> 0.7 s, main thread blocked during VM updates 78% -> 32%
With few volumes the gain is much smaller (8-20%).

Related to: https://github.com/harvester/harvester/issues/9513

Signed-off-by: Volker Theile <vtheile@suse.com>

* fix: read VM data from the store of the resource instead of a fixed one

The migrations of a VMI and the migration badge of the state column were read from the hardcoded `harvester` store. In the Rancher explorer (embedded and standalone) the data is loaded into the `cluster` store, so a running migration was shown neither in the VM state nor as badge.

The VM model remembered the store of the current product in a module variable on first use and never updated it. The same model is used in the Harvester product (`harvester` store) and in the Rancher explorer (`cluster` store), and Rancher keeps the page loaded when going from the explorer of a Harvester cluster to Virtualization Management and into the Harvester product. The VM list then read the VMIs, launcher pods, volumes and restores from the wrong store, and running VMs were shown as Starting until the page was reloaded.

Read the data from the store the resource lives in (`this.$getters`, in the formatter the store of the current product). In the Harvester product this is still the `harvester` store, so nothing changes there.

Not covered: volume details like the lock icon of encrypted volumes. The explorer uses the generic Rancher PVC model, which does not know them, and settings based checks (feature flags, backup target) are not loaded there either.

Related to: https://github.com/harvester/harvester/issues/11882

Signed-off-by: Volker Theile <vtheile@suse.com>

---------

Signed-off-by: Volker Theile <vtheile@suse.com>
This commit is contained in:
Volker Theile
2026-10-14 13:31:09 +02:00
committed by GitHub
parent 71141b257a
commit 5406c08f76
14 changed files with 388 additions and 116 deletions
@@ -14,6 +14,7 @@ import Labels from '@shell/components/form/Labels';
import LabelValue from '@shell/components/LabelValue'; import LabelValue from '@shell/components/LabelValue';
import { HCI } from '../../types'; import { HCI } from '../../types';
import VM_MIXIN from '../../mixins/harvester-vm'; import VM_MIXIN from '../../mixins/harvester-vm';
import { getLatestMigration } from '../../utils/vm';
import CloudConfig from '../../edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig'; import CloudConfig from '../../edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
import Volume from '../../edit/kubevirt.io.virtualmachine/VirtualMachineVolume'; import Volume from '../../edit/kubevirt.io.virtualmachine/VirtualMachineVolume';
@@ -139,18 +140,7 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore; const inStore = this.$store.getters['currentProduct'].inStore;
const vmimList = this.$store.getters[`${ inStore }/all`](HCI.VMIM) || []; const vmimList = this.$store.getters[`${ inStore }/all`](HCI.VMIM) || [];
const vmiName = this.vmi?.name || ''; return getLatestMigration(vmimList, this.vmi?.metadata?.namespace, this.vmi?.name) || null;
// filter the corresponding vmims by vmim.spec.vmiName and find the latest one by creationTimestamp
const vmim = vmimList.filter((VMIM) => VMIM?.spec?.vmiName === vmiName).sort((a, b) => {
if (a?.metadata?.creationTimestamp > b?.metadata?.creationTimestamp) {
return -1;
}
return 1;
});
return vmim.length > 0 && vmim[0] ? vmim[0] : null;
}, },
migrationEvents() { migrationEvents() {
@@ -100,8 +100,7 @@ export default {
}, },
vmiIp() { vmiIp() {
const vmiResources = this.$store.getters[`${ this.inStore }/all`](HCI.VMI); const resource = this.$store.getters[`${ this.inStore }/byId`](HCI.VMI, this.value);
const resource = vmiResources.find((VMI) => VMI.id === this.value) || null;
const networksName = this.row.networksName || []; const networksName = this.row.networksName || [];
const vmiNetworks = resource?.spec?.networks || []; const vmiNetworks = resource?.spec?.networks || [];
@@ -20,7 +20,9 @@ export default {
computed: { computed: {
vmiResource() { vmiResource() {
return this.$store.getters['harvester/byId'](HCI.VMI, this.vmResource?.id) || null; const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/byId`](HCI.VMI, this.vmResource?.id) || null;
}, },
migrationState() { migrationState() {
return this.vmiResource?.migrationState?.status || ''; return this.vmiResource?.migrationState?.status || '';
@@ -151,7 +151,8 @@ export default {
}, },
rows() { rows() {
const matchVMIs = this.allVMIs.filter((VMI) => !this.allVMs.find((VM) => VM.id === VMI.id)); const vmIds = new Set(this.allVMs.map((VM) => VM.id));
const matchVMIs = this.allVMIs.filter((VMI) => !vmIds.has(VMI.id));
return [...this.allVMs, ...matchVMIs]; return [...this.allVMs, ...matchVMIs];
}, },
@@ -160,7 +161,11 @@ export default {
* We want to show the progress bar only for Backup's restore; snapshot's restore is immediate. * We want to show the progress bar only for Backup's restore; snapshot's restore is immediate.
*/ */
hasBackUpRestoreInProgress() { hasBackUpRestoreInProgress() {
return !!this.rows.find((r) => r.restoreResource && !r.restoreResource.fromSnapshot && !r.restoreResource.isComplete); return this.rows.some((r) => {
const restore = r.restoreResource;
return restore && !restore.fromSnapshot && !restore.isComplete;
});
}, },
vmRestartRequiredNames() { vmRestartRequiredNames() {
@@ -5,18 +5,15 @@ import { HCI } from '../../types';
// The Steve base model and the shell resource class pull in most of the dashboard, none of that is needed here // The Steve base model and the shell resource class pull in most of the dashboard, none of that is needed here
jest.mock('../harvester', () => ({ __esModule: true, default: class {} })); jest.mock('../harvester', () => ({ __esModule: true, default: class {} }));
jest.mock('@shell/plugins/dashboard-store/resource-class', () => ({ colorForState: (state) => (state === 'Running' ? 'text-success' : 'text-info') })); jest.mock('@shell/plugins/dashboard-store/resource-class', () => ({
colorForState: (state) => (state === 'Running' ? 'text-success' : 'text-info'),
stateSort: (color, display) => `${ color.replace(/^text-/, '') } ${ display }`,
}));
const VM_ID = 'ns1/vm1'; const VM_ID = 'ns1/vm1';
/** // Creates a VM model with mocked store getters. Like the real store, `all` returns the same array that is mutated in place.
* Create a VM model instance without the full Steve model plumbing. Only the store getters that the // Reads from the global `$rootGetters` are recorded in `globalReads`.
* VM model reads are mocked, backed by plain lists (like the real store, `all` returns the same array
* that is mutated in place on updates).
*
* The model reads its store either through `$rootGetters` (`harvester/...`) or through `$getters`, the getters of the
* store it lives in. Both are backed by the same data, so the tests do not depend on which of them the model uses.
*/
function createVm({ function createVm({
vm = {}, vmi, pods = [], pvcs = [], restores = [], backups = [], claims = ['disk-pvc'], printableStatus vm = {}, vmi, pods = [], pvcs = [], restores = [], backups = [], claims = ['disk-pvc'], printableStatus
} = {}) { } = {}) {
@@ -33,12 +30,14 @@ function createVm({
byId: (type, id) => (byId[type] || lists[type] || []).find((x) => x.id === id), byId: (type, id) => (byId[type] || lists[type] || []).find((x) => x.id === id),
schemaFor: () => undefined, schemaFor: () => undefined,
}; };
const rootGetters = { const globalReads = [];
'harvester-common/getFeatureEnabled': () => false, const rootGetters = new Proxy({ 'harvester-common/getFeatureEnabled': () => false }, {
currentProduct: { inStore: 'harvester' }, get(target, key) {
'harvester/all': getters.all, globalReads.push(key);
'harvester/byId': getters.byId,
}; return target[key];
}
});
const obj = Object.create(VirtVm.prototype); const obj = Object.create(VirtVm.prototype);
@@ -66,7 +65,9 @@ function createVm({
Object.defineProperty(obj, '$getters', { value: getters }); Object.defineProperty(obj, '$getters', { value: getters });
Object.defineProperty(obj, 't', { value: (k) => k }); Object.defineProperty(obj, 't', { value: (k) => k });
return { vm: obj, lists }; return {
vm: obj, lists, globalReads
};
} }
const readyVmi = (extra = {}) => ({ const readyVmi = (extra = {}) => ({
@@ -287,6 +288,24 @@ describe('class VirtVm', () => {
}); });
}); });
describe('stateSort', () => {
it('should be built from the same state as stateDisplay and stateColor', () => {
const { vm } = createVm({ vmi: readyVmi() });
expect(vm.stateSort).toBe(`${ vm.stateColor.replace(/^text-/, '') } ${ vm.stateDisplay }`);
});
it('should evaluate the state only once', () => {
const { vm } = createVm({ vmi: readyVmi() });
const spy = jest.spyOn(vm, 'actualState', 'get');
expect(vm.stateSort).toBe('success Running');
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});
describe('podResource', () => { describe('podResource', () => {
it('should find the launcher pod by owner name', () => { it('should find the launcher pod by owner name', () => {
const pod = { metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] } }; const pod = { metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] } };
@@ -311,6 +330,62 @@ describe('class VirtVm', () => {
expect(vm.podResource.metadata.name).toBe('launcher-target'); expect(vm.podResource.metadata.name).toBe('launcher-target');
}); });
it('should find pods that are added to the store later', () => {
const { vm, lists } = createVm({ vmi: readyVmi(), pods: [] });
expect(vm.podResource).toBeUndefined();
const pod = { metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] } };
lists[POD].push(pod);
expect(vm.podResource).toStrictEqual(pod);
});
it('should not return a pod of a VM with the same name in another namespace', () => {
const other = { metadata: { namespace: 'ns2', ownerReferences: [{ name: 'vm1' }] } };
const mine = { metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] } };
const { vm } = createVm({ vmi: readyVmi(), pods: [mine, other] });
expect(vm.podResource).toStrictEqual(mine);
});
it('should not return pods that were removed from the store', () => {
const pod = { metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] } };
const { vm, lists } = createVm({ vmi: readyVmi(), pods: [pod] });
expect(vm.podResource).toStrictEqual(pod);
lists[POD].splice(0, 1);
expect(vm.podResource).toBeUndefined();
});
});
describe('store', () => {
it('should read the data of the VM from the store the VM lives in, not from a fixed one', () => {
// The VM model is used with the `harvester` store (Harvester product) and the `cluster` store (Rancher explorer)
const pod = { metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] } };
const { vm, globalReads } = createVm({
vmi: readyVmi(),
pods: [pod],
pvcs: [pvc('disk-pvc', false)],
restores: [{ id: 'ns1/r1', isComplete: true }],
vm: {
metadata: {
name: 'vm1', namespace: 'ns1', annotations: { 'restore.harvesterhci.io/name': 'r1' }
}
},
});
expect(vm.vmi.id).toBe(VM_ID);
expect(vm.podResource).toStrictEqual(pod);
expect(vm.volumes).toHaveLength(1);
expect(vm.restoreResource.id).toBe('ns1/r1');
expect(vm.actualState).toBe('Running');
expect(globalReads.filter((key) => /^(harvester|cluster)\//.test(key))).toStrictEqual([]);
});
}); });
describe('isRestartRequired', () => { describe('isRestartRequired', () => {
@@ -25,13 +25,23 @@ function createVmi(migrations) {
annotations: { [HCI_ANNOTATIONS.MIGRATION_STATE]: 'Migrating' }, annotations: { [HCI_ANNOTATIONS.MIGRATION_STATE]: 'Migrating' },
}, },
$getters: { all: (type) => type === HCI.VMIM ? list : [] }, $getters: { all: (type) => type === HCI.VMIM ? list : [] },
$rootGetters: { 'harvester/all': (type) => type === HCI.VMIM ? list : [] },
}); });
return { vmi, list }; return { vmi, list };
} }
describe('class VirtVmInstance', () => { describe('class VirtVmInstance', () => {
it('should select the newest migration in the VMI namespace regardless of list order', () => {
const { vmi } = createVmi([
migration('ns1', '2026-01-03T00:00:00Z'),
migration('ns2', '2026-01-04T00:00:00Z', 'Failed'),
migration('ns1', '2026-01-01T00:00:00Z', 'Failed'),
]);
expect(vmi.vmimResource.metadata.creationTimestamp).toBe('2026-01-03T00:00:00Z');
expect(vmi.migrationState.status).toBe('Migrating');
});
it('should follow additions, timestamp changes, phase changes and removals in the same list', () => { it('should follow additions, timestamp changes, phase changes and removals in the same list', () => {
const { vmi, list } = createVmi([]); const { vmi, list } = createVmi([]);
@@ -54,4 +64,32 @@ describe('class VirtVmInstance', () => {
list.splice(0, 2); list.splice(0, 2);
expect(vmi.migrationState).toBeNull(); expect(vmi.migrationState).toBeNull();
}); });
it('should follow a migration reassigned to another VMI or namespace', () => {
const { vmi, list } = createVmi([migration('ns1', '2026-01-01T00:00:00Z')]);
expect(vmi.migrationState.status).toBe('Migrating');
list[0].spec.vmiName = 'vm2';
expect(vmi.migrationState).toBeNull();
list[0].spec.vmiName = 'vm1';
list[0].metadata.namespace = 'ns2';
expect(vmi.migrationState).toBeNull();
});
it('should invalidate the index when the store replaces properties on an existing migration', () => {
const { vmi, list } = createVmi([migration('ns1', '2026-01-01T00:00:00Z')]);
expect(vmi.migrationState.status).toBe('Migrating');
const existing = list[0];
for (const key of Object.keys(existing)) {
delete existing[key];
}
Object.assign(existing, migration('ns2', '2026-01-02T00:00:00Z', 'Failed'));
expect(vmi.migrationState).toBeNull();
});
}); });
@@ -326,7 +326,7 @@ export default class HciPv extends HarvesterResource {
} }
get relatedPV() { get relatedPV() {
return this.$rootGetters['harvester/all'](PV).find((pv) => pv.metadata?.name === this.spec?.volumeName); return this.$rootGetters['harvester/byId'](PV, this.spec?.volumeName);
} }
get volumeProvider() { get volumeProvider() {
@@ -1,7 +1,7 @@
import { load } from 'js-yaml'; import { load } from 'js-yaml';
import { omitBy, pickBy } from 'lodash'; import { omitBy, pickBy } from 'lodash';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester'; import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester';
import { colorForState } from '@shell/plugins/dashboard-store/resource-class'; import { colorForState, stateSort } from '@shell/plugins/dashboard-store/resource-class';
import { POD, NODE, PVC } from '@shell/config/types'; import { POD, NODE, PVC } from '@shell/config/types';
import { findBy } from '@shell/utils/array'; import { findBy } from '@shell/utils/array';
import { parseSi } from '@shell/utils/units'; import { parseSi } from '@shell/utils/units';
@@ -14,6 +14,7 @@ import { BACKUP_TYPE } from '../config/types';
import { HCI } from '../types'; import { HCI } from '../types';
import HarvesterResource from './harvester'; import HarvesterResource from './harvester';
import { getVmCPUMemoryValues } from '../utils/cpuMemory'; import { getVmCPUMemoryValues } from '../utils/cpuMemory';
import { sharedComputed } from '../utils/shared-computed';
import { isBackupTargetSettingUnavailable } from '../utils/setting'; import { isBackupTargetSettingUnavailable } from '../utils/setting';
export const OFF = 'Off'; export const OFF = 'Off';
@@ -81,42 +82,48 @@ const VMIPhase = {
Unknown: 'Unknown' Unknown: 'Unknown'
}; };
let productInStore; // Index of the pods (namespace/ownerName -> pod), shared by all rows. `podList` must be the store list, not a copy.
// If several pods have the same owner (e.g. while a VM migrates), the last one wins.
function getPodOwnerIndex(podList) {
return sharedComputed(podList, 'podOwnerIndex', (pods) => {
const map = new Map(); // namespace/ownerName -> pod
let _podOwnerMap = null; for (const pod of pods) {
let _podOwnerMapSource = null; const refName = pod.metadata?.ownerReferences?.[0]?.name;
function getPodByOwnerName(rootGetters, inStore, ownerName) { if (refName) {
const podList = rootGetters[`${ inStore }/all`](POD); map.set(`${ pod.metadata.namespace }/${ refName }`, pod);
}
}
return map;
});
}
// `getters` are the getters of the store of the VM (`this.$getters`)
function getPodByOwnerName(getters, namespace, ownerName) {
const podList = getters['all'](POD);
if (!Array.isArray(podList)) { if (!Array.isArray(podList)) {
return undefined; return undefined;
} }
// if not equals (usually means the pod list has been updated), we need to rebuild the map, otherwise we can reuse the map for better performance
if (_podOwnerMapSource !== podList) {
_podOwnerMap = new Map(); // use Map to store ownerReference name and pod mapping
for (const pod of podList) {
const refName = pod.metadata?.ownerReferences?.[0]?.name;
if (refName) { return getPodOwnerIndex(podList).get(`${ namespace }/${ ownerName }`);
_podOwnerMap.set(refName, pod);
}
}
_podOwnerMapSource = podList;
}
return _podOwnerMap.get(ownerName);
} }
function getPvcsByNames(rootGetters, inStore, names) { // A VM only uses PVCs of its own namespace, so look them up by id instead of scanning all PVCs
const pvcList = rootGetters[`${ inStore }/all`](PVC); function getPvcsByNames(getters, namespace, names) {
const out = [];
if (!Array.isArray(pvcList)) { for (const name of new Set(names)) {
return []; const pvc = getters['byId'](PVC, `${ namespace }/${ name }`);
if (pvc) {
out.push(pvc);
}
} }
const uniqueNames = new Set(names);
return pvcList.filter((pvc) => uniqueNames.has(pvc.metadata?.name)); return out;
} }
const IgnoreMessages = ['pod has unbound immediate PersistentVolumeClaims']; const IgnoreMessages = ['pod has unbound immediate PersistentVolumeClaims'];
@@ -135,7 +142,8 @@ export default class VirtVm extends HarvesterResource {
clone.action = 'goToCloneVM'; clone.action = 'goToCloneVM';
} }
const canCreateVMSSchedule = !!this.$getters?.['schemaFor']?.(HCI.SCHEDULE_VM_BACKUP)?.collectionMethods?.find((x) => ['post'].includes(x.toLowerCase())); const scheduleSchema = this.$getters?.['schemaFor']?.(HCI.SCHEDULE_VM_BACKUP);
const canCreateVMSSchedule = !!scheduleSchema && sharedComputed(scheduleSchema, 'canPost', (schema) => !!schema.collectionMethods?.find((x) => ['post'].includes(x.toLowerCase())));
return [ return [
{ {
@@ -288,14 +296,6 @@ export default class VirtVm extends HarvesterResource {
]; ];
} }
get productInStore() {
if (!productInStore) {
productInStore = this.$rootGetters['currentProduct'].inStore;
}
return productInStore;
}
applyDefaults(resources = this, realMode) { applyDefaults(resources = this, realMode) {
const spec = { const spec = {
runStrategy: 'RerunOnFailure', runStrategy: 'RerunOnFailure',
@@ -729,14 +729,13 @@ export default class VirtVm extends HarvesterResource {
} }
get podResource() { get podResource() {
const inStore = this.productInStore; const vmiResource = this.$getters['byId'](HCI.VMI, this.id);
const vmiResource = this.$rootGetters[`${ inStore }/byId`](HCI.VMI, this.id);
if (!vmiResource?.metadata?.name) { if (!vmiResource?.metadata?.name) {
return undefined; return undefined;
} }
return getPodByOwnerName(this.$rootGetters, inStore, vmiResource.metadata.name); return getPodByOwnerName(this.$getters, vmiResource.metadata.namespace, vmiResource.metadata.name);
} }
get isPaused() { get isPaused() {
@@ -764,8 +763,7 @@ export default class VirtVm extends HarvesterResource {
} }
get nsResourceQuota() { get nsResourceQuota() {
const inStore = this.productInStore; const allResQuotas = this.$getters['all'](HCI.RESOURCE_QUOTA);
const allResQuotas = this.$rootGetters[`${ inStore }/all`](HCI.RESOURCE_QUOTA);
return allResQuotas.find( (RQ) => RQ.namespace === this.metadata.namespace); return allResQuotas.find( (RQ) => RQ.namespace === this.metadata.namespace);
} }
@@ -775,15 +773,13 @@ export default class VirtVm extends HarvesterResource {
} }
get vmi() { get vmi() {
const inStore = this.productInStore; return this.$getters['byId'](HCI.VMI, this.id);
return this.$rootGetters[`${ inStore }/byId`](HCI.VMI, this.id);
} }
get volumes() { get volumes() {
const volumeClaimNames = this.spec.template.spec.volumes?.map((v) => v.persistentVolumeClaim?.claimName).filter((v) => !!v) || []; const volumeClaimNames = this.spec.template.spec.volumes?.map((v) => v.persistentVolumeClaim?.claimName).filter((v) => !!v) || [];
return getPvcsByNames(this.$rootGetters, this.productInStore, volumeClaimNames); return getPvcsByNames(this.$getters, this.metadata.namespace, volumeClaimNames);
} }
get lvmVolumes() { get lvmVolumes() {
@@ -807,13 +803,15 @@ export default class VirtVm extends HarvesterResource {
} }
get encryptedVolumeType() { get encryptedVolumeType() {
if (!this.volumes || this.volumes.length === 0) { const volumes = this.volumes;
if (!volumes || volumes.length === 0) {
return 'none'; return 'none';
} }
if (this.volumes.every((vol) => vol.isEncrypted)) { if (volumes.every((vol) => vol.isEncrypted)) {
return 'all'; return 'all';
} else if (this.volumes.some((vol) => vol.isEncrypted)) { } else if (volumes.some((vol) => vol.isEncrypted)) {
return 'partial'; return 'partial';
} else { } else {
return 'none'; return 'none';
@@ -918,16 +916,15 @@ export default class VirtVm extends HarvesterResource {
} }
get isUnschedulable() { get isUnschedulable() {
if (this.isStopping || this.isStarting) { // Check the cheap condition first
const condition = this.status?.conditions?.find((c) => c.reason === UNSCHEDULABLE); const condition = this.status?.conditions?.find((c) => c.reason === UNSCHEDULABLE);
if (!!condition) { if (condition && (this.isStopping || this.isStarting)) {
return { return {
status: UNSCHEDULABLE, status: UNSCHEDULABLE,
message: condition.message || 'VM is unschedulable', message: condition.message || 'VM is unschedulable',
}; };
} }
}
return null; return null;
} }
@@ -971,16 +968,14 @@ export default class VirtVm extends HarvesterResource {
`metadata.annotations."${ HCI_ANNOTATIONS.RESTORE_NAME }"` `metadata.annotations."${ HCI_ANNOTATIONS.RESTORE_NAME }"`
) }`; ) }`;
const inStore = this.productInStore; const res = this.$getters['byId'](HCI.RESTORE, id);
const res = this.$rootGetters[`${ inStore }/byId`](HCI.RESTORE, id);
if (res) { if (res) {
const allBackups = this.$rootGetters[`${ inStore }/all`](HCI.BACKUP); const backupId = `${ res.spec?.virtualMachineBackupNamespace }/${ res.spec?.virtualMachineBackupName }`;
const backup = this.$getters['byId'](HCI.BACKUP, backupId);
res.fromSnapshot = !!allBackups // Snapshots and backups are told apart by spec.type
.filter((b) => b.spec?.type !== BACKUP_TYPE.BACKUP) res.fromSnapshot = !!backup && backup.spec?.type !== BACKUP_TYPE.BACKUP;
.find((s) => s.id === `${ res.spec?.virtualMachineBackupNamespace }/${ res.spec?.virtualMachineBackupName }`);
} }
return res; return res;
@@ -1005,11 +1000,13 @@ export default class VirtVm extends HarvesterResource {
} }
get restoreState() { get restoreState() {
if (!this.restoreResource) { const restore = this.restoreResource;
if (!restore) {
return true; return true;
} }
return this.restoreResource?.isComplete; return restore.isComplete;
} }
get actualState() { get actualState() {
@@ -1110,6 +1107,13 @@ export default class VirtVm extends HarvesterResource {
return colorForState(state); return colorForState(state);
} }
// Evaluate actualState once, the base implementation does it in stateColor and stateDisplay
get stateSort() {
const state = this.actualState;
return stateSort(colorForState(state), state);
}
get networkIps() { get networkIps() {
let networkData = ''; let networkData = '';
const out = []; const out = [];
@@ -1160,7 +1164,6 @@ export default class VirtVm extends HarvesterResource {
get rootImageId() { get rootImageId() {
let imageId = ''; let imageId = '';
const inStore = this.productInStore;
const volumes = this.spec.template.spec.volumes || []; const volumes = this.spec.template.spec.volumes || [];
@@ -1170,7 +1173,7 @@ export default class VirtVm extends HarvesterResource {
}); });
if (!isNoExistingVolume) { if (!isNoExistingVolume) {
const existingVolume = this.$rootGetters[`${ inStore }/byId`](PVC, `${ this.metadata.namespace }/${ firstVolumeName }`); const existingVolume = this.$getters['byId'](PVC, `${ this.metadata.namespace }/${ firstVolumeName }`);
if (existingVolume) { if (existingVolume) {
return existingVolume?.metadata?.annotations?.[ return existingVolume?.metadata?.annotations?.[
@@ -1366,9 +1369,14 @@ export default class VirtVm extends HarvesterResource {
get isBackupTargetUnavailable() { get isBackupTargetUnavailable() {
const backupTargetSetting = this.$rootGetters['harvester/byId'](HCI.SETTING, 'backup-target'); const backupTargetSetting = this.$rootGetters['harvester/byId'](HCI.SETTING, 'backup-target');
// sharedComputed needs an object as key
if (!backupTargetSetting) {
return isBackupTargetSettingUnavailable(backupTargetSetting); return isBackupTargetSettingUnavailable(backupTargetSetting);
} }
return sharedComputed(backupTargetSetting, 'unavailable', isBackupTargetSettingUnavailable);
}
setInstanceLabels(val) { setInstanceLabels(val) {
if ( !this.spec?.template?.metadata?.labels ) { if ( !this.spec?.template?.metadata?.labels ) {
set(this, 'spec.template.metadata.labels', {}); set(this, 'spec.template.metadata.labels', {});
@@ -1,6 +1,7 @@
import { colorForState } from '@shell/plugins/dashboard-store/resource-class'; import { colorForState } from '@shell/plugins/dashboard-store/resource-class';
import { NODE } from '@shell/config/types'; import { NODE } from '@shell/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations'; import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { getLatestMigration } from '@pkg/utils/vm';
import { HCI } from '../types'; import { HCI } from '../types';
import HarvesterResource from './harvester'; import HarvesterResource from './harvester';
@@ -45,32 +46,25 @@ export default class VirtVmInstance extends HarvesterResource {
return colorForState(state); return colorForState(state);
} }
// The newest migration of this VMI, an empty array if there is none
get vmimResource() { get vmimResource() {
const all = this.$rootGetters['harvester/all'](HCI.VMIM) || []; const all = this.$getters['all'](HCI.VMIM) || [];
const vmimList = all.filter((vmim) => vmim.spec?.vmiName === this.metadata?.name);
if (vmimList.length === 0) { return getLatestMigration(all, this.metadata?.namespace, this.metadata?.name) || [];
return [];
}
vmimList.sort((a, b) => {
return a?.metadata?.creationTimestamp > b?.metadata?.creationTimestamp ? -1 : 1;
});
return vmimList[0];
} }
get migrationState() { get migrationState() {
const state = this.metadata?.annotations?.[HCI_ANNOTATIONS.MIGRATION_STATE]; const state = this.metadata?.annotations?.[HCI_ANNOTATIONS.MIGRATION_STATE];
const migration = this.vmimResource;
if (this.vmimResource?.status?.phase === VMIPhase.Failed) { if (migration?.status?.phase === VMIPhase.Failed) {
return { return {
type: 'migration', type: 'migration',
status: VMIPhase.Failed status: VMIPhase.Failed
}; };
} }
if (this.vmimResource?.status?.phase && state) { if (migration?.status?.phase && state) {
return { return {
type: 'migration', type: 'migration',
status: state status: state
@@ -0,0 +1,34 @@
import { reactive } from 'vue';
import { sharedComputed } from '../shared-computed';
describe('sharedComputed', () => {
it('should run the function once per source and key, not once per call', () => {
const source = reactive([1, 2, 3]);
const fn = jest.fn((list) => list.length);
expect(sharedComputed(source, 'length', fn)).toBe(3);
expect(sharedComputed(source, 'length', fn)).toBe(3);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should run the function again after reactive data it read changed', () => {
const source = reactive([1, 2, 3]);
const fn = jest.fn((list) => list.length);
expect(sharedComputed(source, 'length', fn)).toBe(3);
source.push(4);
expect(sharedComputed(source, 'length', fn)).toBe(4);
expect(fn).toHaveBeenCalledTimes(2);
});
it('should keep the values of different keys and different sources apart', () => {
const one = reactive([1]);
const two = reactive([1, 2]);
expect(sharedComputed(one, 'length', (list) => list.length)).toBe(1);
expect(sharedComputed(two, 'length', (list) => list.length)).toBe(2);
expect(sharedComputed(one, 'first', (list) => list[0])).toBe(1);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { reactive } from 'vue';
import { getLatestMigration } from '../vm';
const migration = (namespace, timestamp, vmiName = 'vm1') => ({
metadata: { namespace, creationTimestamp: timestamp },
spec: { vmiName },
});
describe('getLatestMigration', () => {
it('should return the newest migration of the VMI in its namespace, regardless of list order', () => {
const newest = migration('ns1', '2026-01-03T00:00:00Z');
const list = [
migration('ns1', '2026-01-01T00:00:00Z'),
newest,
migration('ns2', '2026-01-09T00:00:00Z'),
migration('ns1', '2026-02-01T00:00:00Z', 'vm2'),
];
expect(getLatestMigration(list, 'ns1', 'vm1')).toBe(newest);
});
it('should return the first one of several migrations with the same timestamp, as the lookup before the index did', () => {
const first = { ...migration('ns1', '2026-01-03T00:00:00Z'), id: 'first' };
const second = { ...migration('ns1', '2026-01-03T00:00:00Z'), id: 'second' };
expect(getLatestMigration([first, second], 'ns1', 'vm1')).toBe(first);
});
it('should return undefined if there is no migration or no VMI name', () => {
const list = [migration('ns1', '2026-01-01T00:00:00Z')];
expect(getLatestMigration(list, 'ns2', 'vm1')).toBeUndefined();
expect(getLatestMigration(list, 'ns1', undefined)).toBeUndefined();
expect(getLatestMigration([], 'ns1', 'vm1')).toBeUndefined();
});
it('should follow changes of a reactive store list', () => {
const list = reactive([]);
const first = migration('ns1', '2026-01-01T00:00:00Z');
expect(getLatestMigration(list, 'ns1', 'vm1')).toBeUndefined();
list.push(first);
expect(getLatestMigration(list, 'ns1', 'vm1')).toStrictEqual(first);
list.push(migration('ns1', '2026-01-02T00:00:00Z'));
expect(getLatestMigration(list, 'ns1', 'vm1').metadata.creationTimestamp).toBe('2026-01-02T00:00:00Z');
list.splice(0, 2);
expect(getLatestMigration(list, 'ns1', 'vm1')).toBeUndefined();
});
});
+14 -1
View File
@@ -34,7 +34,7 @@ function getLatestCompatibleVersion(version) {
return compatible[0] || getLatestVersion(); return compatible[0] || getLatestVersion();
} }
export const featureEnabled = (featureKey, serverVersion) => { function resolveFeatureEnabled(featureKey, serverVersion) {
const minSupportedVersion = '1.3.0'; const minSupportedVersion = '1.3.0';
const version = getVersion(serverVersion); const version = getVersion(serverVersion);
@@ -59,4 +59,17 @@ export const featureEnabled = (featureKey, serverVersion) => {
} }
return releasedFeatures.includes(featureKey); return releasedFeatures.includes(featureKey);
}
// Cache the result, models ask for the same few flags for every row
const _featureEnabledCache = new Map();
export const featureEnabled = (featureKey, serverVersion) => {
const cacheKey = `${ featureKey }|${ serverVersion }`;
if (!_featureEnabledCache.has(cacheKey)) {
_featureEnabledCache.set(cacheKey, resolveFeatureEnabled(featureKey, serverVersion));
}
return _featureEnabledCache.get(cacheKey);
}; };
+25
View File
@@ -0,0 +1,25 @@
import { computed } from 'vue';
// Caches a value that all rows derive from the same shared store object (e.g. a lookup index of a whole list).
// The WeakMap is keyed by that object, so the value is dropped together with it. The value is a `computed`, so it is
// derived again after the reactive data it read changed.
const _computedsBySource = new WeakMap();
/**
* Runs `fn(source)` once per source object instead of once per call, and again when its reactive data changed.
* `key` tells apart several shared computeds of the same source. For a list, `source` must be the store list, not a copy.
*/
export function sharedComputed(source, key, fn) {
let perSource = _computedsBySource.get(source);
if (!perSource) {
perSource = new Map();
_computedsBySource.set(source, perSource);
}
if (!perSource.has(key)) {
perSource.set(key, computed(() => fn(source)));
}
return perSource.get(key).value;
}
+37
View File
@@ -1,3 +1,4 @@
import { sharedComputed } from './shared-computed';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations'; import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export function parseVolumeClaimTemplates(data) { export function parseVolumeClaimTemplates(data) {
@@ -20,3 +21,39 @@ export function getBootDevices(devices = {}) {
.filter((device) => !!device.bootOrder) .filter((device) => !!device.bootOrder)
.sort((a, b) => a.bootOrder - b.bootOrder); .sort((a, b) => a.bootOrder - b.bootOrder);
} }
// Index of the migrations (namespace/vmiName -> newest migration), shared by all VMIs. `migrations` must be the store list,
// not a copy. Of two migrations with the same creationTimestamp the first one wins.
function getLatestMigrationIndex(migrations) {
return sharedComputed(migrations, 'latestMigrationIndex', (list) => {
const latest = new Map();
for (const migration of list) {
const name = migration.spec?.vmiName;
if (!name) {
continue;
}
const key = `${ migration.metadata?.namespace }/${ name }`;
const previous = latest.get(key);
if (!previous || migration.metadata?.creationTimestamp > previous.metadata?.creationTimestamp) {
latest.set(key, migration);
}
}
return latest;
});
}
/**
* The newest migration of a VMI, or undefined if there is none. The caller passes the migrations of its store.
*/
export function getLatestMigration(migrations, namespace, vmiName) {
if (!vmiName) {
return undefined;
}
return getLatestMigrationIndex(migrations).get(`${ namespace }/${ vmiName }`);
}