Latest changes from Harvester master - a537c1ae38eb7030542ac371f24ae3336cd9d422

Signed-off-by: Francesco Torchia <francesco.torchia@suse.com>
This commit is contained in:
Francesco Torchia 2024-09-30 14:57:53 +02:00
parent 2ca86930ad
commit 118aaf16b7
No known key found for this signature in database
GPG Key ID: E6D011B7415D4393
35 changed files with 1484 additions and 93 deletions

View File

@ -16,6 +16,7 @@
}, },
"resolutions": { "resolutions": {
"@types/node": "~20.10.0", "@types/node": "~20.10.0",
"cronstrue": "2.50.0",
"d3-color": "3.1.0", "d3-color": "3.1.0",
"ejs": "3.1.9", "ejs": "3.1.9",
"follow-redirects": "1.15.2", "follow-redirects": "1.15.2",

View File

@ -0,0 +1,123 @@
<script>
import { RadioGroup } from '@components/Form/Radio';
export default {
name: 'HarvesterFilterVMSchedule',
emits: ['change-rows'],
components: { RadioGroup },
props: {
rows: {
type: Array,
required: true,
},
},
data() {
return {
selected: '',
};
},
computed: {
scheduleOptions() {
const options = this.rows.map(r => r.sourceSchedule).filter(r => r);
return Array.from(new Set(options));
},
},
methods: {
onSelect(selected) {
this.selected = selected;
this.filterRows();
},
remove() {
this.selected = '';
this.filterRows();
},
filterRows() {
if (!this.selected) {
this.$emit('change-rows', this.rows);
return;
}
const filteredRows = this.rows.filter(row => row.sourceSchedule === this.selected);
this.$emit('change-rows', filteredRows, this.selected);
}
},
watch: {
rows: {
deep: true,
immediate: false,
handler() {
this.filterRows();
}
}
}
};
</script>
<template>
<div class="vm-schedule-filter">
<template>
<span v-if="selected" class="banner-item bg-warning">
{{ t('harvester.tableHeaders.vmSchedule') }}{{ selected ? ` = ${selected}`: '' }}<i class="icon icon-close ml-5" @click="remove" />
</span>
</template>
<v-dropdown
:triggers="scheduleOptions.length ? ['click'] : []"
placement="bottom-end"
offset="1"
>
<button ref="actionDropDown" class="btn bg-primary mr-10">
<slot name="title">
{{ t('harvester.fields.filterSchedule') }}
</slot>
</button>
<template #popper>
<div class="filter-popup">
<RadioGroup
v-model:value="selected"
class="mr-10 ml-10"
name="model"
:options="scheduleOptions"
:labels="scheduleOptions"
@input="onSelect"
/>
</div>
</template>
</v-dropdown>
</div>
</template>
<style lang="scss" scoped>
.vm-schedule-filter {
display: inline-block;
.banner-item {
display: inline-block;
font-size: 16px;
margin-right: 10px;
padding: 6px;
border-radius: 2px;
i {
cursor: pointer;
vertical-align: middle;
}
}
}
.filter-popup {
width: max-content;
}
</style>

View File

@ -398,6 +398,7 @@ export function init($plugin, store) {
basicType( basicType(
[ [
HCI.SCHEDULE_VM_BACKUP,
HCI.BACKUP, HCI.BACKUP,
HCI.SNAPSHOT, HCI.SNAPSHOT,
HCI.VM_SNAPSHOT, HCI.VM_SNAPSHOT,
@ -445,6 +446,20 @@ export function init($plugin, store) {
exact: false exact: false
}); });
configureType(HCI.SCHEDULE_VM_BACKUP, { showListMasthead: false, showConfigView: false });
virtualType({
labelKey: 'harvester.schedule.label',
name: HCI.SCHEDULE_VM_BACKUP,
namespaced: true,
weight: 201,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.SCHEDULE_VM_BACKUP }
},
exact: false,
ifHaveType: HCI.SCHEDULE_VM_BACKUP,
});
configureType(HCI.BACKUP, { showListMasthead: false, showConfigView: false }); configureType(HCI.BACKUP, { showListMasthead: false, showConfigView: false });
virtualType({ virtualType({
labelKey: 'harvester.backup.label', labelKey: 'harvester.backup.label',

View File

@ -53,4 +53,5 @@ export const HCI = {
NODE_CPU_MANAGER_UPDATE_STATUS: 'harvesterhci.io/cpu-manager-update-status', NODE_CPU_MANAGER_UPDATE_STATUS: 'harvesterhci.io/cpu-manager-update-status',
CPU_MANAGER: 'cpumanager', CPU_MANAGER: 'cpumanager',
VM_DEVICE_ALLOCATION_DETAILS: 'harvesterhci.io/deviceAllocationDetails', VM_DEVICE_ALLOCATION_DETAILS: 'harvesterhci.io/deviceAllocationDetails',
SVM_BACKUP_ID: 'harvesterhci.io/svmbackupId',
}; };

View File

@ -40,3 +40,40 @@ export const SNAPSHOT_TARGET_VOLUME = {
sort: 'spec.source.persistentVolumeClaimName', sort: 'spec.source.persistentVolumeClaimName',
formatter: 'SnapshotTargetVolume', formatter: 'SnapshotTargetVolume',
}; };
// The column of cron expression volume on VM schedules list page
export const VM_SCHEDULE_CRON = {
name: 'CronExpression',
labelKey: 'harvester.tableHeaders.cronExpression',
value: 'spec.cron',
align: 'center',
sort: 'spec.cron',
formatter: 'HarvesterCronExpression',
};
// The column of retain on VM schedules list page
export const VM_SCHEDULE_RETAIN = {
name: 'Retain',
labelKey: 'harvester.tableHeaders.retain',
value: 'spec.retain',
sort: 'spec.retain',
align: 'center',
};
// The column of maxFailure on VM schedules list page
export const VM_SCHEDULE_MAX_FAILURE = {
name: 'MaxFailure',
labelKey: 'harvester.tableHeaders.maxFailure',
value: 'spec.maxFailure',
sort: 'spec.maxFailure',
align: 'center',
};
// The column of type on VM schedules list page
export const VM_SCHEDULE_TYPE = {
name: 'Type',
labelKey: 'harvester.tableHeaders.scheduleType',
value: 'spec.vmbackup.type',
sort: 'spec.vmbackup.type',
align: 'center',
};

View File

@ -0,0 +1,129 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import { STATE, NAME, AGE } from '@shell/config/table-headers';
import { allSettled } from '../../utils/promise';
import { BACKUP_TYPE } from '../../config/types';
import { HCI } from '../../types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'BackupList',
components: { ResourceTable },
props: {
id: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = await allSettled({ backups: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.BACKUP }) });
this.rows = hash.backups;
},
data() {
const inStore = this.$store.getters['currentProduct'].inStore;
const schema = this.$store.getters[`${ inStore }/schemaFor`](HCI.BACKUP);
return {
rows: [],
schema
};
},
computed: {
headers() {
const cols = [
STATE,
{
...NAME,
width: 400
},
{
name: 'targetVM',
labelKey: 'tableHeaders.targetVm',
value: 'attachVM',
align: 'left',
sort: 'attachVM',
formatter: 'AttachVMWithName'
},
{
name: 'backupTarget',
labelKey: 'tableHeaders.backupTarget',
value: 'backupTarget',
sort: 'backupTarget',
align: 'left',
formatter: 'HarvesterBackupTargetValidation'
},
{
name: 'readyToUse',
labelKey: 'tableHeaders.readyToUse',
value: 'status.readyToUse',
sort: 'status.readyToUse',
align: 'center',
formatter: 'Checked',
},
];
if (this.hasBackupProgresses) {
cols.push({
name: 'backupProgress',
labelKey: 'tableHeaders.progress',
value: 'backupProgress',
sort: 'backupProgress',
align: 'left',
formatter: 'HarvesterBackupProgressBar',
});
}
cols.push(AGE);
return cols;
},
hasBackupProgresses() {
return !!this.rows.find(R => R.status?.progress !== undefined);
},
filteredRows() {
let r = this.rows.filter(row => row.spec?.type === BACKUP_TYPE.BACKUP);
if (this.id) {
r = r.filter(backup => backup.metadata.annotations?.[HCI_ANNOTATIONS.SVM_BACKUP_ID] === this.id);
}
return r;
},
},
};
</script>
<template>
<ResourceTable
v-bind="$attrs"
:headers="headers"
:groupable="false"
:rows="filteredRows"
:schema="schema"
key-field="_key"
default-sort-by="age"
>
<template #col:name="{row}">
<td>
<span>
<router-link
v-if="row?.status?.source"
:to="row.detailLocation"
>
{{ row.nameDisplay }}
</router-link>
<span v-else>
{{ row.nameDisplay }}
</span>
</span>
</td>
</template>
</ResourceTable>
</template>

View File

@ -0,0 +1,97 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import { STATE, NAME, AGE } from '@shell/config/table-headers';
import { allSettled } from '../../utils/promise';
import { BACKUP_TYPE } from '../../config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { HCI } from '../../types';
import { schema } from '../../list/harvesterhci.io.vmsnapshot';
export default {
name: 'SnapshotList',
components: { ResourceTable },
props: {
id: {
type: String,
required: true,
},
},
async fetch() {
const hash = await allSettled({ backups: this.$store.dispatch('harvester/findAll', { type: HCI.BACKUP }) });
this.rows = hash.backups;
},
data() {
return {
rows: [],
schema
};
},
computed: {
headers() {
return [
STATE,
NAME,
{
name: 'targetVM',
labelKey: 'tableHeaders.targetVm',
value: 'attachVM',
align: 'left',
formatter: 'AttachVMWithName'
},
{
name: 'readyToUse',
labelKey: 'tableHeaders.readyToUse',
value: 'status.readyToUse',
align: 'center',
formatter: 'Checked',
},
AGE
];
},
filteredRows() {
let r = this.rows.filter(row => row.spec?.type === BACKUP_TYPE.SNAPSHOT);
if (this.id) {
r = r.filter(row => row.metadata.annotations?.[HCI_ANNOTATIONS.SVM_BACKUP_ID] === this.id);
}
return r;
},
},
};
</script>
<template>
<ResourceTable
v-bind="$attrs"
:headers="headers"
:groupable="false"
:rows="filteredRows"
:schema="schema"
key-field="_key"
default-sort-by="age"
>
<template #col:name="{row}">
<td>
<span>
<router-link
v-if="row?.status?.source"
:to="row.detailLocation"
>
{{ row.nameDisplay }}
</router-link>
<span v-else>
{{ row.nameDisplay }}
</span>
</span>
</td>
</template>
</ResourceTable>
</template>

View File

@ -0,0 +1,125 @@
<script>
import LabelValue from '@shell/components/LabelValue';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { BACKUP_TYPE } from '../../config/types';
import BackupList from './BackupList';
import SnapshotList from './SnapshotList';
import cronstrue from 'cronstrue';
export default {
name: 'ScheduleVmBackupDetail',
components: {
BackupList,
SnapshotList,
Tab,
Tabbed,
LabelValue,
},
props: {
value: {
type: Object,
required: true,
},
},
computed: {
isBackup() {
return this.value.spec.vmbackup.type === BACKUP_TYPE.BACKUP;
},
isSnapshot() {
return this.value.spec.vmbackup.type === BACKUP_TYPE.SNAPSHOT;
},
cronExpression() {
let cronHint = '';
try {
cronHint = cronstrue.toString(this.value.spec.cron, { verbose: true });
} catch (e) {
cronHint = this.t('generic.invalidCron');
}
return cronHint ? `${ this.value.spec.cron } (${ cronHint })` : this.value.spec.cron;
}
}
};
</script>
<template>
<Tabbed v-bind="$attrs" class="mt-15" :side-tabs="true">
<Tab
name="basic"
:label="t('harvester.virtualMachine.detail.tabs.basics')"
class="bordered-table"
:weight="99"
>
<div class="row">
<div class="col span-6 mb-20">
<LabelValue
:name="t('harvester.schedule.detail.namespace')"
:value="value.metadata.namespace"
/>
</div>
<div class="col span-6 mb-20">
<LabelValue
:name="t('harvester.schedule.detail.sourceVM')"
:value="value.spec.vmbackup.source.name"
/>
</div>
</div>
<div class="row">
<div class="col span-6 mb-20">
<LabelValue
:name="t('harvester.schedule.cron')"
:value="cronExpression"
/>
</div>
<div class="col span-6 mb-20">
<LabelValue
:name="t('harvester.schedule.scheduleType')"
:value="value.spec.vmbackup.type"
/>
</div>
</div>
<div class="row">
<div class="col span-6 mb-20">
<LabelValue
:name="t('harvester.schedule.retain.label')"
:value="value.spec.retain"
/>
</div>
<div class="col span-6 mb-20">
<LabelValue
:name="t('harvester.schedule.maxFailure.label')"
:value="value.spec.maxFailure"
/>
</div>
</div>
</Tab>
<Tab
v-if="isBackup"
name="backups"
:label="t('harvester.schedule.tabs.backups')"
:weight="89"
class="bordered-table"
>
<BackupList :id="value.id" />
</Tab>
<Tab
v-if="isSnapshot"
name="snapshots"
:label="t('harvester.schedule.tabs.snapshots')"
:weight="79"
class="bordered-table"
>
<SnapshotList :id="value.id" />
</Tab>
</Tabbed>
</template>
<style lang="scss" scoped>
.error {
color: var(--error);
}
</style>

View File

@ -127,29 +127,29 @@ export default {
<Tabbed v-if="spec" :side-tabs="true" @changed="onTabChanged"> <Tabbed v-if="spec" :side-tabs="true" @changed="onTabChanged">
<Tab name="Basics" :label="t('harvester.virtualMachine.detail.tabs.basics')"> <Tab name="Basics" :label="t('harvester.virtualMachine.detail.tabs.basics')">
<div class="row"> <div class="row">
<div class="col span-6"> <div class="col span-6 mb-20">
<LabelValue :name="t('harvester.virtualMachine.detail.details.name')" :value="name" /> <LabelValue :name="t('harvester.virtualMachine.detail.details.name')" :value="name" />
</div> </div>
<div class="col span-6"> <div class="col span-6 mb-20">
<LabelValue :name="t('harvester.fields.image')" :value="imageName" /> <LabelValue :name="t('harvester.fields.image')" :value="imageName" />
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col span-6"> <div class="col span-6 mb-20">
<LabelValue :name="t('harvester.virtualMachine.detail.details.hostname')" :value="hostname" /> <LabelValue :name="t('harvester.virtualMachine.detail.details.hostname')" :value="hostname" />
</div> </div>
<div class="col span-6"> <div class="col span-6 mb-20">
<LabelValue :name="t('harvester.virtualMachine.input.MachineType')" :value="machineType" /> <LabelValue :name="t('harvester.virtualMachine.input.MachineType')" :value="machineType" />
</div> </div>
</div> </div>
<div class="mb-20">
<CpuMemory :cpu="cpu" :mode="mode" :memory="memory" /> <CpuMemory :cpu="cpu" :mode="mode" :memory="memory" />
</div>
<div class="row"> <div class="row">
<div class="col span-6"> <div class="col span-6 mb-20">
<LabelValue :name="t('harvester.virtualMachine.detail.details.bootOrder')"> <LabelValue :name="t('harvester.virtualMachine.detail.details.bootOrder')">
<template #value> <template #value>
<div> <div>
@ -162,7 +162,7 @@ export default {
</template> </template>
</LabelValue> </LabelValue>
</div> </div>
<div class="col span-6"> <div class="col span-6 mb-20">
<LabelValue :name="t('harvester.virtualMachine.detail.details.CDROMs')"> <LabelValue :name="t('harvester.virtualMachine.detail.details.CDROMs')">
<template #value> <template #value>
<div> <div>
@ -180,7 +180,6 @@ export default {
</div> </div>
</div> </div>
</Tab> </Tab>
<Tab <Tab
name="volume" name="volume"
:label="t('harvester.tab.volume')" :label="t('harvester.tab.volume')"

View File

@ -1,13 +1,15 @@
<script> <script>
import { HCI } from '../types';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { Card } from '@components/Card'; import { Card } from '@components/Card';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
import { escapeHtml } from '@shell/utils/string'; import { escapeHtml } from '@shell/utils/string';
import { HCI } from '../types';
export default { export default {
name: 'HarvesterEnableUSBPassthrough', name: 'HarvesterEnableUSBPassthrough',
emits: ['close'],
components: { components: {
AsyncButton, AsyncButton,
Card, Card,
@ -83,17 +85,19 @@ export default {
<template> <template>
<Card :show-highlight-border="false"> <Card :show-highlight-border="false">
<template #title>
<h4 <h4
slot="title"
v-clean-html="t('promptRemove.title')" v-clean-html="t('promptRemove.title')"
class="text-default-text" class="text-default-text"
/> />
<template #body>
<t k="harvester.usb.enablePassthroughWarning" :raw="true" />
</template> </template>
<div slot="actions" class="actions"> <template #body>
{{ t('harvester.usb.enablePassthroughWarning') }}
</template>
<template #actions>
<div class="actions">
<div class="buttons"> <div class="buttons">
<button class="btn role-secondary mr-10" @click="close"> <button class="btn role-secondary mr-10" @click="close">
{{ t('generic.cancel') }} {{ t('generic.cancel') }}
@ -102,6 +106,7 @@ export default {
<AsyncButton mode="enable" @click="save" /> <AsyncButton mode="enable" @click="save" />
</div> </div>
</div> </div>
</template>
</Card> </Card>
</template> </template>

View File

@ -171,7 +171,6 @@ export default {
@click="apply" @click="apply"
/> />
</div> </div>
<Banner v-for="(err, i) in errors" :key="i" color="error" :label="err" /> <Banner v-for="(err, i) in errors" :key="i" color="error" :label="err" />
</template> </template>
</Card> </Card>

View File

@ -0,0 +1,298 @@
<script>
import { RadioGroup } from '@components/Form/Radio';
import { Banner } from '@components/Banner';
import { LabeledInput } from '@components/Form/LabeledInput';
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import MessageLink from '@shell/components/MessageLink';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { isCronValid } from '@pkg/harvester/utils/cron';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '@pkg/harvester/config/harvester';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
import { sortBy } from '@shell/utils/sort';
import { BACKUP_TYPE } from '../config/types';
import { _EDIT, _CREATE } from '@shell/config/query-params';
export default {
name: 'CreateVMSchedule',
components: {
CruResource,
Tabbed,
Tab,
RadioGroup,
LabeledInput,
LabeledSelect,
MessageLink,
Banner,
},
mixins: [CreateEditView],
async fetch() {
const hash = await allHash({
settings: this.$store.dispatch('harvester/findAll', { type: HCI.SETTING }),
vms: this.$store.dispatch('harvester/findAll', { type: HCI.VM }),
});
this.allVms = hash.vms;
this.settings = hash.settings;
},
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
}
},
data() {
if (this.mode === _CREATE) {
const defaultNs = this.$store.getters['defaultNamespace'];
const vmNamespace = this.$route.query?.vmNamespace || defaultNs;
const vmName = this.$route.query?.vmName;
delete this.value.metadata.annotations;
delete this.value.metadata.labels;
this.value['metadata'] = {
namespace: vmNamespace,
name: vmName ? `svmbackup-${ vmName }` : ''
};
if (!this.value.spec) {
this.value['spec'] = {
cron: '',
retain: 8,
maxFailure: 4,
vmbackup: {
source: {
apiGroup: 'kubevirt.io',
kind: 'VirtualMachine',
name: vmName || ''
},
type: BACKUP_TYPE.BACKUP
}
};
}
}
return { settings: [] };
},
computed: {
backupTargetResource() {
return this.settings.find( O => O.id === 'backup-target');
},
isEmptyValue() {
return this.getBackupTargetValueIsEmpty(this.backupTargetResource);
},
canUpdate() {
return this?.backupTargetResource?.canUpdate;
},
errorMessage() {
return this.backupTargetResource?.errMessage;
},
canSave() {
return !!this.value.spec.cron && isCronValid(this.value.spec.cron) &&
!!this.value.metadata.name &&
!!this.value.metadata.namespace &&
!!this.value.spec.retain &&
!!this.value.spec.maxFailure;
},
isBackupTargetUnAvailable() {
return this.value.spec.vmbackup.type === BACKUP_TYPE.BACKUP && (this.errorMessage || this.isEmptyValue) && this.canUpdate;
},
vmOptions() {
const nsVmList = this.$store.getters['harvester/all'](HCI.VM).filter(vm => vm.metadata.namespace === this.value.metadata.namespace);
const vmObjectLists = nsVmList.map(obj => ({
label: obj.nameDisplay,
value: obj.name,
}));
return sortBy(vmObjectLists, 'label');
},
namespaces() {
const allNamespaces = this.$store.getters['allNamespaces'];
const out = sortBy(
allNamespaces.map((obj) => {
return {
label: obj.nameDisplay,
value: obj.id,
};
}),
'label'
);
return out;
},
toBackupTargetSetting() {
const { cluster } = this.$router?.currentRoute?.params || {};
return {
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-id`,
params: {
resource: `${ HCI.SETTING }`,
cluster,
id: 'backup-target'
},
query: { mode: _EDIT }
};
},
scheduleTypeOptions() {
return [BACKUP_TYPE.BACKUP, BACKUP_TYPE.SNAPSHOT];
}
},
watch: {
'value.metadata.namespace'() {
this.value.spec.vmbackup.source.name = '';
},
'value.spec.vmbackup.source.name'(neu) {
this.value.metadata.name = `svm${ this.value.spec.vmbackup.type }-${ neu }`;
}
},
methods: {
onTypeChange(newType) {
this.value.metadata.name = `svm${ newType }-${ this.value.spec.vmbackup.source.name }`;
},
getBackupTargetValueIsEmpty(resource) {
let out = true;
if (resource?.value) {
try {
const valueJson = JSON.parse(resource?.value);
out = !valueJson.type;
} catch (e) {}
}
return out;
},
validateFailure(count) {
if (this.value.spec.retain && count > this.value.spec.retain) {
this.value.spec['maxFailure'] = this.value.spec.retain;
}
},
},
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validationPassed="canSave"
@finish="save"
@error="e=>errors = e"
>
<div class="banner">
<Banner
v-if="isBackupTargetUnAvailable"
color="error"
>
<MessageLink
v-if="isEmptyValue"
:to="toBackupTargetSetting"
:target="_blank"
prefix-label="harvester.backup.message.noSetting.prefix"
middle-label="harvester.backup.message.noSetting.middle"
suffix-label="harvester.schedule.message.noSetting.suffix"
/>
<MessageLink
v-else
:to="toBackupTargetSetting"
prefix-label="harvester.backup.message.errorTip.prefix"
middle-label="harvester.backup.message.errorTip.middle"
>
<template v-slot:suffix>
{{ t('harvester.backup.message.errorTip.suffix') }} {{ errorMessage }}
</template>
</MessageLink>
</Banner>
<div class="mb-30">
<RadioGroup
v-model:value="value.spec.vmbackup.type"
name="model"
:options="scheduleTypeOptions"
:labels="[t('harvester.schedule.type.backup'), t('harvester.schedule.type.snapshot')]"
:disabled="isEdit || isView"
:mode="mode"
row
@input="onTypeChange"
/>
</div>
<div class="row mb-30">
<div class="col span-6">
<LabeledSelect
v-model:value="value.metadata.namespace"
:label="t('nameNsDescription.namespace.label')"
:options="namespaces"
required
:disabled="isBackupTargetUnAvailable || isEdit || isView"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.vmbackup.source.name"
:label="t('harvester.schedule.virtualMachine.title')"
:placeholder="t('harvester.schedule.virtualMachine.placeholder')"
:options="vmOptions"
required
:disabled="isBackupTargetUnAvailable || isEdit || isView"
/>
</div>
</div>
</div>
<Tabbed v-bind="$attrs" class="mt-15" :side-tabs="true">
<Tab name="basics" :label="t('harvester.network.tabs.basics')" :weight="99" class="bordered-table">
<LabeledInput
v-model:value="value.spec.cron"
class="mb-30"
type="cron"
required
:mode="mode"
:label="t('harvester.schedule.cron')"
placeholder="0 * * * *"
:disabled="isBackupTargetUnAvailable || isView"
/>
<LabeledInput
v-model:value.number="value.spec.retain"
v-int-number
class="mb-30"
:min="2"
:max="250"
type="number"
:label="t('harvester.schedule.retain.label')"
required
:tooltip="t('harvester.schedule.retain.tooltip')"
:disabled="isBackupTargetUnAvailable || isView"
/>
<LabeledInput
v-model:value.number="value.spec.maxFailure"
v-int-number
class="mb-30"
:min="2"
type="number"
:label="t('harvester.schedule.maxFailure.label')"
required
:tooltip="t('harvester.schedule.maxFailure.tooltip')"
:disabled="isBackupTargetUnAvailable || isView"
@input="validateFailure"
/>
</Tab>
</Tabbed>
</CruResource>
</template>

View File

@ -159,6 +159,10 @@ export default {
}, },
methods: { methods: {
cancelAction() {
this.$router.go(-1);
},
async saveRestore(buttonCb) { async saveRestore(buttonCb) {
this.update(); this.update();
@ -261,7 +265,7 @@ export default {
/> />
</div> </div>
<Footer mode="create" class="footer" :errors="errors" @save="saveRestore" @done="done" /> <Footer mode="create" class="footer" :errors="errors" @save="saveRestore" @done="cancelAction" />
</div> </div>
</template> </template>

View File

@ -157,6 +157,9 @@ export default {
}, },
methods: { methods: {
cancelAction() {
this.$router.go(-1);
},
async saveRestore(buttonCb) { async saveRestore(buttonCb) {
this.update(); this.update();
@ -241,7 +244,7 @@ export default {
<LabeledSelect v-if="!restoreNewVm" v-model:value="deletionPolicy" :label="t('harvester.backup.restore.deletePreviousVolumes')" :options="deletionPolicyOption" /> <LabeledSelect v-if="!restoreNewVm" v-model:value="deletionPolicy" :label="t('harvester.backup.restore.deletePreviousVolumes')" :options="deletionPolicyOption" />
</div> </div>
<Footer mode="create" class="footer" :errors="errors" @save="saveRestore" @done="done" /> <Footer mode="create" class="footer" :errors="errors" @save="saveRestore" @done="cancelAction" />
</div> </div>
</template> </template>

View File

@ -225,13 +225,15 @@ export default {
} }
}, },
headerFor(type) { headerFor(type, hasVolBackups = false) {
return { const mainHeader = {
[SOURCE_TYPE.NEW]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.volume'), [SOURCE_TYPE.NEW]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.volume'),
[SOURCE_TYPE.IMAGE]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.vmImage'), [SOURCE_TYPE.IMAGE]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.vmImage'),
[SOURCE_TYPE.ATTACH_VOLUME]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.existingVolume'), [SOURCE_TYPE.ATTACH_VOLUME]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.existingVolume'),
[SOURCE_TYPE.CONTAINER]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.container'), [SOURCE_TYPE.CONTAINER]: this.$store.getters['i18n/t']('harvester.virtualMachine.volume.title.container'),
}[type]; }[type];
return hasVolBackups ? `${ mainHeader } and Backups` : mainHeader;
}, },
update() { update() {
@ -322,7 +324,7 @@ export default {
</span> </span>
<span v-else> <span v-else>
{{ headerFor(volume.source) }} {{ headerFor(volume.source, !!volume?.volumeBackups) }}
</span> </span>
</h3> </h3>
<div> <div>

View File

@ -3,6 +3,7 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay'; import InputOrDisplay from '@shell/components/InputOrDisplay';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { Banner } from '@components/Banner';
export default { export default {
name: 'HarvesterEditContainer', name: 'HarvesterEditContainer',
@ -10,7 +11,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
LabeledInput, LabeledSelect, InputOrDisplay LabeledInput, LabeledSelect, InputOrDisplay, Banner
}, },
props: { props: {
@ -70,7 +71,6 @@ export default {
/> />
</InputOrDisplay> </InputOrDisplay>
</div> </div>
<div <div
data-testid="input-hec-type" data-testid="input-hec-type"
class="col span-6" class="col span-6"
@ -111,7 +111,6 @@ export default {
/> />
</InputOrDisplay> </InputOrDisplay>
</div> </div>
<div <div
data-testid="input-hec-bus" data-testid="input-hec-bus"
class="col span-6" class="col span-6"
@ -131,5 +130,28 @@ export default {
</InputOrDisplay> </InputOrDisplay>
</div> </div>
</div> </div>
<div class="row mb-20">
<div
v-if="value.volumeBackups"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.virtualMachine.volume.readyToUse')"
:value="value.volumeBackups.readyToUse"
:mode="mode"
>
<LabelValue
:name="t('harvester.virtualMachine.volume.readyToUse')"
:value="value.volumeBackups.readyToUse"
/>
</InputOrDisplay>
</div>
</div>
<Banner
v-if="value.volumeBackups && value.volumeBackups.error && value.volumeBackups.error.message"
color="error"
class="mb-20"
:label="value.volumeBackups.error.message"
/>
</div> </div>
</template> </template>

View File

@ -1,9 +1,10 @@
<script> <script>
import UnitInput from '@shell/components/form/UnitInput'; import UnitInput from '@shell/components/form/UnitInput';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabelValue from '@shell/components/LabelValue';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay'; import InputOrDisplay from '@shell/components/InputOrDisplay';
import { Banner } from '@components/Banner';
import { sortBy } from '@shell/utils/sort'; import { sortBy } from '@shell/utils/sort';
import { PVC } from '@shell/config/types'; import { PVC } from '@shell/config/types';
import { _CREATE } from '@shell/config/query-params'; import { _CREATE } from '@shell/config/query-params';
@ -17,7 +18,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
}, },
props: { props: {
@ -275,7 +276,6 @@ export default {
/> />
</InputOrDisplay> </InputOrDisplay>
</div> </div>
<div <div
data-testid="input-hee-bus" data-testid="input-hee-bus"
class="col span-6" class="col span-6"
@ -296,6 +296,27 @@ export default {
/> />
</InputOrDisplay> </InputOrDisplay>
</div> </div>
<div
v-if="value.volumeBackups"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.virtualMachine.volume.readyToUse')"
:value="value.volumeBackups.readyToUse"
:mode="mode"
>
<LabelValue
:name="t('harvester.virtualMachine.volume.readyToUse')"
:value="value.volumeBackups.readyToUse"
/>
</InputOrDisplay>
</div> </div>
</div> </div>
<Banner
v-if="value.volumeBackups && value.volumeBackups.error && value.volumeBackups.error.message"
color="error"
class="mb-20"
:label="value.volumeBackups.error.message"
/>
</div>
</template> </template>

View File

@ -4,6 +4,7 @@ import UnitInput from '@shell/components/form/UnitInput';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay'; import InputOrDisplay from '@shell/components/InputOrDisplay';
import { Banner } from '@components/Banner';
import { PVC } from '@shell/config/types'; import { PVC } from '@shell/config/types';
import { formatSi, parseSi } from '@shell/utils/units'; import { formatSi, parseSi } from '@shell/utils/units';
import { HCI } from '../../../../types'; import { HCI } from '../../../../types';
@ -18,7 +19,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
}, },
props: { props: {
@ -101,6 +102,12 @@ export default {
return image ? image.label : '-'; return image ? image.label : '-';
}, },
readyToUse() {
const val = String(this.value.volumeBackups?.readyToUse || false);
return ucFirst(val);
},
pvcsResource() { pvcsResource() {
const allPVCs = this.$store.getters['harvester/all'](PVC) || []; const allPVCs = this.$store.getters['harvester/all'](PVC) || [];
@ -301,7 +308,7 @@ export default {
</div> </div>
<div <div
v-if="isView" v-if="isView"
class="col span-6" class="col span-3"
> >
<LabelValue <LabelValue
:name="t('harvester.virtualMachine.volume.encryption')" :name="t('harvester.virtualMachine.volume.encryption')"
@ -309,5 +316,19 @@ export default {
/> />
</div> </div>
</div> </div>
<div class="row mb-20">
<div v-if="value.volumeBackups && isView" class="col span-3">
<LabelValue
:name="t('harvester.virtualMachine.volume.readyToUse')"
:value="readyToUse"
/>
</div>
</div>
<Banner
v-if="value.volumeBackups && value.volumeBackups.error && value.volumeBackups.error.message"
color="error"
class="mb-20"
:label="value.volumeBackups.error.message"
/>
</div> </div>
</template> </template>

View File

@ -4,7 +4,7 @@ import UnitInput from '@shell/components/form/UnitInput';
import InputOrDisplay from '@shell/components/InputOrDisplay'; import InputOrDisplay from '@shell/components/InputOrDisplay';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { Banner } from '@components/Banner';
import { PVC, STORAGE_CLASS } from '@shell/config/types'; import { PVC, STORAGE_CLASS } from '@shell/config/types';
import { formatSi, parseSi } from '@shell/utils/units'; import { formatSi, parseSi } from '@shell/utils/units';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
@ -18,7 +18,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue, Banner
}, },
props: { props: {
@ -68,9 +68,16 @@ export default {
return ucFirst(String(this.value.isEncrypted)); return ucFirst(String(this.value.isEncrypted));
}, },
readyToUse() {
const val = String(this.value.volumeBackups?.readyToUse || false);
return ucFirst(val);
},
isView() { isView() {
return this.mode === _VIEW; return this.mode === _VIEW;
}, },
pvcsResource() { pvcsResource() {
const allPVCs = this.$store.getters['harvester/all'](PVC) || []; const allPVCs = this.$store.getters['harvester/all'](PVC) || [];
@ -221,7 +228,6 @@ export default {
</InputOrDisplay> </InputOrDisplay>
</div> </div>
</div> </div>
<div class="row mb-20"> <div class="row mb-20">
<div <div
data-testid="input-hev-bus" data-testid="input-hev-bus"
@ -248,5 +254,19 @@ export default {
/> />
</div> </div>
</div> </div>
<div class="row mb-20">
<div v-if="value.volumeBackups && isView" class="col span-3">
<LabelValue
:name="t('harvester.virtualMachine.volume.readyToUse')"
:value="readyToUse"
/>
</div>
</div>
<Banner
v-if="value.volumeBackups && value.volumeBackups.error && value.volumeBackups.error.message"
color="error"
class="mb-20"
:label="value.volumeBackups.error.message"
/>
</div> </div>
</template> </template>

View File

@ -1,7 +1,6 @@
<script> <script>
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Tabbed from '@shell/components/Tabbed'; import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab'; import Tab from '@shell/components/Tabbed/Tab';
import { Checkbox } from '@components/Form/Checkbox'; import { Checkbox } from '@components/Form/Checkbox';

View File

@ -0,0 +1,41 @@
<script>
import { HCI } from '../types';
export default {
props: {
value: {
type: String,
default: ''
},
},
data() {
const scheduleList = this.$store.getters['harvester/all'](HCI.SCHEDULE_VM_BACKUP) || [];
return { scheduleList };
},
computed: {
vmSchedule() {
if (!this.value) {
return '';
} else {
return this.scheduleList.find(s => s.id === this.value);
}
},
to() {
return this.vmSchedule?.detailLocation;
},
}
};
</script>
<template>
<router-link v-if="to" :to="to">
{{ value }}
</router-link>
<span v-else-if="value">
{{ value }}
</span>
<span v-else class="text-muted">
&mdash;
</span>
</template>

View File

@ -0,0 +1,32 @@
<script>
import cronstrue from 'cronstrue';
export default {
props: {
value: {
type: String,
default: ''
},
},
computed: {
cronTooltipHint() {
let cronHint = '';
try {
cronHint = cronstrue.toString(this.value, { verbose: true });
} catch (e) {
cronHint = this.t('generic.invalidCron');
}
return cronHint || this.value.spec.cron;
}
}
};
</script>
<template>
<span v-clean-tooltip="cronTooltipHint">
{{ value }}
</span>
</template>

View File

@ -147,7 +147,7 @@ export default {
> >
<template #title="{formattedPercentage}"> <template #title="{formattedPercentage}">
<span> <span>
{{ t('harvester.formatters.hardwareResourceGauge.allocated') }} {{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}

View File

@ -84,6 +84,12 @@ harvester:
tip: Please enter a template name! tip: Please enter a template name!
success: 'Template { templateName } created successfully.' success: 'Template { templateName } created successfully.'
failed: 'Failed generated template!' failed: 'Failed generated template!'
schedule:
title: Create Schedule
message:
tip: Please enter a schedule name!
success: 'Schedule { name } created successfully.'
failed: 'Failed create schedule!'
cloneVM: cloneVM:
title: Clone Virtual Machine title: Clone Virtual Machine
name: New Virtual Machine Name name: New Virtual Machine Name
@ -171,8 +177,11 @@ harvester:
setDefaultVersion: Set default version setDefaultVersion: Set default version
addTemplateVersion: Add template version addTemplateVersion: Add template version
backup: Take Backup backup: Take Backup
createSchedule: Create Schedule
restore: Restore restore: Restore
restoreNewVM: Restore New restoreNewVM: Restore New
resumeSchedule: Resume
suspendSchedule: Suspend
restoreExistingVM: Replace Existing restoreExistingVM: Replace Existing
migrate: Migrate migrate: Migrate
abortMigration: Abort Migration abortMigration: Abort Migration
@ -223,6 +232,12 @@ harvester:
readyToUse: Ready To Use readyToUse: Ready To Use
backupTarget: Backup Target backupTarget: Backup Target
targetVm: Target Virtual Machine targetVm: Target Virtual Machine
cronExpression: Cron Expression
retain: Retain
scheduleType: Type
maxFailure: Max Failure
sourceVm: Source VM
vmSchedule: Virtual Machine Schedule
hostIp: Host IP hostIp: Host IP
vm: vm:
ipAddress: IP Address ipAddress: IP Address
@ -269,6 +284,7 @@ harvester:
promiscuous: Promiscuous promiscuous: Promiscuous
ipv4Address: IPv4 address ipv4Address: IPv4 address
filterLabels: Filter labels filterLabels: Filter labels
filterSchedule: Filter schedule
storageClass: Storage class storageClass: Storage class
dockerImage: Docker image dockerImage: Docker image
pci: pci:
@ -356,6 +372,7 @@ harvester:
cpu: CPU cpu: CPU
memory: Memory memory: Memory
storage: Storage storage: Storage
allocated: Allocated
sections: sections:
events: events:
label: Events label: Events
@ -590,6 +607,7 @@ harvester:
size: Size size: Size
edit: Edit edit: Edit
bus: Bus bus: Bus
readyToUse: Ready To Use
bootOrder: Boot Order bootOrder: Boot Order
volume: Volume volume: Volume
dockerImage: Docker Image dockerImage: Docker Image
@ -849,6 +867,37 @@ harvester:
tip: Unmet system requirements and incorrectly performed procedures may cause complete upgrade failure and other issues that require manual workarounds. tip: Unmet system requirements and incorrectly performed procedures may cause complete upgrade failure and other issues that require manual workarounds.
moreNotes: For more details about the release notes, please visit - moreNotes: For more details about the release notes, please visit -
schedule:
label: Virtual Machine Schedules
createTitle: Create Schedule
createButtonText: Create Schedule
scheduleType: Virtual Machine Schedule Type
cron: Cron Schedule
detail:
namespace: Namespace
sourceVM: Source Virtual Machine
tabs:
basic: Basic
backups: Backups
snapshots: Snapshots
message:
noSetting:
suffix: before creating a backup schedule
retain:
label: Retain
count: Count
tooltip: Number of up-to-date VM backups to retain. Maximum to 250, minimum to 2.
maxFailure:
label: Max Failure
count: Count
tooltip: Max number of consecutive failed backups that could be tolerated. If reach this threshold, Harvester controller will suspend the schedule job. This value should less than retain count
virtualMachine:
title: Virtual Machine Name
placeholder: Select a virtual machine
type:
snapshot: Snapshot
backup: Backup
backup: backup:
label: Virtual Machine Backups label: Virtual Machine Backups
createText: Restore Backup createText: Restore Backup
@ -886,7 +935,6 @@ harvester:
starting: Backup initiating starting: Backup initiating
progress: Backup in progress progress: Backup in progress
complete: Backup completed complete: Backup completed
restore: restore:
progress: progress:
details: Volume details details: Volume details
@ -1532,6 +1580,11 @@ typeLabel:
one { Template } one { Template }
other { Templates } other { Templates }
} }
harvesterhci.io.schedulevmbackup: |-
{count, plural,
one { Virtual Machine Schedule }
other { Virtual Machine Schedules }
}
harvesterhci.io.virtualmachinebackup: |- harvesterhci.io.virtualmachinebackup: |-
{count, plural, {count, plural,
one { Virtual Machines Backup } one { Virtual Machines Backup }

View File

@ -660,7 +660,7 @@ export default {
:name="t('harvester.dashboard.hardwareResourceGauge.storage')" :name="t('harvester.dashboard.hardwareResourceGauge.storage')"
:used="storageUsed" :used="storageUsed"
:reserved="storageAllocated" :reserved="storageAllocated"
:reserved-title="t('harvester.formatters.hardwareResourceGauge.allocated')" :reserved-title="t('harvester.dashboard.hardwareResourceGauge.allocated')"
/> />
</div> </div>
</template> </template>

View File

@ -0,0 +1,123 @@
<script>
import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import ResourceTable from '@shell/components/ResourceTable';
import { HCI } from '../types';
import { allSettled } from '../utils/promise';
import { STATE, AGE, NAME, NAMESPACE } from '@shell/config/table-headers';
import { VM_SCHEDULE_CRON, VM_SCHEDULE_RETAIN, VM_SCHEDULE_TYPE, VM_SCHEDULE_MAX_FAILURE } from '../config/table-headers';
import { BACKUP_TYPE } from '../config/types';
export default {
name: 'HarvesterListSchedule',
components: {
ResourceTable, Loading, Masthead,
},
props: {
schema: {
type: Object,
required: true,
}
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = await allSettled({
vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM }),
rows: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SCHEDULE_VM_BACKUP }),
});
this.rows = hash.rows;
},
data() {
const params = { ...this.$route.params };
const resource = params.resource;
return {
rows: [],
settings: [],
resource,
to: `${ HCI.SETTING }/backup-target?mode=edit`,
};
},
computed: {
headers() {
const cols = [
STATE,
NAME,
NAMESPACE,
{
name: 'sourceVm',
labelKey: 'harvester.tableHeaders.sourceVm',
value: 'spec.vmbackup.source.name',
sort: 'sourceVm',
align: 'center',
formatter: 'AttachVMWithName'
},
VM_SCHEDULE_TYPE,
VM_SCHEDULE_CRON,
VM_SCHEDULE_RETAIN,
VM_SCHEDULE_MAX_FAILURE,
AGE,
];
return cols;
},
filteredRows() {
return this.rows.filter(R => R.spec?.type !== BACKUP_TYPE.SNAPSHOT);
},
typeDisplay() {
return this.t('harvester.schedule.label');
}
},
methods: {
getRow(row) {
return row.spec?.vmbackup?.source?.name;
}
},
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else>
<Masthead
:schema="schema"
:resource="resource"
:type-display="typeDisplay"
:parentNameOverride="'Virtual Machine schedule'"
:create-button-label="t('harvester.schedule.createButtonText')"
/>
<ResourceTable
v-bind="$attrs"
:headers="headers"
:groupable="true"
:rows="filteredRows"
:schema="schema"
key-field="_key"
default-sort-by="age"
>
<template #col:name="{row}">
<td>
<span>
<router-link
v-if="getRow(row)"
:to="row.detailLocation"
>
{{ row.nameDisplay }}
</router-link>
<span v-else>
{{ row.nameDisplay }}
</span>
</span>
</td>
</template>
</ResourceTable>
</div>
</template>

View File

@ -6,21 +6,23 @@ import Masthead from '@shell/components/ResourceList/Masthead';
import ResourceTable from '@shell/components/ResourceTable'; import ResourceTable from '@shell/components/ResourceTable';
import { STATE, AGE, NAME, NAMESPACE } from '@shell/config/table-headers'; import { STATE, AGE, NAME, NAMESPACE } from '@shell/config/table-headers';
import FilterVMSchedule from '../components/FilterVMSchedule';
import { HCI } from '../types'; import { HCI } from '../types';
import { allSettled } from '../utils/promise'; import { allSettled } from '../utils/promise';
import { BACKUP_TYPE } from '../config/types'; import { BACKUP_TYPE } from '../config/types';
import { defaultTableSortGenerationFn } from '@shell/components/ResourceTable.vue';
export default { export default {
name: 'HarvesterListBackup', name: 'HarvesterListBackup',
components: { components: {
ResourceTable, Banner, Loading, Masthead, MessageLink ResourceTable, Banner, Loading, Masthead, MessageLink, FilterVMSchedule
}, },
props: { props: {
schema: { schema: {
type: Object, type: Object,
required: true, required: true,
} },
}, },
async fetch() { async fetch() {
@ -28,12 +30,13 @@ export default {
const hash = await allSettled({ const hash = await allSettled({
vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM }), vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM }),
settings: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SETTING }), settings: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SETTING }),
rows: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.BACKUP }), backups: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.BACKUP }),
scheduleList: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SCHEDULE_VM_BACKUP }),
}); });
this.rows = hash.rows; this.backups = hash.backups;
this.rows = hash.backups;
this.settings = hash.settings; this.settings = hash.settings;
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.SETTING)) { if (this.$store.getters[`${ inStore }/schemaFor`](HCI.SETTING)) {
const backupTargetResource = hash.settings.find( O => O.id === 'backup-target'); const backupTargetResource = hash.settings.find( O => O.id === 'backup-target');
const isEmpty = this.getBackupTargetValueIsEmpty(backupTargetResource); const isEmpty = this.getBackupTargetValueIsEmpty(backupTargetResource);
@ -51,9 +54,11 @@ export default {
return { return {
rows: [], rows: [],
backups: [],
settings: [], settings: [],
resource, resource,
to: `${ HCI.SETTING }/backup-target?mode=edit`, to: `${ HCI.SETTING }/backup-target?mode=edit`,
searchSchedule: ''
}; };
}, },
@ -85,7 +90,25 @@ export default {
} }
return out; return out;
} },
getRow(row) {
return row.status && row.status.source;
},
changeRows(filteredRows, searchSchedule) {
this[searchSchedule] = searchSchedule;
this[backups] = filteredRows;
},
sortGenerationFn() {
let base = defaultTableSortGenerationFn(this.schema, this.$store);
base += this.searchSchedule;
return base;
},
}, },
computed: { computed: {
@ -101,6 +124,12 @@ export default {
align: 'left', align: 'left',
formatter: 'AttachVMWithName' formatter: 'AttachVMWithName'
}, },
{
name: 'backupCreatedFrom',
labelKey: 'harvester.tableHeaders.vmSchedule',
value: 'sourceSchedule',
formatter: 'BackupCreatedFrom',
},
{ {
name: 'backupTarget', name: 'backupTarget',
labelKey: 'tableHeaders.backupTarget', labelKey: 'tableHeaders.backupTarget',
@ -112,7 +141,7 @@ export default {
name: 'readyToUse', name: 'readyToUse',
labelKey: 'tableHeaders.readyToUse', labelKey: 'tableHeaders.readyToUse',
value: 'status.readyToUse', value: 'status.readyToUse',
align: 'left', align: 'center',
formatter: 'Checked', formatter: 'Checked',
}, },
]; ];
@ -124,25 +153,24 @@ export default {
value: 'backupProgress', value: 'backupProgress',
align: 'left', align: 'left',
formatter: 'HarvesterBackupProgressBar', formatter: 'HarvesterBackupProgressBar',
width: 200,
}); });
} }
cols.push(AGE); cols.push(AGE);
return cols; return cols;
}, },
hasBackupProgresses() { hasBackupProgresses() {
return !!this.rows.find(R => R.status?.progress !== undefined); return !!this.backups.find(r => r.status?.progress !== undefined);
}, },
filteredRows() { filteredRows() {
return this.rows.filter(R => R.spec?.type !== BACKUP_TYPE.SNAPSHOT); return this.backups.filter(r => r.spec?.type !== BACKUP_TYPE.SNAPSHOT);
},
getRawRows() {
return this.rows.filter(r => r.spec?.type === BACKUP_TYPE.BACKUP);
}, },
backupTargetResource() { backupTargetResource() {
return this.settings.find( O => O.id === 'backup-target'); return this.settings.find(O => O.id === 'backup-target');
}, },
isEmptyValue() { isEmptyValue() {
@ -211,16 +239,22 @@ export default {
:headers="headers" :headers="headers"
:groupable="true" :groupable="true"
:rows="filteredRows" :rows="filteredRows"
:sort-generation-fn="sortGenerationFn"
:schema="schema" :schema="schema"
key-field="_key" key-field="_key"
default-sort-by="age" default-sort-by="age"
> >
<template #more-header-middle>
<FilterVMSchedule
:rows="getRawRows"
@change-rows="changeRows"
/>
</template>
<template #col:name="{row}"> <template #col:name="{row}">
<td> <td>
<span> <span>
<router-link <router-link
v-if="row.status && row.status.source" v-if="getRow(row)"
:to="row.detailLocation" :to="row.detailLocation"
> >
{{ row.nameDisplay }} {{ row.nameDisplay }}

View File

@ -2,14 +2,15 @@
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead'; import Masthead from '@shell/components/ResourceList/Masthead';
import ResourceTable from '@shell/components/ResourceTable'; import ResourceTable from '@shell/components/ResourceTable';
import { SCHEMA } from '@shell/config/types'; import { SCHEMA } from '@shell/config/types';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import FilterVMSchedule from '../components/FilterVMSchedule';
import { STATE, AGE, NAME, NAMESPACE } from '@shell/config/table-headers'; import { STATE, AGE, NAME, NAMESPACE } from '@shell/config/table-headers';
import { HCI } from '../types'; import { HCI } from '../types';
import { BACKUP_TYPE } from '../config/types'; import { BACKUP_TYPE } from '../config/types';
import { defaultTableSortGenerationFn } from '@shell/components/ResourceTable.vue';
const schema = { export const schema = {
id: HCI.VM_SNAPSHOT, id: HCI.VM_SNAPSHOT,
type: SCHEMA, type: SCHEMA,
attributes: { attributes: {
@ -22,7 +23,7 @@ const schema = {
export default { export default {
name: 'HarvesterListVMSnapshot', name: 'HarvesterListVMSnapshot',
components: { components: {
ResourceTable, Loading, Masthead ResourceTable, Loading, Masthead, FilterVMSchedule
}, },
async fetch() { async fetch() {
@ -39,6 +40,7 @@ export default {
} }
this.rows = hash.rows; this.rows = hash.rows;
this.snapshots = hash.rows;
}, },
data() { data() {
@ -48,6 +50,8 @@ export default {
return { return {
rows: [], rows: [],
snapshots: [],
searchSchedule: '',
resource, resource,
}; };
}, },
@ -63,19 +67,32 @@ export default {
labelKey: 'tableHeaders.targetVm', labelKey: 'tableHeaders.targetVm',
value: 'attachVM', value: 'attachVM',
align: 'left', align: 'left',
sort: 'attachVM',
formatter: 'AttachVMWithName' formatter: 'AttachVMWithName'
}, },
{
name: 'backupCreatedFrom',
labelKey: 'harvester.tableHeaders.vmSchedule',
value: 'sourceSchedule',
sort: 'sourceSchedule',
formatter: 'BackupCreatedFrom',
},
{ {
name: 'readyToUse', name: 'readyToUse',
labelKey: 'tableHeaders.readyToUse', labelKey: 'tableHeaders.readyToUse',
value: 'status.readyToUse', value: 'status.readyToUse',
align: 'left', align: 'center',
sort: 'status.readyToUse',
formatter: 'Checked', formatter: 'Checked',
}, },
AGE AGE
]; ];
}, },
getRawRows() {
return this.rows.filter(r => r.spec?.type === BACKUP_TYPE.SNAPSHOT);
},
schema() { schema() {
return schema; return schema;
}, },
@ -85,9 +102,24 @@ export default {
}, },
filteredRows() { filteredRows() {
return this.rows.filter(R => R.spec?.type !== BACKUP_TYPE.BACKUP); return this.snapshots.filter(r => r.spec?.type !== BACKUP_TYPE.BACKUP);
}, },
}, },
methods: {
changeRows(filteredRows, searchSchedule) {
this['searchSchedule'] = searchSchedule;
this['snapshots'] = filteredRows;
},
sortGenerationFn() {
let base = defaultTableSortGenerationFn(this.schema, this.$store);
base += this.searchSchedule;
return base;
},
}
}; };
</script> </script>
@ -100,17 +132,23 @@ export default {
:type-display="typeDisplay" :type-display="typeDisplay"
:create-button-label="t('harvester.vmSnapshot.createText')" :create-button-label="t('harvester.vmSnapshot.createText')"
/> />
<ResourceTable <ResourceTable
v-bind="$attrs" v-bind="$attrs"
:headers="headers" :headers="headers"
:groupable="true" :groupable="true"
:rows="filteredRows" :rows="filteredRows"
:schema="schema" :schema="schema"
:sort-generation-fn="sortGenerationFn"
key-field="_key" key-field="_key"
default-sort-by="age" default-sort-by="age"
> >
<template #more-header-middle>
<FilterVMSchedule
:rows="getRawRows"
@change-rows="changeRows"
/>
</template>
<template #col:name="{row}"> <template #col:name="{row}">
<td> <td>
<span> <span>
@ -126,6 +164,6 @@ export default {
</span> </span>
</td> </td>
</template> </template>
</resourcetable> </ResourceTable>
</div> </div>
</template> </template>

View File

@ -301,6 +301,7 @@ export default {
} = config; } = config;
const vm = this.resourceType === HCI.VM ? value : this.resourceType === HCI.BACKUP ? this.value.status?.source : value.spec.vm; const vm = this.resourceType === HCI.VM ? value : this.resourceType === HCI.BACKUP ? this.value.status?.source : value.spec.vm;
const volumeBackups = this.resourceType === HCI.BACKUP ? this.value.status?.volumeBackups : null;
const spec = vm?.spec; const spec = vm?.spec;
@ -335,7 +336,8 @@ export default {
const sshKey = this.getSSHFromAnnotation(spec) || []; const sshKey = this.getSSHFromAnnotation(spec) || [];
const imageId = this.getRootImageId(vm) || ''; const imageId = this.getRootImageId(vm) || '';
const diskRows = this.getDiskRows(vm); const diskRows = this.getDiskRows(vm, volumeBackups);
const networkRows = this.getNetworkRows(vm, { fromTemplate, init }); const networkRows = this.getNetworkRows(vm, { fromTemplate, init });
const hasCreateVolumes = this.getHasCreatedVolumes(spec) || []; const hasCreateVolumes = this.getHasCreatedVolumes(spec) || [];
@ -404,7 +406,7 @@ export default {
this.refreshYamlEditor(); this.refreshYamlEditor();
}, },
getDiskRows(vm) { getDiskRows(vm, volBackups) {
const namespace = vm.metadata.namespace; const namespace = vm.metadata.namespace;
const _volumes = vm.spec.template.spec.volumes || []; const _volumes = vm.spec.template.spec.volumes || [];
const _disks = vm.spec.template.spec.domain.devices.disks || []; const _disks = vm.spec.template.spec.domain.devices.disks || [];
@ -422,6 +424,7 @@ export default {
const isIsoImage = /iso$/i.test(imageResource?.imageSuffix); const isIsoImage = /iso$/i.test(imageResource?.imageSuffix);
const imageSize = Math.max(imageResource?.status?.size, imageResource?.status?.virtualSize); const imageSize = Math.max(imageResource?.status?.size, imageResource?.status?.virtualSize);
const isEncrypted = imageResource?.isEncrypted || false; const isEncrypted = imageResource?.isEncrypted || false;
const volumeBackups = volBackups?.find(vBackup => vBackup.volumeName === 'disk-0') || null ;
if (isIsoImage) { if (isIsoImage) {
bus = 'sata'; bus = 'sata';
@ -449,7 +452,8 @@ export default {
storageClassName: '', storageClassName: '',
image: this.imageId, image: this.imageId,
volumeMode: 'Block', volumeMode: 'Block',
isEncrypted isEncrypted,
volumeBackups,
}); });
} else { } else {
out = _disks.map( (DISK, index) => { out = _disks.map( (DISK, index) => {
@ -531,6 +535,7 @@ export default {
const volumeStatus = pvc?.relatedPV?.metadata?.annotations?.[HCI_ANNOTATIONS.VOLUME_ERROR]; const volumeStatus = pvc?.relatedPV?.metadata?.annotations?.[HCI_ANNOTATIONS.VOLUME_ERROR];
const isEncrypted = pvc?.isEncrypted || false; const isEncrypted = pvc?.isEncrypted || false;
const volumeBackups = volBackups?.find(vBackup => vBackup.volumeName === DISK.name) || null;
return { return {
id: randomStr(5), id: randomStr(5),
@ -551,7 +556,8 @@ export default {
volumeStatus, volumeStatus,
dataSource, dataSource,
namespace, namespace,
isEncrypted isEncrypted,
volumeBackups,
}; };
}); });
} }
@ -1396,6 +1402,14 @@ export default {
} }
}, },
setCpuPinning(value) {
if (value) {
set(this.spec.template.spec.domain.cpu, 'dedicatedCpuPlacement', true);
} else {
this.$delete(this.spec.template.spec.domain.cpu, 'dedicatedCpuPlacement');
}
},
setTPM(tpmEnabled) { setTPM(tpmEnabled) {
if (tpmEnabled) { if (tpmEnabled) {
set(this.spec.template.spec.domain.devices, 'tpm', {}); set(this.spec.template.spec.domain.devices, 'tpm', {});

View File

@ -0,0 +1,97 @@
import HarvesterResource from './harvester';
import { get } from '@shell/utils/object';
import { findBy } from '@shell/utils/array';
import { colorForState, stateDisplay, STATES } from '@shell/plugins/dashboard-store/resource-class';
import { _CREATE } from '@shell/config/query-params';
import { ucFirst, escapeHtml } from '@shell/utils/string';
export default class ScheduleVmBackup extends HarvesterResource {
detailPageHeaderActionOverride(realMode) {
if (realMode === _CREATE) {
return this.t('harvester.schedule.createTitle');
}
}
get _availableActions() {
const toFilter = ['goToClone'];
const out = super._availableActions.filter((action) => {
if (!toFilter.includes(action.action)) {
return action;
}
});
return [
{
action: 'resumeSchedule',
enabled: ucFirst(this.state) === STATES.suspended.label,
icon: 'icons icon-play',
label: this.t('harvester.action.resumeSchedule'),
},
{
action: 'suspendSchedule',
enabled: ucFirst(this.state) === STATES.active.label,
icon: 'icons icon-pause',
label: this.t('harvester.action.suspendSchedule'),
},
...out
];
}
async suspendSchedule() {
try {
this.spec.suspend = true; // suspend schedule
await this.save();
} catch (err) {
this.spec.suspend = false;
this.$dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.metadata.name) }),
err,
}, { root: true });
}
}
async resumeSchedule() {
try {
this.spec.suspend = false; // resume schedule
await this.save();
} catch (err) {
this.spec.suspend = true;
this.$dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.metadata.name) }),
err,
}, { root: true });
}
}
get state() {
const conditions = get(this, 'status.conditions');
const isSuspended = findBy(conditions, 'type', 'BackupSuspend')?.status === 'True';
if (isSuspended) {
return STATES.suspended.label;
}
return this.metadata.state.name;
}
get stateDescription() {
const suspendedCondition = (this.status?.conditions || []).find(c => c.type === 'BackupSuspend');
return ucFirst(suspendedCondition?.message) || super.stateDescription;
}
get stateBackground() {
return colorForState(this.stateDisplay).replace('text-', 'bg-');
}
get stateColor() {
return colorForState(this.state);
}
get stateDisplay() {
return stateDisplay(this.state);
}
}

View File

@ -1,6 +1,7 @@
import { get, clone } from '@shell/utils/object'; import { get, clone } from '@shell/utils/object';
import { findBy } from '@shell/utils/array'; import { findBy } from '@shell/utils/array';
import { colorForState } from '@shell/plugins/dashboard-store/resource-class'; import { colorForState } from '@shell/plugins/dashboard-store/resource-class';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { _CREATE } from '@shell/config/query-params'; import { _CREATE } from '@shell/config/query-params';
import { HCI } from '../types'; import { HCI } from '../types';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester'; import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester';
@ -18,9 +19,8 @@ export default class HciVmBackup extends HarvesterResource {
get detailLocation() { get detailLocation() {
const detailLocation = clone(this._detailLocation); const detailLocation = clone(this._detailLocation);
const route = this.currentRoute();
detailLocation.params.resource = route.params.resource; detailLocation.params.resource = HCI.BACKUP;
return detailLocation; return detailLocation;
} }
@ -81,24 +81,30 @@ export default class HciVmBackup extends HarvesterResource {
} }
restoreExistingVM(resource = this) { restoreExistingVM(resource = this) {
const route = this.currentRoute();
const router = this.currentRouter(); const router = this.currentRouter();
const targetResource = resource.spec.type === BACKUP_TYPE.BACKUP ? HCI.BACKUP : HCI.VM_SNAPSHOT;
router.push({ router.push({
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-create`, name: `${ HARVESTER_PRODUCT }-c-cluster-resource-create`,
params: { resource: route.params.resource }, params: { resource: targetResource },
query: { restoreMode: 'existing', resourceName: resource.name } query: {
restoreMode: 'existing',
resourceName: resource.name,
}
}); });
} }
restoreNewVM(resource = this) { restoreNewVM(resource = this) {
const route = this.currentRoute();
const router = this.currentRouter(); const router = this.currentRouter();
const targetResource = resource.spec.type === BACKUP_TYPE.BACKUP ? HCI.BACKUP : HCI.VM_SNAPSHOT;
router.push({ router.push({
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-create`, name: `${ HARVESTER_PRODUCT }-c-cluster-resource-create`,
params: { resource: route.params.resource }, params: { resource: targetResource },
query: { restoreMode: 'new', resourceName: resource.name } query: {
restoreMode: 'new',
resourceName: resource.name,
}
}); });
} }
@ -125,6 +131,10 @@ export default class HciVmBackup extends HarvesterResource {
return colorForState(state); return colorForState(state);
} }
get sourceSchedule() {
return this.metadata?.annotations[HCI_ANNOTATIONS.SVM_BACKUP_ID];
}
get attachVM() { get attachVM() {
return this.spec.source.name; return this.spec.source.name;
} }

View File

@ -1,6 +1,6 @@
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 { colorForState } from '@shell/plugins/dashboard-store/resource-class'; import { colorForState } 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';
@ -161,6 +161,12 @@ export default class VirtVm extends HarvesterResource {
icon: 'icon icon-storage', icon: 'icon icon-storage',
label: this.t('harvester.action.editVMQuota') label: this.t('harvester.action.editVMQuota')
}, },
{
action: 'createSchedule',
enabled: true,
icon: 'icon icon-history',
label: this.t('harvester.action.createSchedule')
},
{ {
action: 'restoreVM', action: 'restoreVM',
enabled: !!this.actions?.restore, enabled: !!this.actions?.restore,
@ -323,6 +329,16 @@ export default class VirtVm extends HarvesterResource {
); );
} }
createSchedule(resources = this) {
const router = this.currentRouter();
router.push({
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-create`,
params: { resource: HCI.SCHEDULE_VM_BACKUP },
query: { vmNamespace: this.metadata.namespace, vmName: this.metadata.name }
});
}
backupVM(resources = this) { backupVM(resources = this) {
this.$dispatch('promptModal', { this.$dispatch('promptModal', {
resources, resources,

View File

@ -11,6 +11,7 @@ export const HCI = {
SETTING: 'harvesterhci.io.setting', SETTING: 'harvesterhci.io.setting',
UPGRADE: 'harvesterhci.io.upgrade', UPGRADE: 'harvesterhci.io.upgrade',
UPGRADE_LOG: 'harvesterhci.io.upgradelog', UPGRADE_LOG: 'harvesterhci.io.upgradelog',
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
BACKUP: 'harvesterhci.io.virtualmachinebackup', BACKUP: 'harvesterhci.io.virtualmachinebackup',
RESTORE: 'harvesterhci.io.virtualmachinerestore', RESTORE: 'harvesterhci.io.virtualmachinerestore',
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork', NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',

View File

@ -0,0 +1,11 @@
import cronstrue from 'cronstrue';
export function isCronValid(schedule = '') {
try {
const hint = cronstrue.toString(schedule);
return !!hint;
} catch (e) {
return false;
}
}

View File

@ -5960,10 +5960,10 @@ cron-validator@1.2.0:
resolved "https://registry.yarnpkg.com/cron-validator/-/cron-validator-1.2.0.tgz#952d2c926b85724dfe9c0d0ca781fe956124de93" resolved "https://registry.yarnpkg.com/cron-validator/-/cron-validator-1.2.0.tgz#952d2c926b85724dfe9c0d0ca781fe956124de93"
integrity sha512-fX9eq71ToAt4bJeJzFNe8OCljKNQdc2Otw4kZDfB3vyplrAyEO9Q20YgmCJ4pr+jI/QQ2yizM87Eh+b2Ty7GuQ== integrity sha512-fX9eq71ToAt4bJeJzFNe8OCljKNQdc2Otw4kZDfB3vyplrAyEO9Q20YgmCJ4pr+jI/QQ2yizM87Eh+b2Ty7GuQ==
cronstrue@1.95.0: cronstrue@1.95.0, cronstrue@2.50.0:
version "1.95.0" version "2.50.0"
resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-1.95.0.tgz#171df1fad8b0f0cb636354dd1d7842161c15478f" resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.50.0.tgz#eabba0f915f186765258b707b7a3950c663b5573"
integrity sha512-CdbQ17Z8Na2IdrK1SiD3zmXfE66KerQZ8/iApkGsxjmUVGJPS9M9oK4FZC3LM6ohUjjq3UeaSk+90Cf3QbXDfw== integrity sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg==
cross-env@6.0.3: cross-env@6.0.3:
version "6.0.3" version "6.0.3"