mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-10-01 03:36:04 +08:00
Add pkg/harvester components + shell portings - 1
Signed-off-by: Francesco Torchia <francesco.torchia@suse.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
export default {
|
||||
name: 'EditGenericAddon',
|
||||
components: {
|
||||
Tabbed,
|
||||
Tab,
|
||||
RadioGroup,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabbed :side-tabs="true">
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('generic.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<RadioGroup
|
||||
v-model="value.spec.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
export default {
|
||||
name: 'EditSeederAddon',
|
||||
components: {
|
||||
Tabbed,
|
||||
Tab,
|
||||
RadioGroup,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabbed :side-tabs="true">
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('harvester.addons.vmImport.titles.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<RadioGroup
|
||||
v-model="value.spec.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
|
||||
export default {
|
||||
name: 'EditAddon',
|
||||
|
||||
components: { CruResource },
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
currentComponent() {
|
||||
const name = this.value.metadata.name;
|
||||
|
||||
try {
|
||||
return require(`./${ name }.vue`).default;
|
||||
} catch {
|
||||
return require(`./generic.vue`).default;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
@finish="save"
|
||||
>
|
||||
<component
|
||||
:is="currentComponent"
|
||||
:value="value"
|
||||
:register-before-hook="registerBeforeHook"
|
||||
:mode="mode"
|
||||
/>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script>
|
||||
import merge from 'lodash/merge';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import { Banner } from '@components/Banner';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
const DEFAULT_VALUE = { image: { repo: 'rancher/harvester-nvidia-driver-toolkit' } };
|
||||
|
||||
export default {
|
||||
name: 'EditAddonNvidiaDriverToolkit',
|
||||
components: {
|
||||
Banner,
|
||||
LabeledInput,
|
||||
RadioGroup,
|
||||
Tabbed,
|
||||
Tab,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
initSpec: clone(this.value.spec),
|
||||
valuesContentJson: this.parseValuesContent()
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
valuesContentJson: {
|
||||
handler(neu) {
|
||||
this.update(neu);
|
||||
},
|
||||
deep: true,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
parsingSpecError() {
|
||||
return this.valuesContentJson && (this.valuesContentJson.image === undefined || this.valuesContentJson.driverLocation === undefined);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
parseValuesContent() {
|
||||
try {
|
||||
return merge({}, DEFAULT_VALUE, jsyaml.load(this.value.spec.valuesContent));
|
||||
} catch (err) {
|
||||
this.$store.dispatch('growl/fromError', {
|
||||
title: this.$store.getters['i18n/t']('generic.notification.title.error'),
|
||||
err: err.data || err,
|
||||
}, { root: true });
|
||||
|
||||
return DEFAULT_VALUE;
|
||||
}
|
||||
},
|
||||
|
||||
toggleEnable(v) {
|
||||
this.resetSpec();
|
||||
this.value.spec.enabled = v;
|
||||
},
|
||||
|
||||
resetSpec() {
|
||||
this.value.spec = clone(this.initSpec);
|
||||
|
||||
this.valuesContentJson = this.parseValuesContent();
|
||||
},
|
||||
|
||||
update(values) {
|
||||
this.value.spec.valuesContent = jsyaml.dump(values);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Banner
|
||||
v-if="parsingSpecError"
|
||||
color="error"
|
||||
:label="t('harvester.addons.nvidiaDriverToolkit.parsingSpecError', null, { raw: true })"
|
||||
/>
|
||||
<Tabbed v-else :side-tabs="true">
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('harvester.addons.nvidiaDriverToolkit.titles.basic')"
|
||||
:weight="1"
|
||||
>
|
||||
<RadioGroup
|
||||
:value="value.spec.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
@input="toggleEnable"
|
||||
/>
|
||||
<div v-if="value.spec.enabled">
|
||||
<div v-if="valuesContentJson.image" class="row mb-15">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.image.repo"
|
||||
:mode="mode"
|
||||
:required="true"
|
||||
label-key="harvester.addons.nvidiaDriverToolkit.image.repository"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.image.tag"
|
||||
:mode="mode"
|
||||
:required="true"
|
||||
class="col span-6"
|
||||
label-key="harvester.addons.nvidiaDriverToolkit.image.tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-15">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.driverLocation"
|
||||
:mode="mode"
|
||||
:required="true"
|
||||
label-key="harvester.addons.nvidiaDriverToolkit.driver.location"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
export default {
|
||||
name: 'EditAddonPCI',
|
||||
components: {
|
||||
Tabbed,
|
||||
Tab,
|
||||
RadioGroup,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabbed :side-tabs="true">
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('harvester.addons.vmImport.titles.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<RadioGroup
|
||||
v-model="value.spec.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script>
|
||||
import merge from 'lodash/merge';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import jsyaml from 'js-yaml';
|
||||
|
||||
const DEFAUL_VALUE = {
|
||||
fluentbit: {
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: '200m',
|
||||
memory: '200Mi'
|
||||
},
|
||||
requests: {
|
||||
cpu: '50m',
|
||||
memory: '50Mi'
|
||||
}
|
||||
}
|
||||
},
|
||||
fluentd: {
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: '1000m',
|
||||
memory: '800Mi'
|
||||
},
|
||||
requests: {
|
||||
cpu: '100m',
|
||||
memory: '20Mi'
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'EditAddonLogging',
|
||||
components: {
|
||||
Tabbed,
|
||||
Tab,
|
||||
RadioGroup,
|
||||
LabeledInput,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
let valuesContentJson = DEFAUL_VALUE;
|
||||
|
||||
try {
|
||||
valuesContentJson = merge({}, DEFAUL_VALUE, jsyaml.load(this.value.spec.valuesContent));
|
||||
} catch (err) {
|
||||
valuesContentJson = DEFAUL_VALUE;
|
||||
|
||||
this.$store.dispatch('growl/fromError', {
|
||||
title: this.$store.getters['i18n/t']('generic.notification.title.error'),
|
||||
err: err.data || err,
|
||||
}, { root: true });
|
||||
}
|
||||
|
||||
return { valuesContentJson };
|
||||
},
|
||||
|
||||
watch: {
|
||||
valuesContentJson: {
|
||||
handler(neu) {
|
||||
this.$set(this.value.spec, 'valuesContent', jsyaml.dump(neu));
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabbed :side-tabs="true">
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('harvester.addons.vmImport.titles.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<RadioGroup
|
||||
v-model="value.spec.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab v-if="value.spec.enabled" name="fluentbit" :label="t('harvester.logging.configuration.section.fluentbit')" :weight="-1">
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.fluentbit.resources.requests.cpu"
|
||||
:label="t('monitoring.prometheus.config.requests.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.fluentbit.resources.requests.memory"
|
||||
:label="t('monitoring.prometheus.config.requests.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.fluentbit.resources.limits.cpu"
|
||||
:label="t('monitoring.prometheus.config.limits.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.fluentbit.resources.limits.memory"
|
||||
:label="t('monitoring.prometheus.config.limits.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab v-if="value.spec.enabled" name="fluentd" :label="t('harvester.logging.configuration.section.fluentd')" :weight="-1">
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.fluentd.resources.requests.cpu"
|
||||
:label="t('monitoring.prometheus.config.requests.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.fluentd.resources.requests.memory"
|
||||
:label="t('monitoring.prometheus.config.requests.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.fluentd.resources.limits.cpu"
|
||||
:label="t('monitoring.prometheus.config.limits.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.fluentd.resources.limits.memory"
|
||||
:label="t('monitoring.prometheus.config.limits.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,523 @@
|
||||
<script>
|
||||
import merge from 'lodash/merge';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { findBy } from '@shell/utils/array';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import LazyImage from '@shell/components/LazyImage';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { ENDPOINTS } from '@shell/config/types';
|
||||
|
||||
const CATTLE_MONITORING_NAMESPACE = 'cattle-monitoring-system';
|
||||
const DEFAUL_VALUE = {
|
||||
prometheus: {
|
||||
prometheusSpec: {
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: '1000m',
|
||||
memory: '3000Mi'
|
||||
},
|
||||
requests: {
|
||||
cpu: '750m',
|
||||
memory: '750Mi'
|
||||
}
|
||||
},
|
||||
evaluationInterval: '1m',
|
||||
scrapeInterval: '1m',
|
||||
retention: '5d',
|
||||
retentionSize: '50GiB',
|
||||
},
|
||||
},
|
||||
'prometheus-node-exporter': {
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: '200m',
|
||||
memory: '180Mi'
|
||||
},
|
||||
requests: {
|
||||
cpu: '100m',
|
||||
memory: '30Mi'
|
||||
}
|
||||
}
|
||||
},
|
||||
grafana: {
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: '200m',
|
||||
memory: '500Mi'
|
||||
},
|
||||
requests: {
|
||||
cpu: '100m',
|
||||
memory: '200Mi'
|
||||
}
|
||||
}
|
||||
},
|
||||
alertmanager: {
|
||||
enabled: false,
|
||||
alertmanagerSpec: {
|
||||
retention: '120h',
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: '1000m',
|
||||
memory: '600Mi'
|
||||
},
|
||||
requests: {
|
||||
cpu: '100m',
|
||||
memory: '100Mi'
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'EditAddonMonitoring',
|
||||
components: {
|
||||
LabeledInput, RadioGroup, LazyImage, Tabbed, Tab
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
async fetch() {
|
||||
const { $store, externalLinks } = this;
|
||||
|
||||
if (!$store.getters['harvester/schemaFor'](ENDPOINTS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = await allHash({ endpoints: $store.dispatch('harvester/findAll', { type: ENDPOINTS }) });
|
||||
|
||||
if (!isEmpty(hash.endpoints)) {
|
||||
const amMatch = externalLinks.alertmanager;
|
||||
const grafanaMatch = externalLinks.grafana;
|
||||
const promeMatch = externalLinks.prometheus;
|
||||
const alertmanager = findBy(
|
||||
hash.endpoints,
|
||||
'id',
|
||||
`${ CATTLE_MONITORING_NAMESPACE }/rancher-monitoring-alertmanager`
|
||||
);
|
||||
const grafana = findBy(
|
||||
hash.endpoints,
|
||||
'id',
|
||||
`${ CATTLE_MONITORING_NAMESPACE }/rancher-monitoring-grafana`
|
||||
);
|
||||
const prometheus = findBy(
|
||||
hash.endpoints,
|
||||
'id',
|
||||
`${ CATTLE_MONITORING_NAMESPACE }/rancher-monitoring-prometheus`
|
||||
);
|
||||
|
||||
if (!isEmpty(alertmanager) && !isEmpty(alertmanager.subsets)) {
|
||||
amMatch.enabled = true;
|
||||
}
|
||||
if (!isEmpty(grafana) && !isEmpty(grafana.subsets)) {
|
||||
grafanaMatch.enabled = true;
|
||||
}
|
||||
if (!isEmpty(prometheus) && !isEmpty(prometheus.subsets)) {
|
||||
promeMatch.enabled = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
const grafanaSrc = require('~shell/assets/images/vendor/grafana.svg');
|
||||
const prometheusSrc = require('~shell/assets/images/vendor/prometheus.svg');
|
||||
const currentCluster = this.$store.getters['currentCluster'];
|
||||
let valuesContentJson = DEFAUL_VALUE;
|
||||
|
||||
try {
|
||||
valuesContentJson = merge({}, DEFAUL_VALUE, jsyaml.load(this.value.spec.valuesContent));
|
||||
} catch (err) {
|
||||
valuesContentJson = DEFAUL_VALUE;
|
||||
|
||||
this.$store.dispatch('growl/fromError', {
|
||||
title: this.$store.getters['i18n/t']('generic.notification.title.error'),
|
||||
err: err.data || err,
|
||||
}, { root: true });
|
||||
}
|
||||
|
||||
return {
|
||||
valuesContentJson,
|
||||
externalLinks: {
|
||||
alertmanager: {
|
||||
enabled: false,
|
||||
iconSrc: prometheusSrc,
|
||||
label: 'monitoring.overview.linkedList.alertManager.label',
|
||||
description:
|
||||
'monitoring.overview.linkedList.alertManager.description',
|
||||
link: `/k8s/clusters/${ currentCluster.id }/api/v1/namespaces/${ CATTLE_MONITORING_NAMESPACE }/services/http:rancher-monitoring-alertmanager:9093/proxy`,
|
||||
},
|
||||
grafana: {
|
||||
enabled: false,
|
||||
iconSrc: grafanaSrc,
|
||||
label: 'monitoring.overview.linkedList.grafana.label',
|
||||
description: 'monitoring.overview.linkedList.grafana.description',
|
||||
link: `/k8s/clusters/${ currentCluster.id }/api/v1/namespaces/${ CATTLE_MONITORING_NAMESPACE }/services/http:rancher-monitoring-grafana:80/proxy`,
|
||||
},
|
||||
prometheus: {
|
||||
enabled: false,
|
||||
iconSrc: prometheusSrc,
|
||||
label: 'monitoring.overview.linkedList.prometheusPromQl.label',
|
||||
description:
|
||||
'monitoring.overview.linkedList.prometheusPromQl.description',
|
||||
link: `/k8s/clusters/${ currentCluster.id }/api/v1/namespaces/${ CATTLE_MONITORING_NAMESPACE }/services/http:rancher-monitoring-prometheus:9090/proxy`,
|
||||
},
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
prometheusNodeExporter() {
|
||||
return this.valuesContentJson['prometheus-node-exporter'];
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
valuesContentJson: {
|
||||
handler(neu) {
|
||||
this.$set(this.value.spec, 'valuesContent', jsyaml.dump(neu));
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabbed :side-tabs="true">
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('harvester.addons.vmImport.titles.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<RadioGroup
|
||||
v-model="value.spec.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab v-if="value.spec.enabled" name="prometheus" :label="t('harvester.setting.harvesterMonitoring.section.prometheus')" :weight="-1">
|
||||
<a
|
||||
v-clean-tooltip="!externalLinks.prometheus.enabled ? t('monitoring.overview.linkedList.na') : undefined"
|
||||
:disabled="!externalLinks.prometheus.enabled"
|
||||
:href="externalLinks.prometheus.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="subtype-banner m-0 mt-10 mb-10"
|
||||
>
|
||||
<div class="subtype-content">
|
||||
<div class="title">
|
||||
<div class="subtype-logo round-image">
|
||||
<LazyImage :src="externalLinks.prometheus.iconSrc" />
|
||||
</div>
|
||||
<h5>
|
||||
<span>
|
||||
<t :k="externalLinks.prometheus.label" />
|
||||
</span>
|
||||
</h5>
|
||||
<div class="flex-right">
|
||||
<i class="icon icon-external-link mr-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.prometheus.prometheusSpec.scrapeInterval"
|
||||
:label="t('monitoring.prometheus.config.scrape')"
|
||||
:tooltip="t('harvester.setting.harvesterMonitoring.tips.scrape')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.prometheus.prometheusSpec.evaluationInterval"
|
||||
:label="t('monitoring.prometheus.config.evaluation')"
|
||||
:tooltip="t('harvester.setting.harvesterMonitoring.tips.evaluation')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.prometheus.prometheusSpec.retention"
|
||||
:label="t('monitoring.prometheus.config.retention')"
|
||||
:tooltip="t('harvester.setting.harvesterMonitoring.tips.retention')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.prometheus.prometheusSpec.retentionSize"
|
||||
:label="t('monitoring.prometheus.config.retentionSize')"
|
||||
:tooltip="t('harvester.setting.harvesterMonitoring.tips.retentionSize')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-12 mt-5">
|
||||
<h4 class="mb-0">
|
||||
{{ t('monitoring.prometheus.config.resourceLimits') }}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.prometheus.prometheusSpec.resources.requests.cpu"
|
||||
:label="t('monitoring.prometheus.config.requests.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.prometheus.prometheusSpec.resources.requests.memory"
|
||||
:label="t('monitoring.prometheus.config.requests.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.prometheus.prometheusSpec.resources.limits.cpu"
|
||||
:label="t('monitoring.prometheus.config.limits.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.prometheus.prometheusSpec.resources.limits.memory"
|
||||
:label="t('monitoring.prometheus.config.limits.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab v-if="value.spec.enabled" name="nodeExporter" :label="t('harvester.setting.harvesterMonitoring.section.prometheusNodeExporter')" :weight="-2">
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="prometheusNodeExporter.resources.limits.cpu"
|
||||
:label="t('monitoring.prometheus.config.limits.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="prometheusNodeExporter.resources.limits.memory"
|
||||
:label="t('monitoring.prometheus.config.limits.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="prometheusNodeExporter.resources.requests.cpu"
|
||||
:label="t('monitoring.prometheus.config.requests.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="prometheusNodeExporter.resources.requests.memory"
|
||||
:label="t('monitoring.prometheus.config.requests.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab v-if="value.spec.enabled && valuesContentJson.grafana.resources" name="grafana" :label="t('harvester.setting.harvesterMonitoring.section.grafana')" :weight="-3">
|
||||
<a
|
||||
v-clean-tooltip="!externalLinks.grafana.enabled ? t('monitoring.overview.linkedList.na') : undefined"
|
||||
:disabled="!externalLinks.grafana.enabled"
|
||||
:href="externalLinks.grafana.link"
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
class="subtype-banner m-0 mt-10 mb-10"
|
||||
>
|
||||
<div class="subtype-content">
|
||||
<div class="title">
|
||||
<div class="subtype-logo round-image">
|
||||
<LazyImage :src="externalLinks.grafana.iconSrc" />
|
||||
</div>
|
||||
<h5>
|
||||
<span>
|
||||
<t :k="externalLinks.grafana.label" />
|
||||
</span>
|
||||
</h5>
|
||||
<div class="flex-right">
|
||||
<i class="icon icon-external-link mr-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.grafana.resources.requests.cpu"
|
||||
:label="t('monitoring.prometheus.config.requests.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.grafana.resources.requests.memory"
|
||||
:label="t('monitoring.prometheus.config.requests.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.grafana.resources.limits.cpu"
|
||||
:label="t('monitoring.prometheus.config.limits.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.grafana.resources.limits.memory"
|
||||
:label="t('monitoring.prometheus.config.limits.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab v-if="value.spec.enabled" name="alertmanager" :label="t('harvester.setting.harvesterMonitoring.section.alertmanager')" :weight="-4">
|
||||
<RadioGroup
|
||||
v-model="valuesContentJson.alertmanager.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
/>
|
||||
|
||||
<a
|
||||
v-if="valuesContentJson.alertmanager.enabled"
|
||||
v-clean-tooltip="!externalLinks.alertmanager.enabled ? t('monitoring.overview.linkedList.na') : undefined"
|
||||
:disabled="!externalLinks.alertmanager.enabled"
|
||||
:href="externalLinks.alertmanager.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="subtype-banner m-0 mt-10 mb-10"
|
||||
>
|
||||
<div class="subtype-content">
|
||||
<div class="title">
|
||||
<div class="subtype-logo round-image">
|
||||
<LazyImage :src="externalLinks.alertmanager.iconSrc" />
|
||||
</div>
|
||||
<h5>
|
||||
<span>
|
||||
<t :k="externalLinks.alertmanager.label" />
|
||||
</span>
|
||||
</h5>
|
||||
<div class="flex-right">
|
||||
<i class="icon icon-external-link mr-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div v-if="valuesContentJson.alertmanager.enabled">
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.alertmanager.alertmanagerSpec.retention"
|
||||
:label="t('monitoring.prometheus.config.retention')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.alertmanager.alertmanagerSpec.resources.limits.cpu"
|
||||
:label="t('monitoring.prometheus.config.limits.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.alertmanager.alertmanagerSpec.resources.limits.memory"
|
||||
:label="t('monitoring.prometheus.config.limits.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.alertmanager.alertmanagerSpec.resources.requests.cpu"
|
||||
:label="t('monitoring.prometheus.config.requests.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.alertmanager.alertmanagerSpec.resources.requests.memory"
|
||||
:label="t('monitoring.prometheus.config.requests.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script>
|
||||
import merge from 'lodash/merge';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
|
||||
const DEFAUL_VALUE = {
|
||||
hostname: '',
|
||||
rancherVersion: '',
|
||||
bootstrapPassword: '',
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'EditAddonVcluster',
|
||||
components: { LabeledInput, RadioGroup },
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
registerBeforeHook: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
let valuesContentJson = {};
|
||||
|
||||
try {
|
||||
valuesContentJson = merge({}, DEFAUL_VALUE, jsyaml.load(this.value.spec.valuesContent));
|
||||
} catch (err) {
|
||||
valuesContentJson = DEFAUL_VALUE;
|
||||
|
||||
this.$store.dispatch('growl/fromError', {
|
||||
title: this.$store.getters['i18n/t']('generic.notification.title.error'),
|
||||
err: err.data || err,
|
||||
}, { root: true });
|
||||
}
|
||||
|
||||
return { valuesContentJson };
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.willSave, 'willSave');
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
willSave() {
|
||||
const errors = [];
|
||||
|
||||
if (!this.value.spec.enabled) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (!this.valuesContentJson.hostname) {
|
||||
errors.push(this.t('validation.required', { key: this.t('harvester.addons.rancherVcluster.hostname') }, true));
|
||||
}
|
||||
|
||||
if (!this.valuesContentJson.bootstrapPassword) {
|
||||
errors.push(this.t('validation.required', { key: this.t('harvester.addons.rancherVcluster.password') }, true));
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Promise.reject(errors);
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
valuesContentJson: {
|
||||
handler(neu) {
|
||||
this.$set(this.value.spec, 'valuesContent', jsyaml.dump(neu));
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<RadioGroup
|
||||
v-model="value.spec.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="value.spec.enabled">
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.hostname"
|
||||
label-key="harvester.addons.rancherVcluster.hostname"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
placeholder="rancher.$vip.nip.io"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.rancherVersion"
|
||||
label-key="harvester.addons.rancherVcluster.rancherVersion"
|
||||
:required="true"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContentJson.bootstrapPassword"
|
||||
label-key="harvester.addons.rancherVcluster.password"
|
||||
:mode="mode"
|
||||
:required="true"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script>
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
import { STORAGE_CLASS } from '@shell/config/types';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { set, get, clone } from '@shell/utils/object';
|
||||
|
||||
const VALUES_YAML_KEYS = [
|
||||
'resources.requests.cpu',
|
||||
'resources.requests.memory',
|
||||
'resources.limits.cpu',
|
||||
'resources.limits.memory',
|
||||
'pvcClaim.enabled',
|
||||
'pvcClaim.size',
|
||||
'pvcClaim.storageClassName',
|
||||
];
|
||||
|
||||
const DEFAULT_VALUES = {
|
||||
'resources.requests.cpu': '0.5',
|
||||
'resources.requests.memory': '2Gi',
|
||||
'resources.limits.cpu': '2',
|
||||
'resources.limits.memory': '4Gi',
|
||||
'pvcClaim.enabled': false,
|
||||
'pvcClaim.size': '200Gi',
|
||||
'pvcClaim.storageClassName': '',
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'EditHarvesterLogging',
|
||||
components: {
|
||||
LabeledInput,
|
||||
Tabbed,
|
||||
Tab,
|
||||
RadioGroup,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const hash = { storages: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }) };
|
||||
|
||||
await allHash(hash);
|
||||
},
|
||||
|
||||
data() {
|
||||
let valuesObj = {};
|
||||
|
||||
try {
|
||||
valuesObj = JSON.parse(this.value?.spec?.valuesContent || '{}');
|
||||
} catch (err) {}
|
||||
|
||||
const valuesContent = clone(valuesObj);
|
||||
|
||||
VALUES_YAML_KEYS.map((key) => {
|
||||
if (!get(valuesObj, key)) {
|
||||
set(valuesContent, key, DEFAULT_VALUES[key]);
|
||||
}
|
||||
});
|
||||
|
||||
return { valuesContent };
|
||||
},
|
||||
|
||||
computed: {
|
||||
storageClassOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const storages = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS);
|
||||
|
||||
const out = storages.filter(s => !s.parameters?.backingImage).map((s) => {
|
||||
const label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
|
||||
|
||||
return {
|
||||
label,
|
||||
value: s.name,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
set(this.value, 'spec.valuesContent', JSON.stringify(this.valuesContent));
|
||||
},
|
||||
|
||||
setDefaultClassName() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const defaultStorage = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS).find( s => s.isDefault);
|
||||
|
||||
this.$set(this.valuesContent.pvcClaim, 'storageClassName', this.valuesContent?.pvcClaim?.storageClassName || defaultStorage?.metadata?.name || 'longhorn');
|
||||
|
||||
this.update();
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'valuesContent.pvcClaim.enabled'(value) {
|
||||
if (value) {
|
||||
this.setDefaultClassName();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabbed :side-tabs="true">
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('harvester.addons.vmImport.titles.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<RadioGroup
|
||||
v-model="value.spec.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
/>
|
||||
|
||||
<div v-if="value.spec.enabled">
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContent.resources.limits.cpu"
|
||||
:label="t('monitoring.prometheus.config.limits.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContent.resources.limits.memory"
|
||||
:label="t('monitoring.prometheus.config.limits.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContent.resources.requests.cpu"
|
||||
:label="t('monitoring.prometheus.config.requests.cpu')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContent.resources.requests.memory"
|
||||
:label="t('monitoring.prometheus.config.requests.memory')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<h2>{{ t('harvester.addons.vmImport.titles.pvc') }}</h2>
|
||||
<div v-if="value.spec.enabled">
|
||||
<RadioGroup
|
||||
v-model="valuesContent.pvcClaim.enabled"
|
||||
class="mb-20"
|
||||
name="model"
|
||||
:mode="mode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
@input="update"
|
||||
/>
|
||||
|
||||
<div v-if="valuesContent.pvcClaim.enabled">
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="valuesContent.pvcClaim.size"
|
||||
:label="t('harvester.volume.size')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="valuesContent.pvcClaim.storageClassName"
|
||||
:options="storageClassOptions"
|
||||
:label="t('harvester.storage.storageClass.label')"
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script>
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import YamlEditor from '@shell/components/YamlEditor';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { HCI } from '@pkg/harvester/config/labels-annotations';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditCloudTemplate',
|
||||
|
||||
components: {
|
||||
Tab,
|
||||
Tabbed,
|
||||
YamlEditor,
|
||||
CruResource,
|
||||
LabeledSelect,
|
||||
NameNsDescription,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
data() {
|
||||
return {
|
||||
config: this.value.data?.cloudInit || '',
|
||||
type: this.value?.metadata?.labels?.[HCI.CLOUD_INIT] || 'user',
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
types() {
|
||||
return [{
|
||||
label: 'User Data',
|
||||
value: 'user'
|
||||
}, {
|
||||
label: 'Network Data',
|
||||
value: 'network'
|
||||
}];
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.value.data = { cloudInit: this.config };
|
||||
},
|
||||
|
||||
updateBeforeSave() {
|
||||
if (this.isCreate) {
|
||||
this.value.metadata.labels = {
|
||||
...this.value.metadata.labels,
|
||||
[HCI.CLOUD_INIT]: this.type,
|
||||
};
|
||||
|
||||
this.value.data = { cloudInit: this.config };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:mode="mode"
|
||||
:resource="value"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
@cancel="done"
|
||||
>
|
||||
<NameNsDescription
|
||||
v-model="value"
|
||||
:mode="mode"
|
||||
:namespaced="true"
|
||||
/>
|
||||
|
||||
<Tabbed :side-tabs="true">
|
||||
<Tab name="basics" :label="t('harvester.host.tabs.basics')" :weight="1">
|
||||
<div class="mb-20">
|
||||
<LabeledSelect
|
||||
v-model="type"
|
||||
:label="t('harvester.cloudTemplate.templateType')"
|
||||
:disabled="!isCreate"
|
||||
:options="types"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="resource-yaml">
|
||||
<YamlEditor
|
||||
ref="yamlUser"
|
||||
v-model="config"
|
||||
class="yaml-editor"
|
||||
:editor-mode="mode === 'view' ? 'VIEW_CODE' : 'EDIT_CODE'"
|
||||
@onChanges="update"
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$yaml-height: 200px;
|
||||
|
||||
::v-deep .yaml-editor{
|
||||
flex: 1;
|
||||
min-height: $yaml-height;
|
||||
& .code-mirror .CodeMirror {
|
||||
position: initial;
|
||||
height: auto;
|
||||
min-height: $yaml-height;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<script>
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabelValue from '@shell/components/LabelValue';
|
||||
import { BadgeState } from '@components/BadgeState';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { RadioGroup, RadioButton } from '@components/Form/Radio';
|
||||
import HarvesterDisk from '../../mixins/harvester-disk';
|
||||
import Tags from '../../components/DiskTags';
|
||||
import { HCI } from '../../types';
|
||||
import { LONGHORN_SYSTEM } from './index';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LabeledInput,
|
||||
LabelValue,
|
||||
BadgeState,
|
||||
Banner,
|
||||
RadioGroup,
|
||||
RadioButton,
|
||||
Tags,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
HarvesterDisk,
|
||||
],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
disks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'edit',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
allowSchedulingOptions() {
|
||||
return [{
|
||||
label: this.t('generic.enabled'),
|
||||
value: true,
|
||||
}, {
|
||||
label: this.t('generic.disabled'),
|
||||
value: false,
|
||||
}];
|
||||
},
|
||||
|
||||
evictionRequestedOptions() {
|
||||
return [{
|
||||
label: this.t('generic.yes'),
|
||||
value: true,
|
||||
}, {
|
||||
label: this.t('generic.no'),
|
||||
value: false,
|
||||
}];
|
||||
},
|
||||
|
||||
mountedMessage() {
|
||||
const state = this.blockDevice?.metadata?.state || {};
|
||||
|
||||
if (state?.error) {
|
||||
return state?.message;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
isProvisioned() {
|
||||
return this.blockDevice?.spec.fileSystem.provisioned;
|
||||
},
|
||||
|
||||
forceFormattedDisabled() {
|
||||
const lastFormattedAt = this.blockDevice?.status?.deviceStatus?.fileSystem?.LastFormattedAt;
|
||||
const fileSystem = this.blockDevice?.status?.deviceStatus?.fileSystem.type;
|
||||
|
||||
const systems = ['ext4', 'XFS'];
|
||||
|
||||
if (lastFormattedAt || this.blockDevice?.childParts?.length > 0) {
|
||||
return true;
|
||||
} else if (systems.includes(fileSystem)) {
|
||||
return false;
|
||||
} else if (!fileSystem) {
|
||||
return true;
|
||||
} else {
|
||||
return !this.canEditPath;
|
||||
}
|
||||
},
|
||||
|
||||
canEditPath() {
|
||||
if (this.mountedMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.value.isNew && !this.value.originPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
isFormatted() {
|
||||
return !!this.blockDevice?.status?.deviceStatus?.fileSystem?.LastFormattedAt;
|
||||
},
|
||||
|
||||
formattedBannerLabel() {
|
||||
const system = this.blockDevice?.status?.deviceStatus?.fileSystem?.type;
|
||||
|
||||
const label = this.t('harvester.host.disk.lastFormattedAt.info');
|
||||
|
||||
if (system) {
|
||||
return `${ label } ${ this.t('harvester.host.disk.fileSystem.info', { system }) }`;
|
||||
} else {
|
||||
return label;
|
||||
}
|
||||
},
|
||||
|
||||
provisionPhase() {
|
||||
return this.blockDevice?.provisionPhase || {};
|
||||
},
|
||||
|
||||
blockDevice() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const name = this.value?.name;
|
||||
|
||||
return this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ name }`) || {};
|
||||
},
|
||||
|
||||
isCorrupted() {
|
||||
return this.blockDevice?.status?.deviceStatus?.fileSystem?.corrupted;
|
||||
},
|
||||
|
||||
isFormatting() {
|
||||
return this.blockDevice.isFormatting;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('input', this.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="disk" @input="update">
|
||||
<div class="mt-10" />
|
||||
<Banner
|
||||
v-if="mountedMessage && isProvisioned"
|
||||
color="error"
|
||||
:label="mountedMessage"
|
||||
/>
|
||||
<Banner
|
||||
v-if="isFormatting"
|
||||
color="info"
|
||||
:label="t('harvester.host.disk.fileSystem.formatting')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="isFormatted && !isCorrupted"
|
||||
color="info"
|
||||
:label="formattedBannerLabel"
|
||||
/>
|
||||
<div v-if="!value.isNew">
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<Tags
|
||||
v-model="value.tags"
|
||||
:label="t('harvester.host.disk.tags.label')"
|
||||
:add-label="t('harvester.host.disk.tags.addLabel')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-12">
|
||||
<div class="pull-right">
|
||||
{{ t('harvester.host.disk.conditions') }}:
|
||||
<BadgeState
|
||||
v-clean-tooltip="readyCondition.message"
|
||||
:color="readyCondition.status === 'True' ? 'bg-success' : 'bg-error' "
|
||||
:icon="readyCondition.status === 'True' ? 'icon-checkmark' : 'icon-warning' "
|
||||
label="Ready"
|
||||
class="mr-10 ml-10 state"
|
||||
/>
|
||||
<BadgeState
|
||||
v-clean-tooltip="schedulableCondition.message"
|
||||
:color="schedulableCondition.status === 'True' ? 'bg-success' : 'bg-error' "
|
||||
:icon="schedulableCondition.status === 'True' ? 'icon-checkmark' : 'icon-warning' "
|
||||
label="Schedulable"
|
||||
class="mr-10 state"
|
||||
/>
|
||||
<BadgeState
|
||||
v-if="provisionPhase.label"
|
||||
:color="provisionPhase.color"
|
||||
:icon="provisionPhase.icon"
|
||||
:label="provisionPhase.label"
|
||||
class="mr-10 state"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!value.isNew" class="row mt-30">
|
||||
<div class="col flex span-12">
|
||||
<LabelValue
|
||||
:name="t('harvester.host.disk.storageAvailable.label')"
|
||||
:value="value.storageAvailable"
|
||||
/>
|
||||
<LabelValue
|
||||
:name="t('harvester.host.disk.storageScheduled.label')"
|
||||
:value="value.storageScheduled"
|
||||
/>
|
||||
<LabelValue
|
||||
:name="t('harvester.host.disk.storageMaximum.label')"
|
||||
:value="value.storageMaximum"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="mt-10" />
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-model="value.displayName"
|
||||
:label="t('generic.name')"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(value.isNew && !isFormatted) || isCorrupted" class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model="value.forceFormatted"
|
||||
:mode="mode"
|
||||
name="forceFormatted"
|
||||
label-key="harvester.host.disk.forceFormatted.label"
|
||||
:labels="[t('generic.no'),t('harvester.host.disk.forceFormatted.yes')]"
|
||||
:options="[false, true]"
|
||||
:disabled="forceFormattedDisabled"
|
||||
tooltip-key="harvester.host.disk.forceFormatted.toolTip"
|
||||
>
|
||||
<template #1="{option, listeners}">
|
||||
<RadioButton
|
||||
:label="option.label"
|
||||
:val="option.value"
|
||||
:value="value.forceFormatted"
|
||||
:disabled="forceFormattedDisabled && !value.forceFormatted"
|
||||
v-on="listeners"
|
||||
/>
|
||||
</template>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.close {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding:0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.disk {
|
||||
position: relative;
|
||||
|
||||
.secret-name {
|
||||
height: $input-height;
|
||||
}
|
||||
|
||||
&:not(:last-of-type) {
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.badge-state {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script>
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import { HCI } from '../../types';
|
||||
|
||||
export const ksmtunedMode = [{
|
||||
value: 'standard',
|
||||
label: 'Standard'
|
||||
}, {
|
||||
value: 'high',
|
||||
label: 'High-Perfomanace'
|
||||
}, {
|
||||
value: 'customized',
|
||||
label: 'Customized'
|
||||
}];
|
||||
|
||||
export const ksmtunedRunOption = [{
|
||||
label: 'Run',
|
||||
value: 'run'
|
||||
}, {
|
||||
label: 'Stop',
|
||||
value: 'stop'
|
||||
}, {
|
||||
label: 'Prune',
|
||||
value: 'prune'
|
||||
}];
|
||||
|
||||
export default {
|
||||
name: 'HarvesterKsmtuned',
|
||||
components: {
|
||||
Checkbox, LabeledInput, LabeledSelect, RadioGroup, UnitInput
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
registerBeforeHook: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const hash = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.KSTUNED });
|
||||
|
||||
this.ksmtuned = hash.find((node) => {
|
||||
return node.id === this.node.id;
|
||||
});
|
||||
|
||||
this.enableMergeAcrossNodes = !!this.ksmtuned.spec?.mergeAcrossNodes;
|
||||
this.spec = this.ksmtuned.spec;
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
ksmtuned: {},
|
||||
spec: {},
|
||||
thresCoef: 30,
|
||||
ksmtunedMode,
|
||||
ksmtunedRunOption,
|
||||
enableMergeAcrossNodes: true
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.registerBeforeHook(this.saveKsmtuned, 'saveKsmtuned');
|
||||
},
|
||||
|
||||
computed: {
|
||||
isCustomizedMode() {
|
||||
return this.spec.mode === 'customized';
|
||||
},
|
||||
|
||||
showKsmt() {
|
||||
return this.spec.run === 'run';
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveKsmtuned() {
|
||||
this.spec.mergeAcrossNodes = this.enableMergeAcrossNodes ? 1 : 0;
|
||||
this.$set(this.ksmtuned, 'spec', this.spec);
|
||||
|
||||
await this.ksmtuned.save().catch((reason) => {
|
||||
if (reason?.type === 'error') {
|
||||
this.$store.dispatch('growl/error', {
|
||||
title: this.t('harvester.notification.title.error'),
|
||||
message: reason?.message
|
||||
}, { root: true });
|
||||
|
||||
return Promise.reject(new Error('saveKsmtuned error'));
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LabeledSelect
|
||||
v-model="spec.run"
|
||||
:label="t('harvester.host.ksmtuned.run')"
|
||||
:options="ksmtunedRunOption"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
|
||||
<template v-if="showKsmt">
|
||||
<UnitInput
|
||||
v-model="spec.thresCoef"
|
||||
v-int-number
|
||||
:label="t('harvester.host.ksmtuned.thresCoef')"
|
||||
suffix="%"
|
||||
:delay="0"
|
||||
required
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
/>
|
||||
|
||||
<Checkbox v-model="enableMergeAcrossNodes" :mode="mode" class="check mb-20" type="checkbox" :label="t('harvester.host.ksmtuned.enableMergeNodes')" />
|
||||
|
||||
<h3>
|
||||
<t k="harvester.host.ksmtuned.modeLink" :raw="true" />
|
||||
</h3>
|
||||
<RadioGroup
|
||||
v-model="spec.mode"
|
||||
class="mb-20"
|
||||
:name="t('harvester.host.ksmtuned.mode')"
|
||||
:options="ksmtunedMode"
|
||||
/>
|
||||
|
||||
<template v-if="isCustomizedMode">
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="spec.ksmtunedParameters.boost"
|
||||
required
|
||||
type="number"
|
||||
:label="t('harvester.host.ksmtuned.parameters.boost')"
|
||||
:tooltip="t('harvester.host.ksmtuned.parameters.description.boost')"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="spec.ksmtunedParameters.decay"
|
||||
required
|
||||
type="number"
|
||||
:label="t('harvester.host.ksmtuned.parameters.decay')"
|
||||
:tooltip="t('harvester.host.ksmtuned.parameters.description.decay')"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="spec.ksmtunedParameters.minPages"
|
||||
required
|
||||
type="number"
|
||||
:label="t('harvester.host.ksmtuned.parameters.minPages')"
|
||||
:tooltip="t('harvester.host.ksmtuned.parameters.description.minPages')"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="spec.ksmtunedParameters.maxPages"
|
||||
required
|
||||
type="number"
|
||||
:label="t('harvester.host.ksmtuned.parameters.maxPages')"
|
||||
:tooltip="t('harvester.host.ksmtuned.parameters.description.maxPages')"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="spec.ksmtunedParameters.sleepMsec"
|
||||
required
|
||||
type="number"
|
||||
:label="t('harvester.host.ksmtuned.parameters.sleepMsec')"
|
||||
:tooltip="t('harvester.host.ksmtuned.parameters.description.sleepMsec')"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
<script>
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import { SECRET } from '@shell/config/types';
|
||||
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import { Banner } from '@components/Banner';
|
||||
|
||||
import { base64Encode, base64Decode } from '@shell/utils/crypto';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
|
||||
const _NEW = '_NEW';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterSeeder',
|
||||
|
||||
components: {
|
||||
Checkbox,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
RadioGroup,
|
||||
ModalWithCard,
|
||||
NameNsDescription,
|
||||
Banner,
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
registerAfterHook: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
|
||||
inventory: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
const enableInventory = !!this.inventory?.id;
|
||||
|
||||
return {
|
||||
enableInventory,
|
||||
value: this.inventory,
|
||||
secret: {},
|
||||
errors: [],
|
||||
newSecretSelected: false,
|
||||
isOpen: false,
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.registerAfterHook(this.saveInventory, 'saveInventory');
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
this.secret = await this.$store.dispatch(`${ inStore }/create`, {
|
||||
type: SECRET,
|
||||
data: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
metadata: {
|
||||
namespace: '',
|
||||
name: '',
|
||||
describe: '',
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
computed: {
|
||||
secretOption() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const out = this.$store.getters[`${ inStore }/all`](SECRET).filter((s) => {
|
||||
return s.data?.username && s.data?.password;
|
||||
}).map( (s) => {
|
||||
return {
|
||||
label: s.id,
|
||||
value: s.id
|
||||
};
|
||||
});
|
||||
|
||||
// if (!(this.disableCreate || this.mode === _VIEW) && this.isCreatable) {
|
||||
out.unshift({
|
||||
label: this.t('harvester.virtualMachine.createSSHKey'),
|
||||
value: _NEW,
|
||||
});
|
||||
// }
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
selectedSecret: {
|
||||
get() {
|
||||
const namespace = this.value.spec?.baseboardSpec?.connection?.authSecretRef?.namespace;
|
||||
const name = this.value?.spec?.baseboardSpec?.connection?.authSecretRef?.name;
|
||||
|
||||
if (namespace && name) {
|
||||
return `${ namespace }/${ name }`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
set(value) {
|
||||
if (value === _NEW) {
|
||||
this.newSecretSelected = true;
|
||||
} else {
|
||||
const [namespace, name] = value.split('/');
|
||||
|
||||
this.$set(this.value.spec.baseboardSpec.connection.authSecretRef, 'namespace', namespace);
|
||||
this.$set(this.value.spec.baseboardSpec.connection.authSecretRef, 'name', name);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
username: {
|
||||
get() {
|
||||
return base64Decode(this.secret?.data?.username);
|
||||
},
|
||||
|
||||
set(value) {
|
||||
this.$set(this.secret.data, 'username', base64Encode(value));
|
||||
}
|
||||
},
|
||||
|
||||
password: {
|
||||
get() {
|
||||
return base64Decode(this.secret?.data?.password);
|
||||
},
|
||||
|
||||
set(value) {
|
||||
this.$set(this.secret.data, 'password', base64Encode(value));
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveInventory() {
|
||||
if (this.enableInventory) {
|
||||
const errors = [];
|
||||
|
||||
if (!this.value.spec.baseboardSpec.connection.host) {
|
||||
errors.push(this.t('validation.required', { key: this.t('harvester.seeder.inventory.host.label') }, true));
|
||||
}
|
||||
|
||||
if (!this.value.spec.baseboardSpec.connection.port) {
|
||||
errors.push(this.t('validation.required', { key: this.t('harvester.seeder.inventory.port.label') }, true));
|
||||
}
|
||||
|
||||
if (!this.selectedSecret) {
|
||||
errors.push(this.t('validation.required', { key: this.t('harvester.seeder.inventory.secret.label') }, true));
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Promise.reject(exceptionToErrorsArray(errors));
|
||||
}
|
||||
|
||||
if (!this.value.id) {
|
||||
this.value.metadata.annotations['metal.harvesterhci.io/local-node-name'] = this.node.id;
|
||||
}
|
||||
|
||||
this.value.metadata.annotations['metal.harvesterhci.io/local-inventory'] = 'true';
|
||||
|
||||
return await this.value.save();
|
||||
} else if (this.value.id) {
|
||||
return await this.value.remove();
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
|
||||
show() {
|
||||
this.isOpen = true;
|
||||
},
|
||||
|
||||
hide() {
|
||||
this.isOpen = false;
|
||||
this.newSecretSelected = false;
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.hide();
|
||||
},
|
||||
|
||||
async saveSecret(buttonCb) {
|
||||
this.errors = [];
|
||||
|
||||
if (!this.username) {
|
||||
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.username') }, true));
|
||||
}
|
||||
|
||||
if (!this.password) {
|
||||
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.password') }, true));
|
||||
}
|
||||
|
||||
if (this.errors.length > 0) {
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await this.secret.save();
|
||||
|
||||
if (res.id) {
|
||||
this.secretOption.push({
|
||||
label: res.id,
|
||||
value: res.id
|
||||
});
|
||||
}
|
||||
|
||||
this.selectedSecret = res.id;
|
||||
|
||||
buttonCb(true);
|
||||
this.cancel();
|
||||
} catch (err) {
|
||||
this.errors = [err.message];
|
||||
buttonCb(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
newSecretSelected(val) {
|
||||
if (val) {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="inventory.warningMessages.length > 0">
|
||||
<Banner
|
||||
v-for="msg in inventory.warningMessages"
|
||||
:key="msg.text"
|
||||
color="error"
|
||||
:label="msg.text"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model="enableInventory"
|
||||
:options="[
|
||||
{ label: t('generic.enabled'), value: true },
|
||||
{ label: t('generic.disabled'), value: false }
|
||||
]"
|
||||
:mode="mode"
|
||||
name="enableInventory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="enableInventory">
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="value.spec.baseboardSpec.connection.host"
|
||||
:label="t('harvester.seeder.inventory.host.label')"
|
||||
:placeholder="t('harvester.seeder.inventory.host.placeholder')"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="value.spec.baseboardSpec.connection.insecureTLS"
|
||||
class="mt-5"
|
||||
:mode="mode"
|
||||
:label="t('harvester.seeder.inventory.insecureTLS.label')"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="value.spec.baseboardSpec.connection.port"
|
||||
:label="t('harvester.seeder.inventory.port.label')"
|
||||
:placeholder="t('harvester.seeder.inventory.port.placeholder')"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="selectedSecret"
|
||||
:label="t('harvester.seeder.inventory.secret.label')"
|
||||
:mode="mode"
|
||||
:options="secretOption"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model="value.spec.events.enabled"
|
||||
name="enabled"
|
||||
:options="[true, false]"
|
||||
:label="t('harvester.seeder.inventory.event.label')"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="value.spec.events.enabled"
|
||||
class="col span-6"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model="value.spec.events.pollingInterval"
|
||||
:label="t('harvester.seeder.inventory.pollingInterval.label')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalWithCard
|
||||
v-if="isOpen"
|
||||
width="80%"
|
||||
:errors="errors"
|
||||
name="secretModal"
|
||||
@finish="saveSecret"
|
||||
@close="cancel"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('harvester.seeder.inventory.secret.create.title') }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<NameNsDescription
|
||||
v-model="secret"
|
||||
:namespaced="true"
|
||||
mode="create"
|
||||
/>
|
||||
|
||||
<LabeledInput
|
||||
v-model="username"
|
||||
:label="t('harvester.virtualMachine.input.username')"
|
||||
class="mb-20"
|
||||
required
|
||||
/>
|
||||
|
||||
<LabeledInput
|
||||
v-model="password"
|
||||
type="password"
|
||||
:label="t('harvester.virtualMachine.input.password')"
|
||||
class="mb-20"
|
||||
required
|
||||
/>
|
||||
</template>
|
||||
</ModalWithCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,688 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import Footer from '@shell/components/form/Footer';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import ArrayListGrouped from '@shell/components/form/ArrayListGrouped';
|
||||
import ButtonDropdown from '@shell/components/ButtonDropdown';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { HCI as HCI_LABELS_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { LONGHORN, SECRET } from '@shell/config/types';
|
||||
import { HCI } from '../../types';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { formatSi } from '@shell/utils/units';
|
||||
import { findBy } from '@shell/utils/array';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import KeyValue from '@shell/components/form/KeyValue';
|
||||
import Loading from '@shell/components/Loading.vue';
|
||||
import MessageLink from '@shell/components/MessageLink';
|
||||
import { ADD_ONS } from '@pkg/harvester/config/harvester-map';
|
||||
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '@pkg/harvester/config/harvester';
|
||||
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { Banner } from '@components/Banner';
|
||||
import HarvesterDisk from './HarvesterDisk';
|
||||
import HarvesterKsmtuned from './HarvesterKsmtuned';
|
||||
import HarvesterSeeder from './HarvesterSeeder';
|
||||
import Tags from '../../components/DiskTags';
|
||||
|
||||
export const LONGHORN_SYSTEM = 'longhorn-system';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditNode',
|
||||
components: {
|
||||
Footer,
|
||||
Tabbed,
|
||||
Tab,
|
||||
LabeledInput,
|
||||
NameNsDescription,
|
||||
ArrayListGrouped,
|
||||
HarvesterDisk,
|
||||
HarvesterKsmtuned,
|
||||
ButtonDropdown,
|
||||
KeyValue,
|
||||
Banner,
|
||||
Tags,
|
||||
Loading,
|
||||
HarvesterSeeder,
|
||||
MessageLink,
|
||||
},
|
||||
mixins: [CreateEditView],
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const hash = {
|
||||
longhornNodes: this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.NODES }),
|
||||
blockDevices: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.BLOCK_DEVICE }),
|
||||
addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }),
|
||||
secrets: this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET }),
|
||||
};
|
||||
|
||||
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.INVENTORY)) {
|
||||
hash.inventories = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.INVENTORY });
|
||||
}
|
||||
|
||||
await allHash(hash);
|
||||
|
||||
const blockDevices = this.$store.getters[`${ inStore }/all`](HCI.BLOCK_DEVICE);
|
||||
const provisionedBlockDevices = blockDevices.filter((d) => {
|
||||
const provisioned = d?.spec?.fileSystem?.provisioned;
|
||||
const isCurrentNode = d?.spec?.nodeName === this.value.id;
|
||||
const isLonghornMounted = findBy(this.longhornDisks, 'name', d.metadata.name);
|
||||
|
||||
return provisioned && isCurrentNode && !isLonghornMounted;
|
||||
})
|
||||
.map((d) => {
|
||||
const corrupted = d?.status?.deviceStatus?.fileSystem?.corrupted;
|
||||
|
||||
return {
|
||||
isNew: true,
|
||||
name: d?.metadata?.name,
|
||||
originPath: d?.spec?.fileSystem?.mountPoint,
|
||||
path: d?.spec?.fileSystem?.mountPoint,
|
||||
blockDevice: d,
|
||||
displayName: d?.displayName,
|
||||
forceFormatted: corrupted ? true : d?.spec?.fileSystem?.forceFormatted || false,
|
||||
};
|
||||
});
|
||||
|
||||
const disks = [...this.longhornDisks, ...provisionedBlockDevices];
|
||||
|
||||
this.disks = disks;
|
||||
this.newDisks = clone(disks);
|
||||
this.blockDeviceOpts = this.getBlockDeviceOpts();
|
||||
|
||||
const addons = this.$store.getters[`${ inStore }/all`](HCI.ADD_ONS);
|
||||
const seeder = addons.find(addon => addon.id === `harvester-system/${ ADD_ONS.HARVESTER_SEEDER }`);
|
||||
|
||||
const seederEnabled = seeder ? seeder?.spec?.enabled : false;
|
||||
|
||||
if (seederEnabled) {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const inventories = this.$store.getters[`${ inStore }/all`](HCI.INVENTORY) || [];
|
||||
|
||||
const inventory = inventories.find(inv => inv.id === `harvester-system/${ this.value.id }`);
|
||||
|
||||
if (inventory) {
|
||||
this.inventory = await this.$store.dispatch(`${ inStore }/clone`, { resource: inventory });
|
||||
} else {
|
||||
this.inventory = await this.$store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.INVENTORY,
|
||||
metadata: {
|
||||
name: this.value.id,
|
||||
namespace: 'harvester-system'
|
||||
},
|
||||
});
|
||||
|
||||
this.inventory.applyDefaults();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const customName = this.value.metadata?.annotations?.[HCI_LABELS_ANNOTATIONS.HOST_CUSTOM_NAME] || '';
|
||||
const consoleUrl = this.value.metadata?.annotations?.[HCI_LABELS_ANNOTATIONS.HOST_CONSOLE_URL] || '';
|
||||
|
||||
return {
|
||||
customName,
|
||||
consoleUrl,
|
||||
disks: [],
|
||||
newDisks: [],
|
||||
blockDevice: [],
|
||||
blockDeviceOpts: [],
|
||||
filteredLabels: clone(this.value.filteredSystemLabels),
|
||||
inventory: {},
|
||||
originValue: clone(this.value),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
removedDisks() {
|
||||
const out = this.disks.filter((d) => {
|
||||
return !findBy(this.newDisks, 'name', d.name);
|
||||
}) || [];
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
longhornDisks() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const longhornNode = this.$store.getters[`${ inStore }/byId`](LONGHORN.NODES, `${ LONGHORN_SYSTEM }/${ this.value.id }`);
|
||||
const diskStatus = longhornNode?.status?.diskStatus || {};
|
||||
const diskSpec = longhornNode?.spec?.disks || {};
|
||||
|
||||
const formatOptions = {
|
||||
increment: 1024,
|
||||
minExponent: 3,
|
||||
maxExponent: 3,
|
||||
maxPrecision: 2,
|
||||
suffix: 'iB',
|
||||
};
|
||||
|
||||
const longhornDisks = Object.keys(diskStatus).map((key) => {
|
||||
const blockDevice = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ key }`);
|
||||
|
||||
return {
|
||||
...diskStatus[key],
|
||||
...diskSpec?.[key],
|
||||
name: key,
|
||||
isNew: false,
|
||||
storageReserved: formatSi(diskSpec[key]?.storageReserved, formatOptions),
|
||||
storageAvailable: formatSi(diskStatus[key]?.storageAvailable, formatOptions),
|
||||
storageMaximum: formatSi(diskStatus[key]?.storageMaximum, formatOptions),
|
||||
storageScheduled: formatSi(diskStatus[key]?.storageScheduled, formatOptions),
|
||||
blockDevice,
|
||||
displayName: blockDevice?.displayName || key,
|
||||
forceFormatted: blockDevice?.spec?.fileSystem?.forceFormatted || false,
|
||||
tags: diskSpec?.[key]?.tags || [],
|
||||
};
|
||||
});
|
||||
|
||||
return longhornDisks;
|
||||
},
|
||||
|
||||
showFormattedWarning() {
|
||||
const out = this.newDisks.filter(d => d.forceFormatted && d.isNew) || [];
|
||||
|
||||
return out.length > 0;
|
||||
},
|
||||
|
||||
hasKsmtunedSchema() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED);
|
||||
},
|
||||
|
||||
hasBlockDevicesSchema() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.BLOCK_DEVICE);
|
||||
},
|
||||
|
||||
longhornNode() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const longhornNodes = this.$store.getters[`${ inStore }/all`](LONGHORN.NODES);
|
||||
|
||||
return longhornNodes.find(node => node.id === `${ LONGHORN_SYSTEM }/${ this.value.id }`);
|
||||
},
|
||||
|
||||
seederEnabled() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const addons = this.$store.getters[`${ inStore }/all`](HCI.ADD_ONS);
|
||||
const seeder = addons.find(addon => addon.id === `harvester-system/${ ADD_ONS.HARVESTER_SEEDER }`);
|
||||
|
||||
return seeder ? seeder?.spec?.enabled : false;
|
||||
},
|
||||
|
||||
toEnableSeederAddon() {
|
||||
const { cluster } = this.$router?.currentRoute?.params || {};
|
||||
|
||||
return {
|
||||
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-namespace-id`,
|
||||
params: {
|
||||
resource: `${ HCI.ADD_ONS }`,
|
||||
namespace: 'harvester-system',
|
||||
cluster,
|
||||
id: `${ ADD_ONS.HARVESTER_SEEDER }`
|
||||
},
|
||||
query: { mode: _EDIT }
|
||||
};
|
||||
},
|
||||
|
||||
hasAddonSchema() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return this.$store.getters[`${ inStore }/schemaFor`](HCI.ADD_ONS);
|
||||
},
|
||||
|
||||
hasSeederAddon() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const addons = this.$store.getters[`${ inStore }/all`](HCI.ADD_ONS);
|
||||
|
||||
return addons.find(addon => addon.id === `harvester-system/${ ADD_ONS.HARVESTER_SEEDER }`);
|
||||
},
|
||||
|
||||
hasInventorySchema() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return this.$store.getters[`${ inStore }/schemaFor`](HCI.INVENTORY);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
customName(neu) {
|
||||
this.value.setAnnotation(HCI_LABELS_ANNOTATIONS.HOST_CUSTOM_NAME, neu);
|
||||
},
|
||||
|
||||
consoleUrl(neu) {
|
||||
this.value.setAnnotation(HCI_LABELS_ANNOTATIONS.HOST_CONSOLE_URL, neu);
|
||||
},
|
||||
|
||||
newDisks() {
|
||||
this.blockDeviceOpts = this.getBlockDeviceOpts();
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.willSave, 'willSave');
|
||||
}
|
||||
|
||||
if (this.registerAfterHook) {
|
||||
this.registerAfterHook(this.saveDisk);
|
||||
this.registerAfterHook(this.saveLonghornNode);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
addDisk(id) {
|
||||
const removedDisk = findBy(this.removedDisks, 'blockDevice.id', id);
|
||||
|
||||
if (removedDisk) {
|
||||
return this.newDisks.push(removedDisk);
|
||||
}
|
||||
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const disk = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, id);
|
||||
const mountPoint = disk?.spec?.fileSystem?.mountPoint;
|
||||
const lastFormattedAt = disk?.status?.deviceStatus?.fileSystem?.LastFormattedAt;
|
||||
|
||||
let forceFormatted = true;
|
||||
const systems = ['ext4', 'XFS'];
|
||||
|
||||
if (disk.childParts?.length > 0) {
|
||||
forceFormatted = true;
|
||||
} else if (lastFormattedAt) {
|
||||
forceFormatted = false;
|
||||
} else if (systems.includes(disk?.status?.deviceStatus?.fileSystem?.type)) {
|
||||
forceFormatted = false;
|
||||
}
|
||||
|
||||
const name = disk?.metadata?.name;
|
||||
|
||||
this.newDisks.push({
|
||||
name,
|
||||
path: mountPoint,
|
||||
allowScheduling: false,
|
||||
evictionRequested: false,
|
||||
storageReserved: 0,
|
||||
isNew: true,
|
||||
originPath: disk?.spec?.fileSystem?.mountPoint,
|
||||
blockDevice: disk,
|
||||
displayName: disk?.displayName,
|
||||
forceFormatted,
|
||||
});
|
||||
},
|
||||
|
||||
async saveDisk() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const addDisks = this.newDisks.filter(d => d.isNew);
|
||||
const removeDisks = this.disks.filter(d => !findBy(this.newDisks, 'name', d.name) && d.blockDevice);
|
||||
|
||||
if (addDisks.length === 0 && removeDisks.length === 0) {
|
||||
return Promise.resolve();
|
||||
} else if (addDisks.length !== 0 && removeDisks.length === 0) {
|
||||
const updatedDisks = addDisks.filter((d) => {
|
||||
const blockDevice = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ d.name }`);
|
||||
const { provisioned, forceFormatted } = blockDevice.spec.fileSystem;
|
||||
|
||||
if (provisioned && forceFormatted === d.forceFormatted) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
if (updatedDisks.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(addDisks.map((d) => {
|
||||
const blockDevice = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ d.name }`);
|
||||
|
||||
blockDevice.spec.fileSystem.provisioned = true;
|
||||
blockDevice.spec.fileSystem.forceFormatted = d.forceFormatted;
|
||||
|
||||
return blockDevice.save();
|
||||
}));
|
||||
|
||||
await Promise.all(removeDisks.map((d) => {
|
||||
const blockDevice = this.$store.getters[`${ inStore }/byId`](HCI.BLOCK_DEVICE, `${ LONGHORN_SYSTEM }/${ d.name }`);
|
||||
|
||||
blockDevice.spec.fileSystem.provisioned = false;
|
||||
|
||||
return blockDevice.save();
|
||||
}));
|
||||
|
||||
this.$store.dispatch('growl/success', {
|
||||
title: this.t('generic.notification.title.succeed'),
|
||||
message: this.t('harvester.host.disk.notification.success', { name: this.value.metadata?.name || '' }),
|
||||
}, { root: true });
|
||||
} catch (err) {
|
||||
return Promise.reject(exceptionToErrorsArray(err));
|
||||
}
|
||||
},
|
||||
|
||||
canRemove(row) {
|
||||
return !!row?.value?.blockDevice;
|
||||
},
|
||||
|
||||
onRemove(scope) {
|
||||
scope.remove();
|
||||
},
|
||||
|
||||
updateHostLabels(labels) {
|
||||
this.filteredLabels = labels;
|
||||
},
|
||||
|
||||
selectable(opt) {
|
||||
if ( opt.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
getBlockDeviceOpts() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const blockDevices = this.$store.getters[`${ inStore }/all`](HCI.BLOCK_DEVICE);
|
||||
|
||||
const out = blockDevices
|
||||
.filter((d) => {
|
||||
const addedToNodeCondition = findBy(d?.status?.conditions || [], 'type', 'AddedToNode');
|
||||
const isAdded = findBy(this.newDisks, 'name', d.metadata.name);
|
||||
const isRemoved = findBy(this.removedDisks, 'name', d.metadata.name);
|
||||
|
||||
const deviceType = d.status?.deviceStatus?.details?.deviceType;
|
||||
|
||||
if (deviceType !== 'disk' || d?.status?.state !== 'Active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((!findBy(this.disks || [], 'name', d.metadata.name) &&
|
||||
d?.spec?.nodeName === this.value.id &&
|
||||
(!addedToNodeCondition || addedToNodeCondition?.status === 'False') &&
|
||||
!d.spec?.fileSystem?.provisioned &&
|
||||
!isAdded) ||
|
||||
isRemoved
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((d) => {
|
||||
const devPath = d.spec?.devPath;
|
||||
const deviceType = d.status?.deviceStatus?.details?.deviceType;
|
||||
const sizeBytes = d.status?.deviceStatus?.capacity?.sizeBytes;
|
||||
const size = formatSi(sizeBytes, { increment: 1024 });
|
||||
const parentDevice = d.status?.deviceStatus?.parentDevice;
|
||||
const isChildAdded = this.newDisks.find(newDisk => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath);
|
||||
const name = d.displayName;
|
||||
|
||||
let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`;
|
||||
|
||||
if (parentDevice) {
|
||||
label = `- ${ label }`;
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
value: d.id,
|
||||
action: this.addDisk,
|
||||
kind: !parentDevice ? 'group' : '',
|
||||
disabled: !!isChildAdded,
|
||||
group: parentDevice || devPath,
|
||||
isParent: !!parentDevice,
|
||||
};
|
||||
});
|
||||
|
||||
return sortBy(out, ['group', 'isParent', 'label']);
|
||||
},
|
||||
|
||||
ddButtonAction() {
|
||||
this.blockDeviceOpts = this.getBlockDeviceOpts();
|
||||
},
|
||||
|
||||
willSave() {
|
||||
const filteredLabels = this.filteredLabels || {};
|
||||
|
||||
this.value.metadata.labels = {
|
||||
...this.value.metadata.labels,
|
||||
...filteredLabels,
|
||||
};
|
||||
|
||||
const originLabels = this.value.filteredSystemLabels;
|
||||
|
||||
Object.keys(originLabels).map((key) => {
|
||||
if (!filteredLabels[key]) {
|
||||
delete this.value.metadata.labels[key];
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async saveLonghornNode() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const disks = this.longhornNode?.spec?.disks || {};
|
||||
|
||||
this.newDisks.map((disk) => {
|
||||
(disks[disk.name] || {}).tags = disk.tags;
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
|
||||
const retrySave = async() => {
|
||||
try {
|
||||
await this.longhornNode.save();
|
||||
} catch (err) {
|
||||
if ((err.status === 409 || err.status === 403) && count < 3) {
|
||||
count++;
|
||||
|
||||
await this.$store.dispatch(`${ inStore }/find`, {
|
||||
type: LONGHORN.NODES,
|
||||
id: this.longhornNode.id,
|
||||
opt: { force: true },
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, '5000'));
|
||||
await retrySave();
|
||||
} else {
|
||||
return Promise.reject(exceptionToErrorsArray(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await retrySave();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Loading v-if="$fetchState.pending" />
|
||||
<div v-else id="node">
|
||||
<div class="content">
|
||||
<NameNsDescription
|
||||
:value="value"
|
||||
:namespaced="false"
|
||||
:mode="mode"
|
||||
/>
|
||||
<Tabbed ref="tabbed" class="mt-15" :side-tabs="true">
|
||||
<Tab name="basics" :weight="100" :label="t('harvester.host.tabs.basics')">
|
||||
<LabeledInput
|
||||
v-model="customName"
|
||||
:label="t('harvester.host.detail.customName')"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<LabeledInput
|
||||
v-model="consoleUrl"
|
||||
:label="t('harvester.host.detail.consoleUrl')"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
v-if="hasBlockDevicesSchema"
|
||||
name="disk"
|
||||
:weight="80"
|
||||
:label="t('harvester.host.tabs.storage')"
|
||||
>
|
||||
<div
|
||||
v-if="longhornNode"
|
||||
class="row mb-20"
|
||||
>
|
||||
<div class="col span-12">
|
||||
<Tags
|
||||
v-model="longhornNode.spec.tags"
|
||||
:label="t('harvester.host.tags.label')"
|
||||
:add-label="t('harvester.host.tags.addLabel')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ArrayListGrouped
|
||||
v-model="newDisks"
|
||||
:mode="mode"
|
||||
:initial-empty-row="false"
|
||||
>
|
||||
<template #default="props">
|
||||
<HarvesterDisk
|
||||
v-model="props.row.value"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
:disks="disks"
|
||||
/>
|
||||
</template>
|
||||
<template #add>
|
||||
<ButtonDropdown
|
||||
v-if="!isView"
|
||||
:button-label="t('harvester.host.disk.add')"
|
||||
:dropdown-options="blockDeviceOpts"
|
||||
size="sm"
|
||||
:selectable="selectable"
|
||||
@click-action="e=>addDisk(e.value)"
|
||||
@dd-button-action="ddButtonAction"
|
||||
>
|
||||
<template #option="option">
|
||||
<template v-if="option.kind === 'group'">
|
||||
<b>
|
||||
{{ option.label }}
|
||||
</b>
|
||||
</template>
|
||||
<div v-else>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</template>
|
||||
</ButtonDropdown>
|
||||
</template>
|
||||
<template #remove-button="scope">
|
||||
<button
|
||||
v-if="canRemove(scope.row, scope.i) && !isView"
|
||||
type="button"
|
||||
class="btn role-link close btn-sm"
|
||||
@click="() => onRemove(scope)"
|
||||
>
|
||||
<i class="icon icon-x" />
|
||||
</button>
|
||||
<span v-else />
|
||||
</template>
|
||||
</ArrayListGrouped>
|
||||
</Tab>
|
||||
<Tab v-if="hasKsmtunedSchema" name="Ksmtuned" :weight="70" :label="t('harvester.host.tabs.ksmtuned')">
|
||||
<HarvesterKsmtuned :mode="mode" :node="value" :register-before-hook="registerBeforeHook" />
|
||||
</Tab>
|
||||
<Tab
|
||||
v-if="hasAddonSchema"
|
||||
name="seeder"
|
||||
:weight="60"
|
||||
:label="t('harvester.host.tabs.seeder')"
|
||||
>
|
||||
<HarvesterSeeder
|
||||
v-if="seederEnabled && hasInventorySchema"
|
||||
:mode="mode"
|
||||
:node="value"
|
||||
:register-after-hook="registerAfterHook"
|
||||
:inventory="inventory"
|
||||
/>
|
||||
<div v-else-if="seederEnabled && !hasInventorySchema">
|
||||
<Banner
|
||||
color="info"
|
||||
:label="t('harvester.seeder.banner.noInventory')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Banner
|
||||
v-if="hasSeederAddon"
|
||||
color="info"
|
||||
>
|
||||
<MessageLink
|
||||
:to="toEnableSeederAddon"
|
||||
prefix-label="harvester.seeder.banner.enable.prefix"
|
||||
middle-label="harvester.seeder.banner.enable.middle"
|
||||
suffix-label="harvester.seeder.banner.enable.suffix"
|
||||
/>
|
||||
</Banner>
|
||||
<Banner
|
||||
v-else
|
||||
color="warning"
|
||||
:label="t('harvester.seeder.banner.noAddon')"
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab name="labels" label-key="harvester.host.tabs.labels">
|
||||
<KeyValue
|
||||
key="labels"
|
||||
:value="filteredLabels"
|
||||
:add-label="t('labels.addLabel')"
|
||||
:mode="mode"
|
||||
:title="t('labels.labels.title')"
|
||||
:read-allowed="false"
|
||||
:value-can-be-empty="true"
|
||||
@input="updateHostLabels"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
<Banner
|
||||
v-if="showFormattedWarning"
|
||||
color="warning"
|
||||
:label="t('harvester.host.disk.forceFormatted.toolTip')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Footer class="footer" :mode="mode" :errors="errors" @save="save" @done="done" />
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
#node {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
|
||||
.content {
|
||||
flex-grow: 1
|
||||
}
|
||||
|
||||
.wrapper{
|
||||
position: relative;
|
||||
}
|
||||
.nicOption {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script>
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import FileSelector, { createOnSelected } from '@shell/components/form/FileSelector';
|
||||
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditKeypair',
|
||||
|
||||
components: {
|
||||
Tab,
|
||||
Tabbed,
|
||||
CruResource,
|
||||
LabeledInput,
|
||||
FileSelector,
|
||||
NameNsDescription
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
if ( !this.value.spec ) {
|
||||
this.value.spec = {};
|
||||
this.value.metadata = { name: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
publicKey: this.value.spec.publicKey || '',
|
||||
randomString: '',
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
publicKey(neu) {
|
||||
const trimNeu = neu.trim();
|
||||
|
||||
this.value.spec.publicKey = trimNeu;
|
||||
|
||||
const splitSSH = trimNeu.split(/\s+/);
|
||||
|
||||
if (splitSSH.length === 3 && !this.value.metadata.name) {
|
||||
const keyComment = splitSSH[2];
|
||||
|
||||
this.randomString = randomStr(10).toLowerCase();
|
||||
this.value.metadata.name = keyComment.includes('@') ? keyComment.split('@')[0] : keyComment;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: { onKeySelected: createOnSelected('publicKey') },
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
>
|
||||
<div class="header mb-20">
|
||||
<FileSelector
|
||||
v-if="isCreate"
|
||||
class="btn btn-sm bg-primary mt-10"
|
||||
:label="t('generic.readFromFile')"
|
||||
accept=".pub"
|
||||
@selected="onKeySelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NameNsDescription
|
||||
ref="nd"
|
||||
:key="randomString"
|
||||
v-model="value"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<Tabbed v-bind="$attrs" class="mt-15" :side-tabs="true">
|
||||
<Tab name="basic" :label="t('harvester.sshKey.tabs.basics')" :weight="1" class="bordered-table">
|
||||
<LabeledInput
|
||||
v-model="publicKey"
|
||||
type="multiline"
|
||||
:mode="mode"
|
||||
:min-height="160"
|
||||
:label="t('harvester.sshKey.keypair')"
|
||||
required
|
||||
/>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script>
|
||||
import { LAST_NAMESPACE } from '@shell/store/prefs';
|
||||
import { LOGGING } from '@shell/config/types';
|
||||
import Flow from '@shell/edit/logging-flow';
|
||||
|
||||
export default {
|
||||
extends: Flow,
|
||||
created() {
|
||||
if (this.isCreate && this.value.type === LOGGING.CLUSTER_FLOW) {
|
||||
this.value.metadata.namespace = 'cattle-logging-system';
|
||||
}
|
||||
|
||||
this.registerBeforeHook(this.willSave, 'willSave');
|
||||
this.registerAfterHook(() => {
|
||||
const allNamespaces = this.$store.getters['allNamespaces'];
|
||||
const defaultNamepsace = allNamespaces.find(N => N.id === 'default');
|
||||
const ns = defaultNamepsace?.id || allNamespaces?.[0]?.id || '';
|
||||
|
||||
this.value.$dispatch('prefs/set', { key: LAST_NAMESPACE, value: ns }, { root: true });
|
||||
this.willSave();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { LAST_NAMESPACE } from '@shell/store/prefs';
|
||||
import CreateEditView from './harvesterhci.io.logging.output';
|
||||
|
||||
export default {
|
||||
extends: CreateEditView,
|
||||
created() {
|
||||
if (this.isCreate) {
|
||||
this.value.metadata.namespace = 'cattle-logging-system';
|
||||
}
|
||||
|
||||
this.registerAfterHook(() => {
|
||||
const allNamespaces = this.$store.getters['allNamespaces'];
|
||||
const defaultNamepsace = allNamespaces.find(N => N.id === 'default');
|
||||
const ns = defaultNamepsace?.id || allNamespaces?.[0]?.id || '';
|
||||
|
||||
this.value.$dispatch('prefs/set', { key: LAST_NAMESPACE, value: ns }, { root: true });
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
import Flow from '@shell/edit/logging-flow';
|
||||
export default { components: { Flow } };
|
||||
</script>
|
||||
<template>
|
||||
<Flow v-bind="$attrs" />
|
||||
</template>
|
||||
@@ -0,0 +1,345 @@
|
||||
<script>
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { SECRET, LOGGING, SCHEMA } from '@shell/config/types';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import Labels from '@shell/components/form/Labels';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { PROVIDERS } from '@shell/models/logging.banzaicloud.io.output';
|
||||
import { _VIEW } from '@shell/config/query-params';
|
||||
import { clone, set } from '@shell/utils/object';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { createYaml } from '@shell/utils/create-yaml';
|
||||
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
|
||||
import { FLOW_TYPE } from '../config/harvester-map';
|
||||
|
||||
const LOGGING_EVENT = 'Logging/Event';
|
||||
const AUDIT_ONLY = 'Audit Only';
|
||||
const OUTPUT_TYPE = [LOGGING_EVENT, AUDIT_ONLY];
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner, CruResource, Labels, LabeledSelect, NameNsDescription, Tab, Tabbed, YamlEditor
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
async fetch() {
|
||||
await this.$store.dispatch('harvester/findAll', { type: SECRET });
|
||||
},
|
||||
|
||||
data() {
|
||||
const schemas = this.$store.getters['harvester/all'](SCHEMA);
|
||||
|
||||
if (this.isCreate) {
|
||||
this.value.metadata.namespace = 'default';
|
||||
}
|
||||
|
||||
set(this.value, 'spec', this.value.spec || {});
|
||||
|
||||
const providers = PROVIDERS.map(provider => ({
|
||||
...provider,
|
||||
value: provider.name,
|
||||
label: this.t(provider.labelKey)
|
||||
}));
|
||||
|
||||
if (this.mode !== _VIEW) {
|
||||
this.$set(this.value, 'spec', this.value.spec || {});
|
||||
|
||||
providers.forEach((provider) => {
|
||||
this.$set(this.value.spec, provider.name, this.value.spec[provider.name] || clone(provider.default));
|
||||
});
|
||||
}
|
||||
|
||||
const selectedProviders = providers.filter((provider) => {
|
||||
const specProvider = this.value.spec[provider.name];
|
||||
const correctedSpecProvider = provider.name === 'forward' ? specProvider?.servers?.[0] || {} : specProvider;
|
||||
|
||||
return !isEmpty(correctedSpecProvider) && !isEqual(correctedSpecProvider, provider.default);
|
||||
});
|
||||
|
||||
const selectedProvider = selectedProviders?.[0]?.value || providers[0].value;
|
||||
|
||||
let bufferYaml;
|
||||
|
||||
if ( !isEmpty(this.value.spec[selectedProvider]?.buffer) ) {
|
||||
bufferYaml = jsyaml.dump(this.value.spec[selectedProvider].buffer);
|
||||
} else {
|
||||
bufferYaml = createYaml(schemas, `logging.banzaicloud.io.v1beta1.output.spec.${ selectedProvider }.buffer`, []);
|
||||
// createYaml doesn't support passing reference types (array, map) as the first type. As such
|
||||
// I'm manipulating the output since I'm not sure it's something we want to actually support
|
||||
// seeing as it's really createResourceYaml and this here is a gray area between spoofed types
|
||||
// and just a field within a spec.
|
||||
bufferYaml = bufferYaml.substring(bufferYaml.indexOf('\n') + 1).replaceAll('# ', '#');
|
||||
}
|
||||
|
||||
return {
|
||||
bufferYaml,
|
||||
initialBufferYaml: bufferYaml,
|
||||
providers,
|
||||
selectedProvider,
|
||||
hasMultipleProvidersSelected: selectedProviders.length > 1,
|
||||
selectedProviders,
|
||||
LOGGING,
|
||||
loggingType: this.value.loggingType !== FLOW_TYPE.AUDIT ? LOGGING_EVENT : AUDIT_ONLY
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
EDITOR_MODES() {
|
||||
return EDITOR_MODES;
|
||||
},
|
||||
enabledProviders() {
|
||||
return this.providers.filter(p => p.enabled);
|
||||
},
|
||||
cruMode() {
|
||||
if (this.selectedProviders.length > 1 || !this.value.allProvidersSupported) {
|
||||
return _VIEW;
|
||||
}
|
||||
|
||||
return this.mode;
|
||||
},
|
||||
outputTypeOptions() {
|
||||
return OUTPUT_TYPE;
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.registerBeforeHook(this.willSave, 'willSave');
|
||||
},
|
||||
methods: {
|
||||
getComponent(name) {
|
||||
return require(`@shell/edit/logging.banzaicloud.io.output/providers/${ name }`).default;
|
||||
},
|
||||
launch(provider) {
|
||||
this.$refs.tabbed.select(provider.name);
|
||||
},
|
||||
willSave() {
|
||||
this.value.spec = { [this.selectedProvider]: this.value.spec[this.selectedProvider] };
|
||||
|
||||
const bufferJson = jsyaml.load(this.bufferYaml);
|
||||
|
||||
if (!isEmpty(bufferJson)) {
|
||||
this.value.spec[this.selectedProvider].buffer = bufferJson;
|
||||
} else {
|
||||
this.$delete(this.value.spec[this.selectedProvider], 'buffer');
|
||||
}
|
||||
|
||||
if (this.loggingType === AUDIT_ONLY) {
|
||||
this.$set(this.value.spec, 'loggingRef', 'harvester-kube-audit-log-ref');
|
||||
}
|
||||
},
|
||||
tabChanged({ tab }) {
|
||||
if ( tab.name === 'buffer' ) {
|
||||
this.$nextTick(() => {
|
||||
if ( this.$refs.yaml ) {
|
||||
this.$refs.yaml.refresh();
|
||||
this.$refs.yaml.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onYamlEditorReady(cm) {
|
||||
cm.getMode().fold = 'yamlcomments';
|
||||
cm.execCommand('foldAll');
|
||||
cm.execCommand('unfold');
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="output">
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:mode="cruMode"
|
||||
:resource="value"
|
||||
:subtypes="[]"
|
||||
:validation-passed="true"
|
||||
:errors="errors"
|
||||
:can-yaml="true"
|
||||
@error="e=>errors = e"
|
||||
@finish="save"
|
||||
@cancel="done"
|
||||
>
|
||||
<NameNsDescription
|
||||
v-if="!isView"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
label="generic.name"
|
||||
:register-before-hook="registerBeforeHook"
|
||||
:namespaced="value.type !== LOGGING.CLUSTER_OUTPUT"
|
||||
/>
|
||||
<Banner v-if="selectedProviders.length > 1" color="info">
|
||||
{{ t('logging.output.tips.singleProvider') }}
|
||||
</Banner>
|
||||
<Banner v-else-if="!value.allProvidersSupported" color="info">
|
||||
{{ t('logging.output.tips.multipleProviders') }}
|
||||
</Banner>
|
||||
<Tabbed v-else ref="tabbed" :side-tabs="true" @changed="tabChanged($event)">
|
||||
<Tab name="Output" label="Output" :weight="2">
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="loggingType"
|
||||
class="mb-20"
|
||||
:options="outputTypeOptions"
|
||||
:disabled="!isCreate"
|
||||
:mode="mode"
|
||||
:label="t('generic.type')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect v-model="selectedProvider" label="Output" :options="providers" :mode="mode" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<component :is="getComponent(selectedProvider)" :value="value.spec[selectedProvider]" :namespace="value.namespace" :mode="mode" />
|
||||
</Tab>
|
||||
<Tab name="buffer" :label="t('logging.output.buffer.label')" :weight="1">
|
||||
<YamlEditor
|
||||
ref="yaml"
|
||||
v-model="bufferYaml"
|
||||
:scrolling="false"
|
||||
:initial-yaml-values="initialBufferYaml"
|
||||
:editor-mode="isView ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE"
|
||||
@onReady="onYamlEditorReady"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
v-if="!isView"
|
||||
name="labels-and-annotations"
|
||||
label-key="generic.labelsAndAnnotations"
|
||||
:weight="0"
|
||||
>
|
||||
<Labels
|
||||
default-container-class="labels-and-annotations-container"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:display-side-by-side="false"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
$chart: 110px;
|
||||
$side: 15px;
|
||||
$margin: 10px;
|
||||
$logo: 60px;
|
||||
|
||||
.output {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
.provider {
|
||||
h1 {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.box-container {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -1*$margin;
|
||||
|
||||
@media only screen and (min-width: map-get($breakpoints, '--viewport-4')) {
|
||||
.toggle-gradient-box {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
|
||||
.toggle-gradient-box {
|
||||
width: calc(50% - 2 * #{$margin});
|
||||
}
|
||||
}
|
||||
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
|
||||
.toggle-gradient-box {
|
||||
width: calc(33.33333% - 2 * #{$margin});
|
||||
}
|
||||
}
|
||||
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
|
||||
.toggle-gradient-box {
|
||||
width: calc(25% - 2 * #{$margin});
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-gradient-box {
|
||||
margin: $margin;
|
||||
padding: $margin;
|
||||
position: relative;
|
||||
border-radius: calc( 1.5 * var(--border-radius));
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 30px var(--shadow);
|
||||
transition: box-shadow 0.1s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.side-label {
|
||||
transform: rotate(180deg);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
min-width: calc(1.5 * var(--border-radius));
|
||||
width: $side;
|
||||
border-top-right-radius: calc( 1.5 * var(--border-radius));
|
||||
border-bottom-right-radius: calc( 1.5 * var(--border-radius));
|
||||
|
||||
label {
|
||||
text-align: center;
|
||||
writing-mode: tb;
|
||||
height: 100%;
|
||||
padding: 0 2px;
|
||||
display: block;
|
||||
white-space: no-wrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
width: $logo;
|
||||
height: $logo;
|
||||
border-radius: calc(2 * var(--border-radius));
|
||||
overflow: hidden;
|
||||
background-color: white;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
|
||||
img {
|
||||
width: $logo - 4px;
|
||||
height: $logo - 4px;
|
||||
object-fit: contain;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-position: right center;
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { MONITORING } from '@shell/config/types';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import { EDITOR_MODES } from '@shell/components/YamlEditor';
|
||||
import { RECEIVERS_TYPES } from '@shell/models/monitoring.coreos.com.receiver';
|
||||
import RouteConfig from '@shell/edit/monitoring.coreos.com.alertmanagerconfig/routeConfig';
|
||||
import ResourceTable from '@shell/components/ResourceTable';
|
||||
import ActionMenu from '@shell/components/ActionMenu';
|
||||
import { _CREATE, _EDIT, _VIEW, _CONFIG } from '@shell/config/query-params';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ActionMenu,
|
||||
CruResource,
|
||||
Loading,
|
||||
NameNsDescription,
|
||||
ResourceTable,
|
||||
RouteConfig,
|
||||
Tab,
|
||||
Tabbed,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const alertmanagerConfigId = this.value.id;
|
||||
|
||||
const alertmanagerConfigResource = await this.$store.dispatch(`${ inStore }/find`, { type: MONITORING.ALERTMANAGERCONFIG, id: alertmanagerConfigId });
|
||||
|
||||
this.alertmanagerConfigId = alertmanagerConfigId;
|
||||
this.alertmanagerConfigResource = alertmanagerConfigResource;
|
||||
this.alertmanagerConfigDetailRoute = alertmanagerConfigResource._detailLocation;
|
||||
|
||||
const alertmanagerConfigActions = alertmanagerConfigResource.availableActions;
|
||||
const receiverActions = alertmanagerConfigResource.getReceiverActions(alertmanagerConfigActions);
|
||||
|
||||
this.receiverActions = receiverActions;
|
||||
},
|
||||
|
||||
data() {
|
||||
this.value.applyDefaults();
|
||||
|
||||
const defaultReceiverValues = {};
|
||||
const receiverSchema = this.$store.getters['harvester/schemaFor'](MONITORING.SPOOFED.ALERTMANAGERCONFIG_RECEIVER_SPEC);
|
||||
const routeSchema = this.$store.getters['harvester/schemaFor'](MONITORING.SPOOFED.ALERTMANAGERCONFIG_ROUTE_SPEC);
|
||||
const receiverOptions = (this.value?.spec?.receivers || []).map((receiver) => receiver.name);
|
||||
|
||||
return {
|
||||
actionMenuTargetElement: null,
|
||||
actionMenuTargetEvent: null,
|
||||
config: _CONFIG,
|
||||
create: _CREATE,
|
||||
createReceiverLink: this.value.getCreateReceiverRoute(),
|
||||
defaultReceiverValues,
|
||||
receiverActionMenuIsOpen: false,
|
||||
receiverTableHeaders: [
|
||||
{
|
||||
name: 'name',
|
||||
labelKey: 'tableHeaders.name',
|
||||
value: 'name',
|
||||
sort: ['nameSort'],
|
||||
formatter: 'LinkDetail',
|
||||
canBeVariable: true,
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
labelKey: 'tableHeaders.type',
|
||||
value: 'name',
|
||||
formatter: 'ReceiverIcons',
|
||||
canBeVariable: true,
|
||||
}
|
||||
// Add more columns
|
||||
],
|
||||
newReceiverType: null,
|
||||
receiverActions: [],
|
||||
receiverOptions,
|
||||
receiverTypes: RECEIVERS_TYPES,
|
||||
routeSchema,
|
||||
receiverSchema,
|
||||
selectedReceiverName: '',
|
||||
selectedRowValue: null,
|
||||
view: _VIEW,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
editorMode() {
|
||||
if ( this.mode === _VIEW ) {
|
||||
return EDITOR_MODES.VIEW_CODE;
|
||||
}
|
||||
|
||||
return EDITOR_MODES.EDIT_CODE;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
translateReceiverTypes() {
|
||||
return this.receiverTypes.map((receiverType) => {
|
||||
return {
|
||||
...receiverType,
|
||||
label: this.t(receiverType.label)
|
||||
};
|
||||
});
|
||||
},
|
||||
getReceiverDetailLink(receiverData) {
|
||||
if (receiverData && receiverData.name) {
|
||||
return this.value.getReceiverDetailLink(receiverData.name);
|
||||
}
|
||||
},
|
||||
|
||||
toggleReceiverActionMenu() {
|
||||
this.receiverActionMenuIsOpen = true;
|
||||
},
|
||||
setActionMenuState(eventData) {
|
||||
// This method is called when the user clicks a context menu
|
||||
// for a receiver in the receiver in the receiver list view.
|
||||
// It sets the target element so the menu can open where the
|
||||
// user clicked.
|
||||
const { event, targetElement } = eventData;
|
||||
|
||||
// TargetElement could be an array of more than
|
||||
// one if there is more than one ref of the same name.
|
||||
if (event && targetElement) {
|
||||
this.actionMenuTargetElement = targetElement;
|
||||
this.actionMenuTargetEvent = event;
|
||||
|
||||
// We take the selected receiver name out of the target
|
||||
// element's ID to help us build the URL to link to
|
||||
// the detail page of that receiver.
|
||||
// We use a plus sign as the delimiter to separate the
|
||||
// name because the plus is not an allowed character in
|
||||
// Kubernetes names.
|
||||
this.selectedReceiverName = targetElement.id.split('+').slice(2).join('');
|
||||
|
||||
this.toggleReceiverActionMenu();
|
||||
} else {
|
||||
throw new Error('Could not find action menu target element.');
|
||||
}
|
||||
},
|
||||
goToEdit() {
|
||||
// 'goToEdit' is the exact name of an action for AlertmanagerConfig
|
||||
// and this method executes the action.
|
||||
this.$router.push(this.alertmanagerConfigResource.getEditReceiverConfigRoute(this.selectedReceiverName, _EDIT));
|
||||
},
|
||||
|
||||
goToEditYaml() {
|
||||
// 'goToEditYaml' is the exact name of an action for AlertmanagerConfig
|
||||
// and this method executes the action.
|
||||
this.$router.push(this.alertmanagerConfigResource.getEditReceiverYamlRoute(this.selectedReceiverName, _EDIT));
|
||||
},
|
||||
promptRemove() {
|
||||
// 'promptRemove' is the exact name of an action for AlertmanagerConfig
|
||||
// and this method executes the action.
|
||||
// Get the name of the receiver to delete from the action info.
|
||||
const nameOfReceiverToDelete = this.selectedReceiverName;
|
||||
// Remove it from the configuration of the parent AlertmanagerConfig
|
||||
// resource.
|
||||
const existingReceivers = this.alertmanagerConfigResource.spec.receivers || [];
|
||||
const receiversMinusDeletedItem = existingReceivers.filter((receiver) => {
|
||||
return receiver.name !== nameOfReceiverToDelete;
|
||||
});
|
||||
|
||||
this.alertmanagerConfigResource.spec.receivers = receiversMinusDeletedItem;
|
||||
// After saving the AlertmanagerConfig, the resource has been deleted.
|
||||
this.alertmanagerConfigResource.save(...arguments);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="$fetchState.pending" />
|
||||
<CruResource
|
||||
v-else
|
||||
class="route"
|
||||
:done-route="doneRoute"
|
||||
:errors="errors"
|
||||
:mode="mode"
|
||||
:resource="value"
|
||||
:subtypes="[]"
|
||||
:cancel-event="true"
|
||||
@error="e=>errors = e"
|
||||
@finish="save"
|
||||
@cancel="done"
|
||||
>
|
||||
<NameNsDescription
|
||||
v-model="value"
|
||||
:mode="mode"
|
||||
:namespaced="isNamespaced"
|
||||
/>
|
||||
|
||||
<Tabbed>
|
||||
<Tab
|
||||
:label="t('monitoring.route.label')"
|
||||
:weight="1"
|
||||
name="route"
|
||||
>
|
||||
<RouteConfig
|
||||
:value="value.spec.route"
|
||||
:mode="mode"
|
||||
:receiver-options="receiverOptions"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
:label="t('alertmanagerConfigReceiver.receivers')"
|
||||
:weight="2"
|
||||
name="receivers"
|
||||
>
|
||||
<ResourceTable
|
||||
:headers="receiverTableHeaders"
|
||||
:schema="receiverSchema"
|
||||
:rows="value.spec.receivers || []"
|
||||
:get-custom-detail-link="getReceiverDetailLink"
|
||||
:table-actions="false"
|
||||
:custom-actions="value.receiverActions"
|
||||
@clickedActionButton="setActionMenuState"
|
||||
>
|
||||
<template #header-button>
|
||||
<router-link
|
||||
v-if="createReceiverLink && createReceiverLink.name"
|
||||
:to="mode !== create ? createReceiverLink : {}"
|
||||
>
|
||||
<button
|
||||
class="btn role-primary"
|
||||
:disabled="mode === create"
|
||||
:tooltip="t('monitoring.alertmanagerConfig.disabledReceiverButton')"
|
||||
>
|
||||
{{ t('monitoring.receiver.addReceiver') }}
|
||||
<i
|
||||
v-if="mode === create"
|
||||
v-clean-tooltip="t('monitoring.alertmanagerConfig.disabledReceiverButton')"
|
||||
class="icon icon-info"
|
||||
/>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
</ResourceTable>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
<ActionMenu
|
||||
:custom-actions="receiverActions"
|
||||
:open="receiverActionMenuIsOpen"
|
||||
:use-custom-target-element="true"
|
||||
:custom-target-element="actionMenuTargetElement"
|
||||
:custom-target-event="actionMenuTargetEvent"
|
||||
@close="receiverActionMenuIsOpen = false"
|
||||
@goToEdit="goToEdit"
|
||||
@goToEditYaml="goToEditYaml"
|
||||
@promptRemove="promptRemove"
|
||||
/>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
h3 {
|
||||
margin-top: 2em;
|
||||
}
|
||||
input {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.route {
|
||||
&[real-mode=view] .label {
|
||||
color: var(--input-label);
|
||||
}
|
||||
}
|
||||
button {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script>
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
|
||||
import { HCI as HCI_LABELS_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI } from '../types';
|
||||
|
||||
const AUTO = 'auto';
|
||||
const MANUAL = 'manual';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Tab,
|
||||
Tabbed,
|
||||
CruResource,
|
||||
LabeledInput,
|
||||
NameNsDescription,
|
||||
RadioGroup,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const config = JSON.parse(this.value.spec.config);
|
||||
const annotations = this.value?.metadata?.annotations || {};
|
||||
const layer3Network = JSON.parse(annotations[HCI_LABELS_ANNOTATIONS.NETWORK_ROUTE] || '{}');
|
||||
|
||||
if ((config.bridge || '').endsWith('-br')) {
|
||||
config.bridge = config.bridge.slice(0, -3);
|
||||
}
|
||||
|
||||
const type = this.value.vlanType || 'L2VlanNetwork' ;
|
||||
|
||||
return {
|
||||
config,
|
||||
type,
|
||||
layer3Network: {
|
||||
mode: layer3Network.mode || AUTO,
|
||||
serverIPAddr: layer3Network.serverIPAddr || '',
|
||||
cidr: layer3Network.cidr || '',
|
||||
gateway: layer3Network.gateway || '',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({ clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }) });
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
modeOptions() {
|
||||
return [{
|
||||
label: this.t('harvester.network.layer3Network.mode.auto'),
|
||||
value: AUTO,
|
||||
}, {
|
||||
label: this.t('harvester.network.layer3Network.mode.manual'),
|
||||
value: MANUAL,
|
||||
}];
|
||||
},
|
||||
|
||||
clusterNetworkOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const clusterNetworks = this.$store.getters[`${ inStore }/all`](HCI.CLUSTER_NETWORK) || [];
|
||||
|
||||
return clusterNetworks.map((n) => {
|
||||
const disabled = !n.isReady;
|
||||
|
||||
return {
|
||||
label: disabled ? `${ n.id } (${ this.t('generic.notReady') })` : n.id,
|
||||
value: n.id,
|
||||
disabled,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
networkType() {
|
||||
return ['L2VlanNetwork', 'UntaggedNetwork'];
|
||||
},
|
||||
|
||||
isUntaggedNetwork() {
|
||||
if (this.isView) {
|
||||
return this.value.vlanType === 'UntaggedNetwork';
|
||||
}
|
||||
|
||||
return this.type === 'UntaggedNetwork';
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveNetwork(buttonCb) {
|
||||
const errors = [];
|
||||
|
||||
if (!this.config.vlan && !this.isUntaggedNetwork) {
|
||||
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('tableHeaders.networkVlan') }));
|
||||
}
|
||||
|
||||
if (!this.config.bridge) {
|
||||
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.network.clusterNetwork.label') }));
|
||||
}
|
||||
|
||||
if (this.layer3Network.mode === MANUAL) {
|
||||
if (!this.layer3Network.gateway) {
|
||||
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.network.layer3Network.gateway.label') }));
|
||||
}
|
||||
if (!this.layer3Network.cidr) {
|
||||
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.network.layer3Network.cidr.label') }));
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
buttonCb(false);
|
||||
this.errors = errors;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.value.setAnnotation(HCI_LABELS_ANNOTATIONS.NETWORK_ROUTE, JSON.stringify(this.layer3Network));
|
||||
|
||||
await this.save(buttonCb);
|
||||
},
|
||||
|
||||
input(neu) {
|
||||
const pattern = /^([1-9]|[1-9][0-9]{1,2}|[1-3][0-9]{3}|40[0-9][0-4])$/;
|
||||
|
||||
if (!pattern.test(neu) && neu !== '') {
|
||||
this.config.vlan = neu > 4094 ? 4094 : 1;
|
||||
}
|
||||
},
|
||||
|
||||
updateBeforeSave() {
|
||||
this.config.name = this.value.metadata.name;
|
||||
|
||||
if (this.isUntaggedNetwork) {
|
||||
delete this.config.vlan;
|
||||
}
|
||||
|
||||
this.value.spec.config = JSON.stringify({
|
||||
...this.config,
|
||||
bridge: `${ this.config.bridge }-br`,
|
||||
});
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="saveNetwork"
|
||||
>
|
||||
<NameNsDescription
|
||||
ref="nd"
|
||||
v-model="value"
|
||||
:mode="mode"
|
||||
/>
|
||||
<Tabbed v-bind="$attrs" class="mt-15" :side-tabs="true">
|
||||
<Tab name="basics" :label="t('harvester.network.tabs.basics')" :weight="99" class="bordered-table">
|
||||
<LabeledSelect
|
||||
v-model="type"
|
||||
class="mb-20"
|
||||
:options="networkType"
|
||||
:mode="mode"
|
||||
:label="t('harvester.fields.type')"
|
||||
required
|
||||
/>
|
||||
|
||||
<LabeledInput
|
||||
v-if="!isUntaggedNetwork"
|
||||
v-model.number="config.vlan"
|
||||
v-int-number
|
||||
class="mb-20"
|
||||
required
|
||||
type="number"
|
||||
placeholder="e.g. 1-4094"
|
||||
:label="t('tableHeaders.networkVlan')"
|
||||
:mode="mode"
|
||||
@input="input"
|
||||
/>
|
||||
|
||||
<div class="row">
|
||||
<div
|
||||
class="col span-12"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="config.bridge"
|
||||
class="mb-20"
|
||||
:label="t('harvester.network.clusterNetwork.label')"
|
||||
required
|
||||
:options="clusterNetworkOptions"
|
||||
:mode="mode"
|
||||
:placeholder="t('harvester.network.clusterNetwork.selectPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab
|
||||
v-if="!isUntaggedNetwork"
|
||||
name="layer3Network"
|
||||
:label="t('harvester.network.tabs.layer3Network')"
|
||||
:weight="98"
|
||||
class="bordered-table"
|
||||
>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model="layer3Network.mode"
|
||||
name="layer3NetworkMode"
|
||||
:label="t('harvester.network.layer3Network.mode.label')"
|
||||
:mode="mode"
|
||||
:options="modeOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="layer3Network.mode === 'auto'"
|
||||
class="row mt-10"
|
||||
>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="layer3Network.serverIPAddr"
|
||||
class="mb-20"
|
||||
:label="t('harvester.network.layer3Network.serverIPAddr.label')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row mt-10"
|
||||
>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="layer3Network.cidr"
|
||||
class="mb-20"
|
||||
:label="t('harvester.network.layer3Network.cidr.label')"
|
||||
:placeholder="t('harvester.network.layer3Network.cidr.placeholder')"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="layer3Network.gateway"
|
||||
class="mb-20"
|
||||
:label="t('harvester.network.layer3Network.gateway.label')"
|
||||
:placeholder="t('harvester.network.layer3Network.gateway.placeholder')"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,281 @@
|
||||
<script>
|
||||
import { SECRET_TYPES as TYPES } from '@shell/config/secret';
|
||||
import { MANAGEMENT, NAMESPACE, DEFAULT_WORKSPACE } from '@shell/config/types';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import {
|
||||
CLOUD_CREDENTIAL, _CLONE, _CREATE, _EDIT, _FLAGGED
|
||||
} from '@shell/config/query-params';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import Labels from '@shell/components/form/Labels';
|
||||
import { HIDE_SENSITIVE, LAST_NAMESPACE } from '@shell/store/prefs';
|
||||
import { CAPI } from '@shell/config/labels-annotations';
|
||||
import { clear } from '@shell/utils/array';
|
||||
import { importCloudCredential } from '@shell/utils/dynamic-importer';
|
||||
import SelectIconGrid from '@shell/components/SelectIconGrid';
|
||||
import { ucFirst } from '@shell/utils/string';
|
||||
|
||||
export default {
|
||||
name: 'CruSecret',
|
||||
|
||||
components: {
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
Loading,
|
||||
NameNsDescription,
|
||||
CruResource,
|
||||
Tabbed,
|
||||
Tab,
|
||||
Labels,
|
||||
SelectIconGrid
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
async fetch() {
|
||||
if ( this.isCloud ) {
|
||||
this.nodeDrivers = await this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE_DRIVER });
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const newCloudCred = this.$route.query[CLOUD_CREDENTIAL] === _FLAGGED;
|
||||
const editCloudCred = this.mode === _EDIT && this.value._type === TYPES.CLOUD_CREDENTIAL;
|
||||
const cloneCloudCred = this.realMode === _CLONE && this.liveValue._type === TYPES.CLOUD_CREDENTIAL;
|
||||
const isCloud = newCloudCred || editCloudCred || cloneCloudCred;
|
||||
|
||||
if ( newCloudCred ) {
|
||||
this.value.metadata.namespace = DEFAULT_WORKSPACE;
|
||||
|
||||
this.$set(this.value.metadata, 'name', '');
|
||||
|
||||
this.$set(this.value, 'data', {});
|
||||
}
|
||||
|
||||
const secretTypes = [
|
||||
{
|
||||
label: 'Custom',
|
||||
value: 'custom'
|
||||
},
|
||||
{
|
||||
label: 'divider',
|
||||
disabled: true,
|
||||
kind: 'divider'
|
||||
}
|
||||
];
|
||||
|
||||
Object.values(TYPES).forEach((t) => {
|
||||
secretTypes.push({
|
||||
label: t,
|
||||
value: t
|
||||
});
|
||||
});
|
||||
|
||||
if ( this.mode === _CREATE ) {
|
||||
this.$set(this.value, '_type', TYPES.OPAQUE);
|
||||
}
|
||||
|
||||
return {
|
||||
isCloud,
|
||||
nodeDrivers: null,
|
||||
secretTypes,
|
||||
secretType: this.value._type,
|
||||
initialSecretType: this.value._type
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
typeKey() {
|
||||
if ( this.isCloud ) {
|
||||
return 'cloud';
|
||||
}
|
||||
|
||||
switch ( this.value._type ) {
|
||||
case TYPES.TLS: return 'tls';
|
||||
case TYPES.BASIC: return 'basic';
|
||||
case TYPES.DOCKER_JSON: return 'registry';
|
||||
case TYPES.SSH: return 'ssh';
|
||||
}
|
||||
|
||||
return 'generic';
|
||||
},
|
||||
|
||||
dataComponent() {
|
||||
return require(`@shell/edit/secret/${ this.typeKey }`).default;
|
||||
},
|
||||
|
||||
driverName() {
|
||||
const driver = this.value.metadata?.annotations?.[CAPI.CREDENTIAL_DRIVER];
|
||||
|
||||
return driver;
|
||||
},
|
||||
|
||||
cloudComponent() {
|
||||
const driver = this.driverName;
|
||||
const haveProviders = this.$store.getters['plugins/credentialDrivers'];
|
||||
|
||||
if ( haveProviders.includes(driver) ) {
|
||||
return importCloudCredential(driver);
|
||||
}
|
||||
|
||||
return importCloudCredential('generic');
|
||||
},
|
||||
|
||||
namespaces() {
|
||||
return this.$store.getters['cluster/all'](NAMESPACE).map((obj) => {
|
||||
return {
|
||||
label: obj.nameDisplay,
|
||||
value: obj.id,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
hideSensitiveData() {
|
||||
return this.$store.getters['prefs/get'](HIDE_SENSITIVE);
|
||||
},
|
||||
|
||||
dataLabel() {
|
||||
switch (this.value._type) {
|
||||
case TYPES.TLS:
|
||||
return this.t('secret.certificate.certificate');
|
||||
case TYPES.SSH:
|
||||
return this.t('secret.ssh.keys');
|
||||
case TYPES.BASIC:
|
||||
return this.t('secret.authentication');
|
||||
default:
|
||||
return this.t('secret.data');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.registerAfterHook(() => {
|
||||
const allNamespaces = this.$store.getters['allNamespaces'];
|
||||
const defaultNamepsace = allNamespaces.find(N => N.id === 'default');
|
||||
const ns = defaultNamepsace?.id || allNamespaces?.[0]?.id || '';
|
||||
|
||||
this.value.$dispatch('prefs/set', { key: LAST_NAMESPACE, value: ns }, { root: true });
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveSecret(btnCb) {
|
||||
if ( this.errors ) {
|
||||
clear(this.errors);
|
||||
}
|
||||
|
||||
if ( typeof this.$refs.cloudComponent?.test === 'function' ) {
|
||||
try {
|
||||
const res = await this.$refs.cloudComponent.test();
|
||||
|
||||
if ( !res || res?.errors) {
|
||||
if (res?.errors) {
|
||||
this.errors = res.errors;
|
||||
} else {
|
||||
this.errors = ['Authentication test failed, please check your credentials'];
|
||||
}
|
||||
btnCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
this.errors = [e];
|
||||
btnCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return this.save(btnCb);
|
||||
},
|
||||
|
||||
typeDisplay(type, driver) {
|
||||
if ( type === CAPI.CREDENTIAL_DRIVER ) {
|
||||
return this.$store.getters['i18n/withFallback'](`cluster.provider."${ driver }"`, null, driver);
|
||||
} else {
|
||||
const fallback = type.replace(/^kubernetes.io\//, '');
|
||||
|
||||
return this.$store.getters['i18n/withFallback'](`secret.types."${ type }"`, null, fallback);
|
||||
}
|
||||
},
|
||||
|
||||
initialDisplayFor(type) {
|
||||
const fallback = (ucFirst(this.typeDisplay(type) || '').replace(/[^A-Z]/g, '') || type).substr(0, 3);
|
||||
|
||||
return this.$store.getters['i18n/withFallback'](`secret.initials."${ type }"`, null, fallback);
|
||||
},
|
||||
|
||||
selectCustomType(type) {
|
||||
if (type !== 'custom') {
|
||||
this.$set(this.value, '_type', type);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="filled-height">
|
||||
<Loading v-if="$fetchState.pending" />
|
||||
<CruResource
|
||||
v-else
|
||||
:mode="mode"
|
||||
:validation-passed="true"
|
||||
:selected-subtype="value._type"
|
||||
:resource="value"
|
||||
:errors="errors"
|
||||
@finish="saveSecret"
|
||||
@error="e=>errors = e"
|
||||
>
|
||||
<NameNsDescription
|
||||
v-model="value"
|
||||
:mode="mode"
|
||||
:namespaced="!isCloud"
|
||||
/>
|
||||
|
||||
<div class="spacer" />
|
||||
<component
|
||||
:is="cloudComponent"
|
||||
v-if="isCloud"
|
||||
ref="cloudComponent"
|
||||
:driver-name="driverName"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:hide-sensitive-data="hideSensitiveData"
|
||||
/>
|
||||
<Tabbed
|
||||
v-else
|
||||
:side-tabs="true"
|
||||
default-tab="data"
|
||||
>
|
||||
<Tab
|
||||
name="data"
|
||||
:label="dataLabel"
|
||||
:weight="99"
|
||||
>
|
||||
<component
|
||||
:is="dataComponent"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:hide-sensitive-data="hideSensitiveData"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
name="labels"
|
||||
label-key="generic.labelsAndAnnotations"
|
||||
:weight="-1"
|
||||
>
|
||||
<Labels
|
||||
v-model="value"
|
||||
:mode="mode"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,255 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { TextAreaAutoGrow } from '@components/Form/TextArea';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
import { HCI_ALLOWED_SETTINGS, HCI_SINGLE_CLUSTER_ALLOWED_SETTING, HCI_SETTING } from '../config/settings';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CruResource,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
RadioGroup,
|
||||
TextAreaAutoGrow
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
data() {
|
||||
const t = this.$store.getters['i18n/t'];
|
||||
const setting =
|
||||
HCI_ALLOWED_SETTINGS[this.value.id] ||
|
||||
HCI_SINGLE_CLUSTER_ALLOWED_SETTING[this.value.id];
|
||||
|
||||
let enumOptions = [];
|
||||
|
||||
if (setting.kind === 'enum') {
|
||||
enumOptions = setting.options.map(id => ({
|
||||
label: `advancedSettings.enum.harv-${ this.value.id }.${ id }`,
|
||||
value: id
|
||||
}));
|
||||
}
|
||||
|
||||
const canReset =
|
||||
setting.canReset || !!this.value.default || this.value.canReset;
|
||||
|
||||
if (this.value.value === undefined) {
|
||||
this.$set(this.value, 'value', null);
|
||||
}
|
||||
|
||||
this.value.value = this.value.value || this.value.default || '';
|
||||
const oldValue = this.value.value;
|
||||
|
||||
const isHarvester = this.value?.type?.includes('harvesterhci');
|
||||
|
||||
// Get all the custom volume types from the file names of this folder
|
||||
const customSettingComponents = require
|
||||
.context('../components/settings', false, /^.*\.vue$/)
|
||||
.keys()
|
||||
.map(path => path.replace(/(\.\/)|(.vue)/g, ''));
|
||||
|
||||
return {
|
||||
setting,
|
||||
description: isHarvester ? t(`advancedSettings.descriptions.harv-${ this.value.id }`) : t(`advancedSettings.descriptions.${ this.value.id }`),
|
||||
editHelp: t(`advancedSettings.editHelp.${ this.value.id }`),
|
||||
enumOptions,
|
||||
canReset,
|
||||
errors: [],
|
||||
hasCustomComponent: false,
|
||||
customComponent: null,
|
||||
customSettingComponents,
|
||||
oldValue
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
doneLocationOverride() {
|
||||
return this.value.doneOverride;
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
let customComponent = false;
|
||||
const hasCustomComponent = this.customSettingComponents.includes(this.value.id);
|
||||
|
||||
if ( hasCustomComponent ) {
|
||||
try {
|
||||
customComponent = require(`../components/settings/${ this.value.id }.vue`).default;
|
||||
} catch {}
|
||||
} else {
|
||||
// Some resources like vlan and network go out to a non-standard location (edit/<resource>/<id>.vue). For example
|
||||
// 'edit/harvesterhci.io.managedchart/rancher-monitoring.vue'
|
||||
const resource = this.$route.params.resource;
|
||||
const name = this.value.metadata.name;
|
||||
|
||||
try {
|
||||
customComponent = require(`./${ resource }/${ name }.vue`).default;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.hasCustomComponent = !!customComponent;
|
||||
this.customComponent = customComponent;
|
||||
|
||||
this.registerAfterHook(() => {
|
||||
if (this.value.id === HCI_SETTING.RANCHER_MANAGER_SUPPORT) {
|
||||
this.$store.commit('isRancherInHarvester', this.value.value === 'true');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
done() {
|
||||
this.$router.go(-1);
|
||||
},
|
||||
|
||||
async saveSettings(done) {
|
||||
const t = this.$store.getters['i18n/t'];
|
||||
|
||||
// Validate the JSON if the setting is a json value
|
||||
if (this.setting.kind === 'json' && this.value.default) {
|
||||
try {
|
||||
JSON.parse(this.value.value);
|
||||
this.errors = [];
|
||||
} catch (e) {
|
||||
this.errors = [t('advancedSettings.edit.invalidJSON')];
|
||||
|
||||
return done(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.value.metadata.name === HCI_SETTING.CLUSTER_REGISTRATION_URL && this.oldValue && this.value.value !== this.oldValue) {
|
||||
await this.clusterRegistrationUrlTip();
|
||||
}
|
||||
|
||||
this.save(done);
|
||||
},
|
||||
|
||||
clusterRegistrationUrlTip() {
|
||||
return new Promise((resolve) => {
|
||||
this.$store.dispatch('harvester/promptModal', {
|
||||
component: 'MessageBox',
|
||||
callback: (action) => {
|
||||
if (action === 'ok') {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
contentKey: 'harvester.setting.clusterRegistrationUrl.message'
|
||||
}, { root: true });
|
||||
});
|
||||
},
|
||||
|
||||
useDefault(ev) {
|
||||
// Lose the focus on the button after click
|
||||
if (ev && ev.srcElement) {
|
||||
ev.srcElement.blur();
|
||||
}
|
||||
|
||||
if (this.value.id === HCI_SETTING.VLAN) {
|
||||
this.value.enable = false;
|
||||
if (this.value.config) {
|
||||
this.value.config.defaultPhysicalNIC = '';
|
||||
}
|
||||
} else {
|
||||
this.value.value = this.value.default || '';
|
||||
}
|
||||
|
||||
if (typeof this.$refs.settingComp?.useDefault === 'function') {
|
||||
this.$refs.settingComp.useDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
class="route"
|
||||
:errors="errors"
|
||||
:mode="mode"
|
||||
:resource="value"
|
||||
:subtypes="[]"
|
||||
:can-yaml="false"
|
||||
:cancel-event="true"
|
||||
@error="e => (errors = e)"
|
||||
@finish="saveSettings"
|
||||
@cancel="done"
|
||||
>
|
||||
<h4 v-clean-html="description"></h4>
|
||||
|
||||
<h5 v-if="editHelp" v-clean-html="editHelp" class="edit-help" />
|
||||
|
||||
<div class="edit-change mt-20">
|
||||
<h5 v-t="'advancedSettings.edit.changeSetting'" />
|
||||
<button
|
||||
:disabled="!canReset"
|
||||
type="button"
|
||||
class="btn role-primary"
|
||||
@click="useDefault"
|
||||
>
|
||||
{{ t('advancedSettings.edit.useDefault') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-20">
|
||||
<div v-if="setting.from === 'import'">
|
||||
<component
|
||||
:is="customComponent"
|
||||
v-if="hasCustomComponent"
|
||||
ref="settingComp"
|
||||
v-model="value"
|
||||
:register-before-hook="registerBeforeHook"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="setting.kind === 'enum'">
|
||||
<LabeledSelect
|
||||
v-model="value.value"
|
||||
:label="t('advancedSettings.edit.value')"
|
||||
:localized-label="true"
|
||||
:mode="mode"
|
||||
:options="enumOptions"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="setting.kind === 'boolean'">
|
||||
<RadioGroup
|
||||
v-model="value.value"
|
||||
name="settings_value"
|
||||
:labels="[
|
||||
t('advancedSettings.edit.trueOption'),
|
||||
t('advancedSettings.edit.falseOption')
|
||||
]"
|
||||
:options="['true', 'false']"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="setting.kind === 'multiline' || setting.kind === 'json'">
|
||||
<TextAreaAutoGrow v-model="value.value" :min-height="254" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<LabeledInput
|
||||
v-model="value.value"
|
||||
:label="t('advancedSettings.edit.value')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-change {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
> h5 {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .edit-help code {
|
||||
padding: 1px 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<script>
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import Tags from '../../components/DiskTags';
|
||||
import ArrayList from '@shell/components/form/ArrayList';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
|
||||
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import Loading from '@shell/components/Loading';
|
||||
|
||||
import { _CREATE, _VIEW } from '@shell/config/query-params';
|
||||
import { mapFeature, UNSUPPORTED_STORAGE_DRIVERS } from '@shell/store/features';
|
||||
import { STORAGE_CLASS, LONGHORN } from '@shell/config/types';
|
||||
import { CSI_DRIVER } from '../../types';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { clone } from '@shell/utils/object';
|
||||
|
||||
const LONGHORN_DRIVER = 'driver.longhorn.io';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterStorage',
|
||||
|
||||
components: {
|
||||
ArrayList,
|
||||
CruResource,
|
||||
LabeledSelect,
|
||||
LabeledInput,
|
||||
NameNsDescription,
|
||||
RadioGroup,
|
||||
Tab,
|
||||
Tabbed,
|
||||
Loading,
|
||||
Tags,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
data() {
|
||||
const reclaimPolicyOptions = [{
|
||||
label: this.t('storageClass.customize.reclaimPolicy.delete'),
|
||||
value: 'Delete'
|
||||
}, {
|
||||
label: this.t('storageClass.customize.reclaimPolicy.retain'),
|
||||
value: 'Retain'
|
||||
}];
|
||||
|
||||
const allowVolumeExpansionOptions = [
|
||||
{
|
||||
label: this.t('generic.enabled'),
|
||||
value: true
|
||||
},
|
||||
{
|
||||
label: this.t('generic.disabled'),
|
||||
value: false
|
||||
}
|
||||
];
|
||||
|
||||
const volumeBindingModeOptions = [
|
||||
{
|
||||
label: this.t('storageClass.customize.volumeBindingMode.now'),
|
||||
value: 'Immediate'
|
||||
},
|
||||
{
|
||||
label: this.t('harvester.storage.customize.volumeBindingMode.later'),
|
||||
value: 'WaitForFirstConsumer'
|
||||
}
|
||||
];
|
||||
|
||||
const allowedTopologies = clone(this.value.allowedTopologies?.[0]?.matchLabelExpressions || []);
|
||||
|
||||
this.$set(this.value, 'parameters', this.value.parameters || {});
|
||||
this.$set(this.value, 'provisioner', this.value.provisioner || LONGHORN_DRIVER);
|
||||
this.$set(this.value, 'allowVolumeExpansion', this.value.allowVolumeExpansion || allowVolumeExpansionOptions[0].value);
|
||||
this.$set(this.value, 'reclaimPolicy', this.value.reclaimPolicy || reclaimPolicyOptions[0].value);
|
||||
this.$set(this.value, 'volumeBindingMode', this.value.volumeBindingMode || volumeBindingModeOptions[0].value);
|
||||
|
||||
return {
|
||||
reclaimPolicyOptions,
|
||||
allowVolumeExpansionOptions,
|
||||
volumeBindingModeOptions,
|
||||
mountOptions: [],
|
||||
provisioner: LONGHORN_DRIVER,
|
||||
STORAGE_CLASS,
|
||||
allowedTopologies,
|
||||
defaultAddValue: {
|
||||
key: '',
|
||||
values: [],
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const hash = {
|
||||
storages: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }),
|
||||
longhornNodes: this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.NODES }),
|
||||
csiDrivers: this.$store.dispatch(`${ inStore }/findAll`, { type: CSI_DRIVER }),
|
||||
};
|
||||
|
||||
await allHash(hash);
|
||||
},
|
||||
|
||||
computed: {
|
||||
showUnsupportedStorage: mapFeature(UNSUPPORTED_STORAGE_DRIVERS),
|
||||
|
||||
inStore() {
|
||||
return this.$store.getters['currentProduct'].inStore;
|
||||
},
|
||||
|
||||
modeOverride() {
|
||||
return this.isCreate ? _CREATE : _VIEW;
|
||||
},
|
||||
|
||||
provisionerWatch() {
|
||||
return this.value.provisioner;
|
||||
},
|
||||
|
||||
provisioners() {
|
||||
const csiDrivers = this.$store.getters[`${ this.inStore }/all`](CSI_DRIVER) || [];
|
||||
const format = { [LONGHORN_DRIVER]: 'storageClass.longhorn.title' };
|
||||
|
||||
return csiDrivers.map((provisioner) => {
|
||||
return {
|
||||
label: format[provisioner.name] || provisioner.name,
|
||||
value: provisioner.name,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
schema() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return this.$store.getters[`${ inStore }/schemaFor`](STORAGE_CLASS);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
provisionerWatch() {
|
||||
this.$set(this.value, 'parameters', {});
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.registerBeforeHook(this.willSave, 'willSave');
|
||||
},
|
||||
|
||||
methods: {
|
||||
getComponent(name) {
|
||||
try {
|
||||
return require(`./provisioners/${ name }`).default;
|
||||
} catch {
|
||||
return require(`./provisioners/custom`).default;
|
||||
}
|
||||
},
|
||||
|
||||
updateProvisioner(provisioner) {
|
||||
this.$set(this.value, 'provisioner', provisioner);
|
||||
this.$set(this.value, 'allowVolumeExpansion', provisioner === LONGHORN_DRIVER);
|
||||
},
|
||||
|
||||
willSave() {
|
||||
Object.keys(this.value.parameters).forEach((key) => {
|
||||
if (this.value.parameters[key] === null || this.value.parameters[key] === '') {
|
||||
delete this.value.parameters[key];
|
||||
}
|
||||
});
|
||||
|
||||
this.formatAllowedTopoloties();
|
||||
},
|
||||
|
||||
formatAllowedTopoloties() {
|
||||
const neu = this.allowedTopologies;
|
||||
|
||||
if (!neu || neu.length === 0) {
|
||||
delete this.value.allowedTopologies;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const matchLabelExpressions = neu.filter(R => !!R.key.trim() && (R.values.length > 0 && !R.values.find(V => !V.trim())));
|
||||
|
||||
if (matchLabelExpressions.length > 0) {
|
||||
this.value.allowedTopologies = [{ matchLabelExpressions }];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="$fetchState.pending" />
|
||||
<CruResource
|
||||
v-else
|
||||
:done-route="doneRoute"
|
||||
:mode="mode"
|
||||
:resource="value"
|
||||
:subtypes="[]"
|
||||
:validation-passed="true"
|
||||
:apply-hooks="applyHooks"
|
||||
:errors="errors"
|
||||
@error="e=>errors = e"
|
||||
@finish="save"
|
||||
@cancel="done"
|
||||
>
|
||||
<NameNsDescription
|
||||
:namespaced="false"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:register-before-hook="registerBeforeHook"
|
||||
/>
|
||||
<LabeledSelect
|
||||
:value="value.provisioner"
|
||||
label="Provisioner"
|
||||
:options="provisioners"
|
||||
:localized-label="true"
|
||||
:mode="modeOverride"
|
||||
:searchable="true"
|
||||
:taggable="true"
|
||||
class="mb-20"
|
||||
@input="updateProvisioner($event)"
|
||||
/>
|
||||
<Tabbed :side-tabs="true">
|
||||
<Tab name="parameters" :label="t('storageClass.parameters.label')" :weight="2">
|
||||
<component
|
||||
:is="getComponent(value.provisioner)"
|
||||
:key="value.provisioner"
|
||||
:value="value"
|
||||
:mode="modeOverride"
|
||||
:real-mode="realMode"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab name="customize" :label="t('storageClass.customize.label')">
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model="value.reclaimPolicy"
|
||||
name="reclaimPolicy"
|
||||
:label="t('storageClass.customize.reclaimPolicy.label')"
|
||||
:mode="modeOverride"
|
||||
:options="reclaimPolicyOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model="value.allowVolumeExpansion"
|
||||
name="allowVolumeExpansion"
|
||||
:label="t('storageClass.customize.allowVolumeExpansion.label')"
|
||||
:mode="modeOverride"
|
||||
:options="allowVolumeExpansionOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model="value.volumeBindingMode"
|
||||
name="volumeBindingMode"
|
||||
:label="t('storageClass.customize.volumeBindingMode.label')"
|
||||
:mode="modeOverride"
|
||||
:options="volumeBindingModeOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab
|
||||
name="allowedTopologies"
|
||||
:label="t('harvester.storage.allowedTopologies.title')"
|
||||
:weight="-1"
|
||||
:tooltip="t('harvester.storage.allowedTopologies.tooltip')"
|
||||
>
|
||||
<ArrayList
|
||||
v-model="allowedTopologies"
|
||||
:default-add-value="defaultAddValue"
|
||||
:initial-empty-row="true"
|
||||
:show-header="true"
|
||||
:mode="modeOverride"
|
||||
>
|
||||
<template v-slot:column-headers>
|
||||
<div class="box">
|
||||
<div class="row">
|
||||
<div class="col span-4 key">
|
||||
{{ t('generic.key') }}
|
||||
<span class="required">*</span>
|
||||
</div>
|
||||
<div class="col span-8 value">
|
||||
{{ t('generic.value') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:columns="scope">
|
||||
<div class="row custom-headers">
|
||||
<div class="col span-4 key">
|
||||
<LabeledInput
|
||||
v-model="scope.row.value.key"
|
||||
:required="true"
|
||||
:mode="modeOverride"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-8 value">
|
||||
<Tags
|
||||
v-model="scope.row.value.values"
|
||||
:add-label="t('generic.add')"
|
||||
:mode="modeOverride"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ArrayList>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-headers {
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script>
|
||||
import KeyValue from '@shell/components/form/KeyValue';
|
||||
|
||||
export default {
|
||||
components: { KeyValue },
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<KeyValue
|
||||
v-model="value.parameters"
|
||||
:add-label="t('storageClass.custom.addLabel')"
|
||||
:read-allowed="false"
|
||||
:mode="mode"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,249 @@
|
||||
<script>
|
||||
import KeyValue from '@shell/components/form/KeyValue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import RadioGroup from '@components/Form/Radio/RadioGroup';
|
||||
|
||||
import { _CREATE, _VIEW } from '@shell/config/query-params';
|
||||
import { LONGHORN } from '@shell/config/types';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { uniq } from '@shell/utils/array';
|
||||
|
||||
const DEFAULT_PARAMETERS = [
|
||||
'numberOfReplicas',
|
||||
'staleReplicaTimeout',
|
||||
'diskSelector',
|
||||
'nodeSelector',
|
||||
'migratable',
|
||||
];
|
||||
|
||||
export default {
|
||||
components: {
|
||||
KeyValue,
|
||||
LabeledSelect,
|
||||
LabeledInput,
|
||||
RadioGroup,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
realMode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
if (this.realMode === _CREATE) {
|
||||
this.$set(this.value, 'parameters', {
|
||||
numberOfReplicas: '3',
|
||||
staleReplicaTimeout: '30',
|
||||
diskSelector: null,
|
||||
nodeSelector: null,
|
||||
migratable: 'true',
|
||||
});
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
|
||||
computed: {
|
||||
longhornNodes() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return this.$store.getters[`${ inStore }/all`](LONGHORN.NODES);
|
||||
},
|
||||
|
||||
nodeTags() {
|
||||
return (this.longhornNodes || []).reduce((sum, node) => {
|
||||
const tags = node.spec?.tags || [];
|
||||
|
||||
return uniq([...sum, ...tags]);
|
||||
}, []);
|
||||
},
|
||||
|
||||
diskTags() {
|
||||
return (this.longhornNodes || []).reduce((sum, node) => {
|
||||
const disks = node.spec?.disks;
|
||||
|
||||
const tagsOfNode = Object.keys(disks).reduce((sum, key) => {
|
||||
const tags = disks[key]?.tags || [];
|
||||
|
||||
return uniq([...sum, ...tags]);
|
||||
}, []);
|
||||
|
||||
return uniq([...sum, ...tagsOfNode]);
|
||||
}, []);
|
||||
},
|
||||
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
|
||||
migratableOptions() {
|
||||
return [{
|
||||
label: this.t('generic.yes'),
|
||||
value: 'true'
|
||||
}, {
|
||||
label: this.t('generic.no'),
|
||||
value: 'false'
|
||||
}];
|
||||
},
|
||||
|
||||
parameters: {
|
||||
get() {
|
||||
const parameters = clone(this.value?.parameters) || {};
|
||||
|
||||
DEFAULT_PARAMETERS.map((key) => {
|
||||
delete parameters[key];
|
||||
});
|
||||
|
||||
return parameters;
|
||||
},
|
||||
|
||||
set(value) {
|
||||
Object.assign(this.value.parameters, value);
|
||||
}
|
||||
},
|
||||
|
||||
nodeSelector: {
|
||||
get() {
|
||||
const nodeSelector = this.value?.parameters?.nodeSelector;
|
||||
|
||||
if ((nodeSelector || '').includes(',')) {
|
||||
return nodeSelector.split(',');
|
||||
} else if (nodeSelector) {
|
||||
return [nodeSelector];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
set(value) {
|
||||
this.value.parameters.nodeSelector = (value || []).join(',');
|
||||
}
|
||||
},
|
||||
|
||||
diskSelector: {
|
||||
get() {
|
||||
const diskSelector = this.value?.parameters?.diskSelector;
|
||||
|
||||
if ((diskSelector || '').includes(',')) {
|
||||
return diskSelector.split(',');
|
||||
} else if (diskSelector) {
|
||||
return [diskSelector];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
set(value) {
|
||||
this.value.parameters.diskSelector = (value || []).join(',');
|
||||
}
|
||||
},
|
||||
|
||||
numberOfReplicas: {
|
||||
get() {
|
||||
return this.value?.parameters?.numberOfReplicas;
|
||||
},
|
||||
|
||||
set(value) {
|
||||
if (value >= 1 && value <= 3) {
|
||||
this.value.parameters.numberOfReplicas = String(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="numberOfReplicas"
|
||||
:label="t('harvester.storage.parameters.numberOfReplicas.label')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
min="1"
|
||||
max="3"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="value.parameters.staleReplicaTimeout"
|
||||
:label="t('harvester.storage.parameters.staleReplicaTimeout.label')"
|
||||
:required="true"
|
||||
:mode="mode"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="nodeSelector"
|
||||
:label="t('harvester.storage.parameters.nodeSelector.label')"
|
||||
:options="nodeTags"
|
||||
:taggable="true"
|
||||
:multiple="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<template #no-options="{ searching }">
|
||||
<span v-if="!searching" class="text-muted">
|
||||
{{ t('harvester.storage.parameters.nodeSelector.no-options', null, true) }}
|
||||
</span>
|
||||
</template>
|
||||
</LabeledSelect>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="diskSelector"
|
||||
:label="t('harvester.storage.parameters.diskSelector.label')"
|
||||
:options="diskTags"
|
||||
:taggable="true"
|
||||
:multiple="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<template #no-options="{ searching }">
|
||||
<span v-if="!searching" class="text-muted">
|
||||
{{ t('harvester.storage.parameters.diskSelector.no-options', null, true) }}
|
||||
</span>
|
||||
</template>
|
||||
</LabeledSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model="value.parameters.migratable"
|
||||
name="layer3NetworkMode"
|
||||
:label="t('harvester.storage.parameters.migratable.label')"
|
||||
:mode="mode"
|
||||
:options="migratableOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<KeyValue
|
||||
v-model="parameters"
|
||||
:add-label="t('storageClass.longhorn.addLabel')"
|
||||
:read-allowed="false"
|
||||
:mode="mode"
|
||||
class="mt-10"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.labeled-input.compact-input {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<script>
|
||||
import Footer from '@shell/components/form/Footer';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import Checkbox from '@components/Form/Checkbox/Checkbox';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { HCI } from '../types';
|
||||
import { BACKUP_TYPE } from '../config/types';
|
||||
|
||||
const createObject = {
|
||||
apiVersion: 'harvesterhci.io/v1beta1',
|
||||
kind: 'VirtualMachineRestore',
|
||||
metadata: { name: '', namespace: '' },
|
||||
type: HCI.RESTORE,
|
||||
spec: {
|
||||
target: {
|
||||
apiGroup: 'kubevirt.io',
|
||||
kind: 'VirtualMachine',
|
||||
name: ''
|
||||
},
|
||||
virtualMachineBackupName: '',
|
||||
newVM: true,
|
||||
deletionPolicy: 'delete'
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'CreateRestore',
|
||||
components: {
|
||||
Checkbox,
|
||||
Footer,
|
||||
RadioGroup,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
async fetch() {
|
||||
await allHash({
|
||||
backups: this.$store.dispatch('harvester/findAll', { type: HCI.BACKUP }),
|
||||
vms: this.$store.dispatch('harvester/findAll', { type: HCI.VM }),
|
||||
});
|
||||
},
|
||||
|
||||
data() {
|
||||
const restoreMode = this.$route.query?.restoreMode;
|
||||
const backupName = this.$route.query?.resourceName;
|
||||
|
||||
const restoreResource = clone(createObject);
|
||||
|
||||
const restoreNewVm = restoreMode === 'new' || restoreMode === undefined;
|
||||
|
||||
return {
|
||||
backupName,
|
||||
restoreNewVm,
|
||||
restoreResource,
|
||||
name: '',
|
||||
description: '',
|
||||
deletionPolicy: 'delete',
|
||||
namespace: ''
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
backupOption() {
|
||||
const choices = this.$store.getters['harvester/all'](HCI.BACKUP);
|
||||
|
||||
return choices.filter( (T) => {
|
||||
const hasVM = this.restoreNewVm || T.attachVmExisting;
|
||||
|
||||
return hasVM && T?.status?.readyToUse && T.spec?.type !== BACKUP_TYPE.SNAPSHOT;
|
||||
}).map( (T) => {
|
||||
return {
|
||||
label: T.metadata.name,
|
||||
value: T.metadata.name
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deletionPolicyOption() {
|
||||
return [{
|
||||
value: 'delete',
|
||||
label: 'Delete'
|
||||
}, {
|
||||
value: 'retain',
|
||||
label: 'Retain'
|
||||
}];
|
||||
},
|
||||
|
||||
currentBackupResource() {
|
||||
const name = this.backupName;
|
||||
|
||||
const backupList = this.$store.getters['harvester/all'](HCI.BACKUP);
|
||||
|
||||
return backupList.find( O => O.name === name);
|
||||
},
|
||||
|
||||
disableExisting() {
|
||||
return !this.currentBackupResource?.attachVmExisting;
|
||||
},
|
||||
|
||||
backupNamespace() {
|
||||
const backupList = this.$store.getters['harvester/all'](HCI.BACKUP);
|
||||
|
||||
return backupList.find( B => B.metadata.name === this.backupName)?.metadata?.namespace;
|
||||
},
|
||||
|
||||
namespaces() {
|
||||
const allNamespaces = this.$store.getters['allNamespaces'];
|
||||
|
||||
const out = sortBy(
|
||||
allNamespaces.map((obj) => {
|
||||
return {
|
||||
label: obj.nameDisplay,
|
||||
value: obj.id,
|
||||
};
|
||||
}),
|
||||
'label'
|
||||
);
|
||||
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
backupName: {
|
||||
handler(neu) {
|
||||
if (this.currentBackupResource) {
|
||||
if (!this.restoreNewVm) {
|
||||
this.name = this?.currentBackupResource?.attachVM;
|
||||
}
|
||||
}
|
||||
|
||||
this.restoreResource.spec.virtualMachineBackupName = neu;
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
|
||||
restoreNewVm(neu) {
|
||||
if (neu) {
|
||||
this.name = '';
|
||||
} else {
|
||||
this.name = this?.currentBackupResource?.attachVM;
|
||||
}
|
||||
},
|
||||
|
||||
backupNamespace: {
|
||||
handler(neu) {
|
||||
this.namespace = neu;
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveRestore(buttonCb) {
|
||||
this.update();
|
||||
|
||||
const proxyResource = await this.$store.dispatch('harvester/create', this.restoreResource);
|
||||
|
||||
proxyResource.metadata.namespace = this.namespace;
|
||||
proxyResource.spec.virtualMachineBackupNamespace = this.backupNamespace;
|
||||
|
||||
try {
|
||||
await proxyResource.save();
|
||||
buttonCb(true);
|
||||
|
||||
this.$router.push({
|
||||
name: this.doneRoute,
|
||||
params: { resource: HCI.VM }
|
||||
});
|
||||
} catch (err) {
|
||||
this.errors = exceptionToErrorsArray(err) || err;
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
update() {
|
||||
this.restoreResource.metadata.generateName = `restore-${ this.backupName }-`;
|
||||
if (this.name) {
|
||||
this.restoreResource.spec.target.name = this.name;
|
||||
}
|
||||
|
||||
if (this.restoreNewVm) {
|
||||
delete this.restoreResource.spec.deletionPolicy;
|
||||
this.restoreResource.spec.newVM = true;
|
||||
} else {
|
||||
this.restoreResource.spec.deletionPolicy = this.deletionPolicy;
|
||||
delete this.restoreResource.spec.newVM;
|
||||
delete this.restoreResource.spec.keepMacAddress;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
componentTitle() {
|
||||
return 'restoreVM';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="restore">
|
||||
<div class="content">
|
||||
<div class="mb-20">
|
||||
<RadioGroup
|
||||
v-model="restoreNewVm"
|
||||
name="model"
|
||||
:options="[true,false]"
|
||||
:labels="[t('harvester.backup.restore.createNew'), t('harvester.backup.restore.replaceExisting')]"
|
||||
:disabled="disableExisting"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="namespace"
|
||||
:disabled="!restoreNewVm"
|
||||
:label="t('nameNsDescription.namespace.label')"
|
||||
:options="namespaces"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="name"
|
||||
:disabled="!restoreNewVm"
|
||||
:label="t('harvester.backup.restore.virtualMachineName')"
|
||||
:placeholder="t('nameNsDescription.name.placeholder')"
|
||||
class="mb-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LabeledSelect
|
||||
v-model="backupName"
|
||||
class="mb-20"
|
||||
:label="t('harvester.backup.restore.backup')"
|
||||
:options="backupOption"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-if="restoreNewVm"
|
||||
v-model="restoreResource.spec.keepMacAddress"
|
||||
type="checkbox"
|
||||
:label="t('harvester.backup.restore.keepMacAddress')"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-if="!restoreNewVm"
|
||||
v-model="deletionPolicy"
|
||||
:label="t('harvester.backup.restore.deletePreviousVolumes')"
|
||||
:options="deletionPolicyOption"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Footer mode="create" class="footer" :errors="errors" @save="saveRestore" @done="done" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#restore {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: var(--header-border-size) solid var(--header-border);
|
||||
|
||||
// Overrides outlet padding
|
||||
margin-left: -$space-m;
|
||||
margin-right: -$space-m;
|
||||
margin-bottom: -$space-m;
|
||||
padding: $space-s $space-m;
|
||||
|
||||
::v-deep .spacer-small {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,446 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import KeyValue from '@shell/components/form/KeyValue';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import Select from '@shell/components/form/Select';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { OS } from '../mixins/harvester-vm';
|
||||
import { VM_IMAGE_FILE_FORMAT } from '../validators/vm-image';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { STORAGE_CLASS } from '@shell/config/types';
|
||||
import { HCI } from '../types';
|
||||
|
||||
const DOWNLOAD = 'download';
|
||||
const UPLOAD = 'upload';
|
||||
const rawORqcow2 = 'raw_qcow2';
|
||||
|
||||
export default {
|
||||
name: 'EditImage',
|
||||
|
||||
components: {
|
||||
Tab,
|
||||
Tabbed,
|
||||
KeyValue,
|
||||
Select,
|
||||
CruResource,
|
||||
LabeledInput,
|
||||
NameNsDescription,
|
||||
RadioGroup,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({
|
||||
images: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IMAGE }),
|
||||
storageClasses: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }),
|
||||
});
|
||||
|
||||
const defaultStorage = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS).find(s => s.isDefault);
|
||||
|
||||
this.$set(this, 'storageClassName', this.storageClassName || defaultStorage?.metadata?.name || 'longhorn');
|
||||
},
|
||||
|
||||
data() {
|
||||
if ( !this.value.spec ) {
|
||||
this.$set(this.value, 'spec', { sourceType: DOWNLOAD });
|
||||
}
|
||||
|
||||
if (!this.value.metadata.name) {
|
||||
this.value.metadata.generateName = 'image-';
|
||||
}
|
||||
|
||||
return {
|
||||
url: this.value.spec.url,
|
||||
files: [],
|
||||
resource: '',
|
||||
headers: {},
|
||||
fileUrl: '',
|
||||
file: '',
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
uploadFileName() {
|
||||
return this.file?.name || '';
|
||||
},
|
||||
|
||||
imageName() {
|
||||
return this.value?.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_NAME] || '-';
|
||||
},
|
||||
|
||||
isCreateEdit() {
|
||||
return this.isCreate || this.isEdit;
|
||||
},
|
||||
|
||||
showEditAsYaml() {
|
||||
return this.value.spec.sourceType === DOWNLOAD;
|
||||
},
|
||||
|
||||
storageClassOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const storages = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS);
|
||||
|
||||
const out = storages.filter(s => !s.parameters?.backingImage).map((s) => {
|
||||
const label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
|
||||
|
||||
return {
|
||||
label,
|
||||
value: s.name,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
storageClassName: {
|
||||
get() {
|
||||
return this.value.metadata.annotations[HCI_ANNOTATIONS.STORAGE_CLASS];
|
||||
},
|
||||
|
||||
set(nue) {
|
||||
this.value.metadata.annotations[HCI_ANNOTATIONS.STORAGE_CLASS] = nue;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'value.spec.url'(neu) {
|
||||
const url = neu.trim();
|
||||
|
||||
this.setImageLabels(url);
|
||||
},
|
||||
|
||||
'value.spec.sourceType'() {
|
||||
this.$set(this, 'file', null);
|
||||
this.url = '';
|
||||
|
||||
if (this.$refs?.file?.value) {
|
||||
this.$refs.file.value = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveImage(buttonCb) {
|
||||
this.value.spec.displayName = (this.value.spec.displayName || '').trim();
|
||||
|
||||
if (this.value.spec.sourceType === UPLOAD && this.isCreate) {
|
||||
try {
|
||||
this.value.spec.url = '';
|
||||
|
||||
const file = this.file;
|
||||
|
||||
this.value.metadata.annotations[HCI_ANNOTATIONS.IMAGE_NAME] = file?.name;
|
||||
|
||||
const res = await this.value.save();
|
||||
|
||||
res.uploadImage(file);
|
||||
|
||||
buttonCb(true);
|
||||
this.done();
|
||||
} catch (e) {
|
||||
this.errors = exceptionToErrorsArray(e);
|
||||
buttonCb(false);
|
||||
}
|
||||
} else {
|
||||
this.save(buttonCb);
|
||||
}
|
||||
},
|
||||
|
||||
setImageLabels(str) {
|
||||
const suffixName = str?.split('/')?.pop() || str;
|
||||
|
||||
const fileSuffix = suffixName?.split('.')?.pop()?.toLowerCase();
|
||||
|
||||
if (VM_IMAGE_FILE_FORMAT.includes(fileSuffix)) {
|
||||
const labelValue = fileSuffix === 'iso' ? fileSuffix : rawORqcow2;
|
||||
|
||||
this.addLabel(HCI_ANNOTATIONS.IMAGE_SUFFIX, labelValue);
|
||||
|
||||
if (!this.value.spec.displayName) {
|
||||
this.$refs.nd.changeNameAndNamespace({
|
||||
text: suffixName,
|
||||
selected: this.value.metadata.namespace,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const os = this.getOSType(str);
|
||||
|
||||
if (os) {
|
||||
this.addLabel(HCI_ANNOTATIONS.OS_TYPE, os.value);
|
||||
}
|
||||
},
|
||||
|
||||
addLabel(labelKey, value) {
|
||||
const rows = this.$refs.labels.rows;
|
||||
|
||||
rows.map((label, idx) => {
|
||||
if (label.key === labelKey) {
|
||||
this.$refs.labels.remove(idx);
|
||||
}
|
||||
});
|
||||
this.$refs.labels.add(labelKey, value);
|
||||
},
|
||||
|
||||
handleFileUpload() {
|
||||
const file = this.$refs.file.files[0];
|
||||
|
||||
this.file = file;
|
||||
|
||||
this.setImageLabels(file?.name);
|
||||
|
||||
if (!this.value.spec.displayName) {
|
||||
this.$refs.nd.changeNameAndNamespace({
|
||||
text: file?.name,
|
||||
selected: this.value.metadata.namespace,
|
||||
});
|
||||
}
|
||||
|
||||
this.setImageLabels();
|
||||
},
|
||||
|
||||
selectFile() {
|
||||
// Clear the value so the user can reselect the same file again
|
||||
this.$refs.file.value = null;
|
||||
this.$refs.file.click();
|
||||
},
|
||||
|
||||
internalAnnotations(option) {
|
||||
const optionKeys = [HCI_ANNOTATIONS.OS_TYPE, HCI_ANNOTATIONS.IMAGE_SUFFIX];
|
||||
|
||||
return optionKeys.find(O => O === option.key);
|
||||
},
|
||||
|
||||
calculateOptions(keyName) {
|
||||
if (keyName === HCI_ANNOTATIONS.OS_TYPE) {
|
||||
return OS;
|
||||
} else if (keyName === HCI_ANNOTATIONS.IMAGE_SUFFIX) {
|
||||
return [{
|
||||
label: 'ISO',
|
||||
value: 'iso'
|
||||
}, {
|
||||
label: 'raw/qcow2',
|
||||
value: rawORqcow2
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
focusKey() {
|
||||
this.$refs.key.focus();
|
||||
},
|
||||
|
||||
getOSType(str) {
|
||||
if (!str) {
|
||||
return;
|
||||
}
|
||||
|
||||
return OS.find( (os) => {
|
||||
if (os.match) {
|
||||
return os.match.find(matchValue => str.toLowerCase().includes(matchValue)) ? os.value : false;
|
||||
} else {
|
||||
return str.toLowerCase().includes(os.value.toLowerCase()) ? os.value : false;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:can-yaml="showEditAsYaml ? true : false"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="saveImage"
|
||||
>
|
||||
<NameNsDescription
|
||||
ref="nd"
|
||||
v-model="value"
|
||||
:mode="mode"
|
||||
:label="t('generic.name')"
|
||||
name-key="spec.displayName"
|
||||
/>
|
||||
|
||||
<Tabbed v-bind="$attrs" class="mt-15" :side-tabs="true">
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('harvester.image.tabs.basics')"
|
||||
:weight="99"
|
||||
class="bordered-table"
|
||||
>
|
||||
<RadioGroup
|
||||
v-if="isCreate"
|
||||
v-model="value.spec.sourceType"
|
||||
name="model"
|
||||
:options="[
|
||||
'download',
|
||||
'upload',
|
||||
]"
|
||||
:labels="[
|
||||
t('harvester.image.sourceType.download'),
|
||||
t('harvester.image.sourceType.upload'),
|
||||
]"
|
||||
:mode="mode"
|
||||
/>
|
||||
<div class="row mb-20 mt-20">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-if="!isCreate"
|
||||
v-model="value.spec.sourceType"
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
:disabled="isEdit"
|
||||
label-key="harvester.image.source"
|
||||
/>
|
||||
|
||||
<LabeledInput
|
||||
v-if="value.spec.sourceType === 'download'"
|
||||
v-model="value.spec.url"
|
||||
:mode="mode"
|
||||
:disabled="isEdit"
|
||||
class="mb-20 labeled-input--tooltip"
|
||||
required
|
||||
label-key="harvester.image.url"
|
||||
:tooltip="t('harvester.image.urlTip', {}, true)"
|
||||
/>
|
||||
|
||||
<div v-else>
|
||||
<LabeledInput
|
||||
v-if="isView"
|
||||
v-model="imageName"
|
||||
:mode="mode"
|
||||
class="mt-20"
|
||||
label-key="harvester.image.fileName"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="isCreate"
|
||||
type="button"
|
||||
class="btn role-primary"
|
||||
@click="selectFile"
|
||||
>
|
||||
<span>
|
||||
{{ t('harvester.image.uploadFile') }}
|
||||
</span>
|
||||
<input
|
||||
v-show="false"
|
||||
id="file"
|
||||
ref="file"
|
||||
type="file"
|
||||
accept=".qcow, .qcow2, .raw, .img, .iso"
|
||||
@change="handleFileUpload()"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="uploadFileName"
|
||||
class="fileName mt-5"
|
||||
>
|
||||
<span class="icon icon-file" />
|
||||
{{ uploadFileName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LabeledInput
|
||||
v-if="value.spec.sourceType === 'download'"
|
||||
v-model="value.spec.checksum"
|
||||
:mode="mode"
|
||||
:disabled="isEdit"
|
||||
label-key="harvester.image.checksum"
|
||||
:tooltip="t('harvester.image.checksumTip')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="storage"
|
||||
:label="t('harvester.storage.label')"
|
||||
:weight="89"
|
||||
class="bordered-table"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="storageClassName"
|
||||
:options="storageClassOptions"
|
||||
:label="t('harvester.storage.storageClass.label')"
|
||||
:mode="mode"
|
||||
:disabled="isEdit"
|
||||
class="mb-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab name="labels" :label="t('labels.labels.title')" :weight="2" class="bordered-table">
|
||||
<KeyValue
|
||||
key="labels"
|
||||
ref="labels"
|
||||
:value="value.labels"
|
||||
:add-label="t('labels.addLabel')"
|
||||
:mode="mode"
|
||||
:pad-left="false"
|
||||
:read-allowed="false"
|
||||
@focusKey="focusKey"
|
||||
@input="value.setLabels($event)"
|
||||
>
|
||||
<template #value="{row, keyName, valueName, queueUpdate}">
|
||||
<Select
|
||||
v-if="internalAnnotations(row)"
|
||||
v-model="row[valueName]"
|
||||
:mode="mode"
|
||||
:searchable="true"
|
||||
:clearable="false"
|
||||
:options="calculateOptions(row[keyName])"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="row[valueName]"
|
||||
:disabled="isView"
|
||||
:type="'text'"
|
||||
:placeholder="t('keyValue.valuePlaceholder')"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</template>
|
||||
</KeyValue>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kv-item.value > .unlabeled-select {
|
||||
height: 40px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,423 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import Labels from '@shell/components/form/Labels';
|
||||
import KeyValue from '@shell/components/form/KeyValue';
|
||||
import NodeScheduling from '@shell/components/form/NodeScheduling';
|
||||
import PodAffinity from '@shell/components/form/PodAffinity';
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
|
||||
import Reserved from './kubevirt.io.virtualmachine/VirtualMachineReserved';
|
||||
import Volume from './kubevirt.io.virtualmachine/VirtualMachineVolume';
|
||||
import Network from './kubevirt.io.virtualmachine/VirtualMachineNetwork';
|
||||
import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory';
|
||||
import CloudConfig from './kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
|
||||
import SSHKey from './kubevirt.io.virtualmachine/VirtualMachineSSHKey';
|
||||
|
||||
import { HCI } from '../types';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { RunStrategys } from '../config/harvester-map';
|
||||
import { _CONFIG, _EDIT, _VIEW } from '@shell/config/query-params';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
|
||||
import VM_MIXIN from '../mixins/harvester-vm';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditVMTemplate',
|
||||
|
||||
components: {
|
||||
Tab,
|
||||
SSHKey,
|
||||
Volume,
|
||||
Tabbed,
|
||||
Network,
|
||||
Checkbox,
|
||||
CpuMemory,
|
||||
CruResource,
|
||||
CloudConfig,
|
||||
LabeledSelect,
|
||||
NameNsDescription,
|
||||
NodeScheduling,
|
||||
PodAffinity,
|
||||
Reserved,
|
||||
UnitInput,
|
||||
Labels,
|
||||
KeyValue,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView, VM_MIXIN],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
if (this.mode === _EDIT) {
|
||||
this.value.cleanForNew();
|
||||
}
|
||||
|
||||
const templateId = this.value.templateId || this.$route.query.templateId;
|
||||
|
||||
return {
|
||||
templateId,
|
||||
templateValue: null,
|
||||
templateSpec: null,
|
||||
versionName: '',
|
||||
description: '',
|
||||
defaultVersion: null,
|
||||
isDefaultVersion: false,
|
||||
RunStrategys,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
isConfig() {
|
||||
return this.$route.query?.as === _CONFIG || this.isView;
|
||||
},
|
||||
|
||||
realTemplateMode() {
|
||||
return this.templateId ? _VIEW : this.mode;
|
||||
},
|
||||
|
||||
secretNamePrefix() {
|
||||
return this.templateValue?.metadata?.name;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
templateId: {
|
||||
async handler(neu) {
|
||||
const templates = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_TEMPLATE });
|
||||
let templateValue = templates.find( V => V.id === neu) || null;
|
||||
let templateSpec = templateValue?.spec;
|
||||
|
||||
if (!templateValue) {
|
||||
templateSpec = {
|
||||
description: '',
|
||||
defaultVersionId: ''
|
||||
};
|
||||
|
||||
templateValue = await this.$store.dispatch('harvester/create', {
|
||||
metadata: {
|
||||
name: '',
|
||||
namespace: ''
|
||||
},
|
||||
spec: templateSpec,
|
||||
type: HCI.VM_TEMPLATE
|
||||
});
|
||||
}
|
||||
|
||||
this.templateValue = templateValue;
|
||||
this.templateSpec = templateSpec;
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.registerAfterHook(async() => {
|
||||
if (this.isDefaultVersion) {
|
||||
// Set the default version according to annotation:[HCI_ANNOTATIONS.TEMPLATE_VERSION_CUSTOM_NAME]
|
||||
const versions = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_VERSION, opt: { force: true } });
|
||||
|
||||
const version = versions.find( V => V?.metadata?.annotations?.[HCI_ANNOTATIONS.TEMPLATE_VERSION_CUSTOM_NAME] === this.customName);
|
||||
|
||||
if (version) {
|
||||
try {
|
||||
this.templateValue.defaultVersionId = version.id;
|
||||
|
||||
const data = [{
|
||||
op: 'replace', path: '/spec/defaultVersionId', value: version.id
|
||||
}];
|
||||
|
||||
await this.templateValue.patch( data, { url: this.templateValue.linkFor('view') });
|
||||
} catch (err) {
|
||||
return Promise.reject(new Error(err.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.imageId = this.diskRows[0]?.image || '';
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveVMT(buttonCb) {
|
||||
this.parseVM();
|
||||
|
||||
const templates = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_TEMPLATE });
|
||||
const template = templates.find( O => O.metadata.name === this.templateValue.metadata.name);
|
||||
|
||||
try {
|
||||
if (!this.templateId) {
|
||||
if (this.templateValue?.metadata?.name) {
|
||||
await this.templateValue.save();
|
||||
} else {
|
||||
this.errors = [this.t('validation.required', { key: this.t('harvester.vmTemplate.nameNsDescription.name') })];
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
template.save();
|
||||
}
|
||||
|
||||
this.value.cleanForNew();
|
||||
this.customName = randomStr(10);
|
||||
this.$set(this.value.metadata, 'annotations', {
|
||||
...this.value.metadata.annotations,
|
||||
[HCI_ANNOTATIONS.TEMPLATE_VERSION_CUSTOM_NAME]: this.customName
|
||||
});
|
||||
|
||||
const name = this.templateValue.metadata.name || template.metadata.name;
|
||||
const namespace = this.templateValue.metadata.namespace || template.metadata.namespace;
|
||||
|
||||
if (this.isCreate) {
|
||||
this.value.metadata.namespace = namespace;
|
||||
}
|
||||
|
||||
this.$set(this.value.spec, 'templateId', `${ namespace }/${ name }`);
|
||||
const res = await this.value.save();
|
||||
|
||||
await this.saveSecret(res);
|
||||
await this.applyHooks(AFTER_SAVE_HOOKS);
|
||||
this.done();
|
||||
} catch (e) {
|
||||
this.errors = [e];
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
onTabChanged({ tab }) {
|
||||
if (tab.name === 'advanced') {
|
||||
this.$refs.yamlEditor?.refresh();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
v-if="templateSpec && spec"
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:can-yaml="false"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="saveVMT"
|
||||
>
|
||||
<NameNsDescription
|
||||
v-model="templateValue"
|
||||
:mode="realTemplateMode"
|
||||
name-label="harvester.vmTemplate.nameNsDescription.name"
|
||||
:namespaced="true"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-if="templateId"
|
||||
v-model="isDefaultVersion"
|
||||
class="mb-20"
|
||||
:label="t('tableHeaders.defaultVersion')"
|
||||
type="checkbox"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<Tabbed :side-tabs="true" @changed="onTabChanged">
|
||||
<Tab name="Basics" :label="t('harvester.vmTemplate.tabs.basics')">
|
||||
<CpuMemory :cpu="cpu" :memory="memory" :disabled="isConfig" @updateCpuMemory="updateCpuMemory" />
|
||||
|
||||
<div class="mb-20">
|
||||
<SSHKey
|
||||
v-model="sshKey"
|
||||
:create-namespace="true"
|
||||
:namespace="templateValue.metadata.namespace"
|
||||
:disable-create="isView"
|
||||
:mode="mode"
|
||||
@update:sshKey="updateSSHKey"
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab name="Volume" :label="t('harvester.tab.volume')" :weight="-1">
|
||||
<Volume v-model="diskRows" :mode="mode" :namespace="value.metadata.namespace" :existing-volume-disabled="true" />
|
||||
</Tab>
|
||||
|
||||
<Tab name="Network" :label="t('harvester.tab.network')" :weight="-2">
|
||||
<Network v-model="networkRows" :mode="mode" />
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="nodeScheduling"
|
||||
:label="t('workload.container.titles.nodeScheduling')"
|
||||
:weight="-89"
|
||||
>
|
||||
<template #default="{active}">
|
||||
<NodeScheduling
|
||||
:key="active"
|
||||
:mode="mode"
|
||||
:value="spec.template.spec"
|
||||
:nodes="nodesIdOptions"
|
||||
/>
|
||||
</template>
|
||||
</Tab>
|
||||
|
||||
<Tab :label="t('harvester.tab.vmScheduling')" name="vmScheduling" :weight="-90">
|
||||
<template #default="{active}">
|
||||
<PodAffinity
|
||||
:key="active"
|
||||
:mode="mode"
|
||||
:value="spec.template.spec"
|
||||
:nodes="nodes"
|
||||
:all-namespaces-option-available="true"
|
||||
:namespaces="filteredNamespaces"
|
||||
:overwrite-labels="affinityLabels"
|
||||
/>
|
||||
</template>
|
||||
</Tab>
|
||||
|
||||
<Tab name="advanced" :label="t('harvester.tab.advanced')" :weight="-99">
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="runStrategy"
|
||||
label-key="harvester.virtualMachine.runStrategy"
|
||||
:options="RunStrategys"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="osType"
|
||||
label-key="harvester.virtualMachine.osType"
|
||||
:mode="mode"
|
||||
:options="OS"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<a v-if="showAdvanced" v-t="'harvester.generic.showMore'" role="button" @click="toggleAdvanced" />
|
||||
<a v-else v-t="'harvester.generic.showMore'" role="button" @click="toggleAdvanced" />
|
||||
</div>
|
||||
|
||||
<div v-if="showAdvanced">
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<Reserved
|
||||
:reserved-memory="reservedMemory"
|
||||
:mode="mode"
|
||||
@updateReserved="updateReserved"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<UnitInput
|
||||
v-model="terminationGracePeriodSeconds"
|
||||
:suffix="terminationGracePeriodSeconds == 1 ? 'Second' : 'Seconds'"
|
||||
:label="t('harvester.virtualMachine.terminationGracePeriodSeconds.label')"
|
||||
:mode="mode"
|
||||
@change="updateTerminationGracePeriodSeconds"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CloudConfig
|
||||
ref="yamlEditor"
|
||||
:mode="mode"
|
||||
:user-script="userScript"
|
||||
:namespace="templateValue.metadata.namespace"
|
||||
:network-script="networkScript"
|
||||
@updateUserData="updateUserData"
|
||||
@updateNetworkData="updateNetworkData"
|
||||
/>
|
||||
|
||||
<div class="spacer"></div>
|
||||
<Checkbox
|
||||
v-model="installUSBTablet"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
:label="t('harvester.virtualMachine.enableUsb')"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model="installAgent"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
label-key="harvester.virtualMachine.installAgent"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model="tpmEnabled"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
label-key="harvester.virtualMachine.advancedOptions.tpm"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model="efiEnabled"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
:label="t('harvester.virtualMachine.efiEnabled')"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-if="efiEnabled"
|
||||
v-model="secureBoot"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
:label="t('harvester.virtualMachine.secureBoot')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
:name="t('generic.labels')"
|
||||
:label="t('harvester.tab.instanceLabel')"
|
||||
:weight="-99"
|
||||
>
|
||||
<Labels
|
||||
:default-container-class="'labels-and-annotations-container'"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:display-side-by-side="false"
|
||||
:show-annotations="false"
|
||||
:show-label-title="false"
|
||||
>
|
||||
<template #labels="{toggler}">
|
||||
<KeyValue
|
||||
key="labels"
|
||||
:value="value.instanceLabels"
|
||||
:protected-keys="value.systemLabels || []"
|
||||
:toggle-filter="toggler"
|
||||
:add-label="t('labels.addLabel')"
|
||||
:mode="mode"
|
||||
:read-allowed="false"
|
||||
:value-can-be-empty="true"
|
||||
@input="value.setInstanceLabels($event)"
|
||||
/>
|
||||
</template>
|
||||
</Labels>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,279 @@
|
||||
<script>
|
||||
import Footer from '@shell/components/form/Footer';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { NAMESPACE } from '@shell/config/types';
|
||||
import { HCI } from '../types';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { BACKUP_TYPE } from '../config/types';
|
||||
|
||||
const createObject = {
|
||||
apiVersion: 'harvesterhci.io/v1beta1',
|
||||
kind: 'VirtualMachineRestore',
|
||||
metadata: { name: '', namespace: '' },
|
||||
type: HCI.RESTORE,
|
||||
spec: {
|
||||
target: {
|
||||
apiGroup: 'kubevirt.io',
|
||||
kind: 'VirtualMachine',
|
||||
name: ''
|
||||
},
|
||||
virtualMachineBackupName: '',
|
||||
newVM: true,
|
||||
deletionPolicy: 'retain'
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'CreateRestore',
|
||||
components: {
|
||||
Footer,
|
||||
RadioGroup,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
async fetch() {
|
||||
await allHash({
|
||||
backups: this.$store.dispatch('harvester/findAll', { type: HCI.BACKUP }),
|
||||
vms: this.$store.dispatch('harvester/findAll', { type: HCI.VM }),
|
||||
});
|
||||
},
|
||||
|
||||
data() {
|
||||
const restoreMode = this.$route.query?.restoreMode;
|
||||
const snapshotName = this.$route.query?.resourceName;
|
||||
|
||||
const restoreResource = clone(createObject);
|
||||
|
||||
const restoreNewVm = restoreMode === 'new' || restoreMode === undefined;
|
||||
|
||||
return {
|
||||
snapshotName,
|
||||
restoreNewVm,
|
||||
restoreResource,
|
||||
name: '',
|
||||
description: '',
|
||||
deletionPolicy: 'retain',
|
||||
namespace: ''
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
snapshotOption() {
|
||||
const choices = this.$store.getters['harvester/all'](HCI.BACKUP);
|
||||
|
||||
return choices.filter( (T) => {
|
||||
const hasVM = this.restoreNewVm || T.attachVmExisting;
|
||||
|
||||
return hasVM && T?.status?.readyToUse && T.spec?.type === BACKUP_TYPE.SNAPSHOT;
|
||||
}).map( (T) => {
|
||||
return {
|
||||
label: T.metadata.name,
|
||||
value: T.metadata.name
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deletionPolicyOption() {
|
||||
return [{
|
||||
value: 'retain',
|
||||
label: 'Retain'
|
||||
}];
|
||||
},
|
||||
|
||||
currentBackupResource() {
|
||||
const name = this.snapshotName;
|
||||
|
||||
const backupList = this.$store.getters['harvester/all'](HCI.BACKUP);
|
||||
|
||||
return backupList.find( O => O.name === name);
|
||||
},
|
||||
|
||||
disableExisting() {
|
||||
return !this.currentBackupResource?.attachVmExisting;
|
||||
},
|
||||
|
||||
snapshotNamespace() {
|
||||
const backupList = this.$store.getters['harvester/all'](HCI.BACKUP);
|
||||
|
||||
return backupList.find( B => B.metadata.name === this.snapshotName)?.metadata?.namespace;
|
||||
},
|
||||
|
||||
namespaces() {
|
||||
const inStore = this.$store.getters['currentStore'](NAMESPACE);
|
||||
const choices = this.$store.getters[`${ inStore }/all`](NAMESPACE);
|
||||
const systemNamespaces = this.$store.getters['systemNamespaces'];
|
||||
|
||||
const out = sortBy(
|
||||
choices.filter(N => !systemNamespaces.includes(N.metadata.name)).map((obj) => {
|
||||
return {
|
||||
label: obj.nameDisplay,
|
||||
value: obj.id,
|
||||
};
|
||||
}),
|
||||
'label'
|
||||
);
|
||||
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
snapshotName: {
|
||||
handler(neu) {
|
||||
if (this.currentBackupResource) {
|
||||
if (!this.restoreNewVm) {
|
||||
this.name = this?.currentBackupResource?.attachVM;
|
||||
}
|
||||
}
|
||||
|
||||
this.restoreResource.spec.virtualMachineBackupName = neu;
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
|
||||
restoreNewVm(neu) {
|
||||
if (neu) {
|
||||
this.name = '';
|
||||
} else {
|
||||
this.name = this?.currentBackupResource?.attachVM;
|
||||
}
|
||||
},
|
||||
|
||||
snapshotNamespace: {
|
||||
handler(neu) {
|
||||
this.namespace = neu;
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveRestore(buttonCb) {
|
||||
this.update();
|
||||
|
||||
const proxyResource = await this.$store.dispatch('harvester/create', this.restoreResource);
|
||||
|
||||
proxyResource.metadata.namespace = this.namespace;
|
||||
proxyResource.spec.virtualMachineBackupNamespace = this.snapshotNamespace;
|
||||
|
||||
try {
|
||||
await proxyResource.save();
|
||||
buttonCb(true);
|
||||
|
||||
this.$router.push({
|
||||
name: this.doneRoute,
|
||||
params: { resource: HCI.VM }
|
||||
});
|
||||
} catch (err) {
|
||||
this.errors = exceptionToErrorsArray(err) || err;
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
update() {
|
||||
this.restoreResource.metadata.generateName = `restore-${ this.snapshotName }-`;
|
||||
if (this.name) {
|
||||
this.restoreResource.spec.target.name = this.name;
|
||||
}
|
||||
|
||||
if (this.restoreNewVm) {
|
||||
delete this.restoreResource.spec.deletionPolicy;
|
||||
this.restoreResource.spec.newVM = true;
|
||||
} else {
|
||||
this.restoreResource.spec.deletionPolicy = this.deletionPolicy;
|
||||
delete this.restoreResource.spec.newVM;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
componentTitle() {
|
||||
return 'restoreVM';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="restore">
|
||||
<div class="content">
|
||||
<div class="mb-20">
|
||||
<RadioGroup
|
||||
v-model="restoreNewVm"
|
||||
name="model"
|
||||
:options="[true,false]"
|
||||
:labels="[t('harvester.backup.restore.createNew'), t('harvester.backup.restore.replaceExisting')]"
|
||||
:disabled="disableExisting"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="namespace"
|
||||
:disabled="true"
|
||||
:label="t('nameNsDescription.namespace.label')"
|
||||
:options="namespaces"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="name"
|
||||
:disabled="!restoreNewVm"
|
||||
:label="t('harvester.backup.restore.virtualMachineName')"
|
||||
:placeholder="t('nameNsDescription.name.placeholder')"
|
||||
class="mb-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LabeledSelect v-model="snapshotName" class="mb-20" :label="t('harvester.vmSnapshot.snapshot')" :options="snapshotOption" />
|
||||
|
||||
<LabeledSelect v-if="!restoreNewVm" v-model="deletionPolicy" :label="t('harvester.backup.restore.deletePreviousVolumes')" :options="deletionPolicyOption" />
|
||||
</div>
|
||||
|
||||
<Footer mode="create" class="footer" :errors="errors" @save="saveRestore" @done="done" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#restore {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
|
||||
::v-deep .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: var(--header-border-size) solid var(--header-border);
|
||||
|
||||
// Overrides outlet padding
|
||||
margin-left: -$space-m;
|
||||
margin-right: -$space-m;
|
||||
margin-bottom: -$space-m;
|
||||
padding: $space-s $space-m;
|
||||
|
||||
::v-deep .spacer-small {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<script>
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import SortableTable from '@shell/components/SortableTable';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { get } from '@shell/utils/object';
|
||||
import { HCI, VOLUME_SNAPSHOT } from '../types';
|
||||
import { STORAGE_CLASS, LONGHORN, PV } from '@shell/config/types';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { saferDump } from '@shell/utils/create-yaml';
|
||||
import { InterfaceOption, VOLUME_DATA_SOURCE_KIND } from '../config/harvester-map';
|
||||
import { _CREATE } from '@shell/config/query-params';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { STATE, NAME, AGE, NAMESPACE } from '@shell/config/table-headers';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterVolume',
|
||||
|
||||
components: {
|
||||
Tab,
|
||||
UnitInput,
|
||||
CruResource,
|
||||
SortableTable,
|
||||
ResourceTabs,
|
||||
LabeledSelect,
|
||||
LabeledInput,
|
||||
NameNsDescription,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const _hash = {
|
||||
images: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IMAGE }),
|
||||
snapshots: this.$store.dispatch(`${ inStore }/findAll`, { type: VOLUME_SNAPSHOT }),
|
||||
storages: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }),
|
||||
pvs: this.$store.dispatch(`${ inStore }/findAll`, { type: PV }),
|
||||
};
|
||||
|
||||
if (this.$store.getters[`${ inStore }/schemaFor`](LONGHORN.VOLUMES)) {
|
||||
_hash.longhornVolumes = this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.VOLUMES });
|
||||
}
|
||||
|
||||
if (this.$store.getters[`${ inStore }/schemaFor`](LONGHORN.ENGINES)) {
|
||||
_hash.longhornEngines = this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.ENGINES });
|
||||
}
|
||||
|
||||
const hash = await allHash(_hash);
|
||||
|
||||
this.snapshots = hash.snapshots;
|
||||
|
||||
const defaultStorage = this.$store.getters[`harvester/all`](STORAGE_CLASS).find( O => O.isDefault);
|
||||
|
||||
this.$set(this.value.spec, 'storageClassName', this.value?.spec?.storageClassName || defaultStorage?.metadata?.name || 'longhorn');
|
||||
},
|
||||
|
||||
data() {
|
||||
if (this.mode === _CREATE) {
|
||||
this.value.spec.volumeMode = 'Block';
|
||||
this.value.spec.accessModes = ['ReadWriteMany'];
|
||||
}
|
||||
|
||||
const storage = this.value?.spec?.resources?.requests?.storage || null;
|
||||
const imageId = get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.IMAGE_ID }"`);
|
||||
const source = !imageId ? 'blank' : 'url';
|
||||
|
||||
return {
|
||||
source,
|
||||
storage,
|
||||
imageId,
|
||||
snapshots: [],
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.registerBeforeHook(this.willSave, 'willSave');
|
||||
},
|
||||
|
||||
computed: {
|
||||
isBlank() {
|
||||
return this.source === 'blank';
|
||||
},
|
||||
|
||||
isVMImage() {
|
||||
return this.source === 'url';
|
||||
},
|
||||
|
||||
sourceOption() {
|
||||
return [{
|
||||
value: 'blank',
|
||||
label: this.t('harvester.volume.sourceOptions.new')
|
||||
}, {
|
||||
value: 'url',
|
||||
label: this.t('harvester.volume.sourceOptions.vmImage')
|
||||
}];
|
||||
},
|
||||
|
||||
interfaceOption() {
|
||||
return InterfaceOption;
|
||||
},
|
||||
|
||||
imageOption() {
|
||||
const choices = this.$store.getters['harvester/all'](HCI.IMAGE);
|
||||
|
||||
return sortBy(
|
||||
choices
|
||||
.filter(obj => obj.isReady)
|
||||
.map((obj) => {
|
||||
return {
|
||||
label: `${ obj.metadata.namespace }/${ obj.spec.displayName }`,
|
||||
value: obj.id
|
||||
};
|
||||
}),
|
||||
'label'
|
||||
);
|
||||
},
|
||||
|
||||
snapshotHeaders() {
|
||||
return [
|
||||
STATE,
|
||||
NAME,
|
||||
NAMESPACE,
|
||||
{
|
||||
name: 'size',
|
||||
labelKey: 'tableHeaders.size',
|
||||
value: 'status.restoreSize',
|
||||
sort: 'size',
|
||||
formatter: 'Si',
|
||||
formatterOpts: {
|
||||
opts: {
|
||||
increment: 1024, addSuffix: true, maxExponent: 3, minExponent: 3, suffix: 'i',
|
||||
},
|
||||
needParseSi: true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'readyToUse',
|
||||
labelKey: 'tableHeaders.readyToUse',
|
||||
value: 'status.readyToUse',
|
||||
align: 'left',
|
||||
formatter: 'Checked',
|
||||
},
|
||||
AGE
|
||||
];
|
||||
},
|
||||
|
||||
dataSourceKind() {
|
||||
return VOLUME_DATA_SOURCE_KIND[this.value.spec?.dataSource?.kind];
|
||||
},
|
||||
|
||||
storageClassOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const storages = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS);
|
||||
|
||||
const out = storages.filter(s => !s.parameters?.backingImage).map((s) => {
|
||||
const label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
|
||||
|
||||
return {
|
||||
label,
|
||||
value: s.name,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
frontend() {
|
||||
return this.value.longhornVolume?.spec?.frontend;
|
||||
},
|
||||
|
||||
frontendDisplay() {
|
||||
const format = ['blockdev'];
|
||||
|
||||
if (format.includes(this.frontend)) {
|
||||
return this.t(`harvester.volume.${ this.frontend }`);
|
||||
}
|
||||
|
||||
return this.frontend;
|
||||
},
|
||||
|
||||
attachedNode() {
|
||||
return this.value.longhornVolume?.spec?.nodeID;
|
||||
},
|
||||
|
||||
endpoint() {
|
||||
return this.value.longhornEngine?.status?.endpoint;
|
||||
},
|
||||
|
||||
diskTags() {
|
||||
return this.value.longhornVolume?.spec?.diskSelector;
|
||||
},
|
||||
|
||||
nodeTags() {
|
||||
return this.value.longhornVolume?.spec?.nodeSelector;
|
||||
},
|
||||
|
||||
replicasNumber() {
|
||||
return this.value.longhornVolume?.spec?.numberOfReplicas;
|
||||
},
|
||||
|
||||
lastBackup() {
|
||||
return this.value.longhornVolume?.status?.lastBackup;
|
||||
},
|
||||
|
||||
lastBackupAt() {
|
||||
return this.value.longhornVolume?.status?.lastBackupAt;
|
||||
},
|
||||
|
||||
rebuildStatus() {
|
||||
return this.value.longhornEngine?.status?.rebuildStatus;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
willSave() {
|
||||
this.update();
|
||||
},
|
||||
update() {
|
||||
let imageAnnotations = '';
|
||||
let storageClassName = this.value.spec.storageClassName;
|
||||
|
||||
if (this.isVMImage && this.imageId) {
|
||||
const images = this.$store.getters['harvester/all'](HCI.IMAGE);
|
||||
|
||||
imageAnnotations = {
|
||||
...this.value.metadata.annotations,
|
||||
[HCI_ANNOTATIONS.IMAGE_ID]: this.imageId
|
||||
};
|
||||
storageClassName = images?.find(image => this.imageId === image.id)?.storageClassName;
|
||||
} else {
|
||||
imageAnnotations = { ...this.value.metadata.annotations };
|
||||
}
|
||||
|
||||
const spec = {
|
||||
...this.value.spec,
|
||||
resources: { requests: { storage: this.storage } },
|
||||
storageClassName
|
||||
};
|
||||
|
||||
this.value.setAnnotations(imageAnnotations);
|
||||
|
||||
this.$set(this.value, 'spec', spec);
|
||||
},
|
||||
|
||||
generateYaml() {
|
||||
const out = saferDump(this.value);
|
||||
|
||||
return out;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:generate-yaml="generateYaml"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
>
|
||||
<NameNsDescription :value="value" :namespaced="true" :mode="mode" />
|
||||
|
||||
<ResourceTabs
|
||||
v-model="value"
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab name="basic" :label="t('harvester.volume.tabs.basics')" :weight="3" class="bordered-table">
|
||||
<LabeledSelect
|
||||
v-model="source"
|
||||
:label="t('harvester.volume.source')"
|
||||
:options="sourceOption"
|
||||
:disabled="!isCreate"
|
||||
required
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
@input="update"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-if="isVMImage"
|
||||
v-model="imageId"
|
||||
:label="t('harvester.volume.image')"
|
||||
:options="imageOption"
|
||||
:disabled="!isCreate"
|
||||
required
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
@input="update"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-if="source === 'blank'"
|
||||
v-model="value.spec.storageClassName"
|
||||
:options="storageClassOptions"
|
||||
:label="t('harvester.storage.storageClass.label')"
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
:disabled="!isCreate"
|
||||
@input="update"
|
||||
/>
|
||||
|
||||
<UnitInput
|
||||
v-model="storage"
|
||||
v-int-number
|
||||
:label="t('harvester.volume.size')"
|
||||
:input-exponent="3"
|
||||
:output-modifier="true"
|
||||
:increment="1024"
|
||||
:mode="mode"
|
||||
required
|
||||
class="mb-20"
|
||||
@input="update"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab v-if="!isCreate" name="details" :label="t('harvester.volume.tabs.details')" :weight="2.5" class="bordered-table">
|
||||
<LabeledInput v-model="frontendDisplay" class="mb-20" :mode="mode" :disabled="true" :label="t('harvester.volume.frontend')" />
|
||||
<LabeledInput v-model="attachedNode" class="mb-20" :mode="mode" :disabled="true" :label="t('harvester.volume.attachedNode')" />
|
||||
<LabeledInput v-model="endpoint" class="mb-20" :mode="mode" :disabled="true" :label="t('harvester.volume.endpoint')" />
|
||||
<LabeledSelect
|
||||
v-model="diskTags"
|
||||
:multiple="true"
|
||||
:label="t('harvester.volume.diskTags')"
|
||||
:options="[]"
|
||||
:disabled="true"
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
/>
|
||||
<LabeledSelect
|
||||
v-model="nodeTags"
|
||||
:multiple="true"
|
||||
:label="t('harvester.volume.nodeTags')"
|
||||
:options="[]"
|
||||
:disabled="true"
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
/>
|
||||
<LabeledInput v-model="lastBackup" class="mb-20" :mode="mode" :disabled="true" :label="t('harvester.volume.lastBackup')" />
|
||||
<LabeledInput v-model="lastBackupAt" class="mb-20" :mode="mode" :disabled="true" :label="t('harvester.volume.lastBackupAt')" />
|
||||
<LabeledInput v-model="replicasNumber" class="mb-20" :mode="mode" :disabled="true" :label="t('harvester.volume.replicasNumber')" />
|
||||
</Tab>
|
||||
<Tab v-if="!isCreate" name="instances" :label="t('harvester.volume.tabs.snapshots')" :weight="2" class="bordered-table">
|
||||
<SortableTable
|
||||
v-bind="$attrs"
|
||||
:headers="snapshotHeaders"
|
||||
default-sort-by="age"
|
||||
:rows="value.relatedVolumeSnapshotCounts"
|
||||
key-field="_key"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab v-if="!isCreate && value.spec.dataSource" name="datasource" :label="t('harvester.volume.tabs.datasource')" :weight="1" class="bordered-table">
|
||||
<LabeledInput v-model="dataSourceKind" class="mb-20" :mode="mode" :disabled="true" :label="t('harvester.volume.kind')" />
|
||||
<LabeledInput v-model="value.spec.dataSource.name" :mode="mode" :disabled="true" :label="t('nameNsDescription.name.label')" />
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script>
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import { get } from '@shell/utils/object';
|
||||
import { HCI } from '../types';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@shell/config/labels-annotations';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterSnapshot',
|
||||
components: {
|
||||
LabeledInput,
|
||||
Tab,
|
||||
ResourceTabs,
|
||||
CruResource,
|
||||
NameNsDescription,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
computed: {
|
||||
volume() {
|
||||
return this.volumes.find((V) => {
|
||||
return V.metadata.name === this.targetVolume;
|
||||
});
|
||||
},
|
||||
|
||||
size() {
|
||||
return this.value?.status?.restoreSize;
|
||||
},
|
||||
|
||||
isImageVolume() {
|
||||
return !!get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.IMAGE_ID }"`);
|
||||
},
|
||||
|
||||
imageName() {
|
||||
const image = this.$store.getters['harvester/all'](HCI.IMAGE).find((I) => {
|
||||
return I.id === get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.IMAGE_ID }"`);
|
||||
});
|
||||
|
||||
return image?.spec?.displayName;
|
||||
},
|
||||
|
||||
targetVolume() {
|
||||
return this.value?.spec?.source?.persistentVolumeClaimName;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
>
|
||||
<NameNsDescription :value="value" :namespaced="true" :mode="mode" />
|
||||
<ResourceTabs
|
||||
v-model="value"
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab name="basics" :label="t('harvester.virtualMachine.detail.tabs.basics')" class="bordered-table">
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput v-model="size" :label="t('harvester.snapshot.size')" class="mt-20" :disabled="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput v-model="targetVolume" :label="t('harvester.snapshot.targetVolume')" class="mt-20" :disabled="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isImageVolume" class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput v-model="imageName" :label="t('harvester.snapshot.image')" class="mt-20" :disabled="true" />
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</div>
|
||||
</template>
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||
|
||||
const _NEW = '_NEW';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
ModalWithCard,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: [Array, String],
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
resource: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
userOptions: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedUser: this.value,
|
||||
newUsername: '',
|
||||
randomStr: randomStr(5).toLowerCase(),
|
||||
errors: [],
|
||||
isOpen: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: { ...mapGetters({ t: 'i18n/t' }) },
|
||||
|
||||
watch: {
|
||||
value(neu) {
|
||||
this.selectedUser = neu;
|
||||
},
|
||||
|
||||
selectedUser(val, old) {
|
||||
if ( val.includes(_NEW)) {
|
||||
this.$set(this, 'selectedUser', old);
|
||||
this.update();
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('update:user', clone(this.selectedUser));
|
||||
},
|
||||
|
||||
addUser(buttonCb) {
|
||||
const reg = /^[-._0-9a-zA-Z]+$/;
|
||||
|
||||
this.errors = [];
|
||||
|
||||
if (!reg.test(this.newUsername)) {
|
||||
const message = this.t('harvester.virtualMachine.accessCredentials.invalidUser');
|
||||
|
||||
this.errors.push(message);
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.userOptions.includes(this.newUsername)) {
|
||||
const message = this.t('harvester.virtualMachine.accessCredentials.duplicatedUser');
|
||||
|
||||
this.errors.push(message);
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.multiple) {
|
||||
this.selectedUser.push(this.newUsername);
|
||||
} else {
|
||||
this.selectedUser = this.newUsername;
|
||||
}
|
||||
|
||||
this.$emit('update:newUser', this.newUsername);
|
||||
this.update();
|
||||
|
||||
buttonCb(true);
|
||||
this.cancel();
|
||||
},
|
||||
|
||||
show() {
|
||||
this.isOpen = true;
|
||||
},
|
||||
|
||||
hide() {
|
||||
this.isOpen = false;
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.hide();
|
||||
this.newUsername = '';
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vm__credentials-users">
|
||||
<LabeledSelect
|
||||
v-model="selectedUser"
|
||||
:options="userOptions"
|
||||
:label="t('harvester.virtualMachine.accessCredentials.injectSSH.users')"
|
||||
:taggable="multiple"
|
||||
:mode="mode"
|
||||
:multiple="multiple"
|
||||
:searchable="false"
|
||||
:tooltip="t('harvester.virtualMachine.accessCredentials.userTips')"
|
||||
:class="{'hasUsers': selectedUser.length > 0}"
|
||||
@input="update"
|
||||
/>
|
||||
|
||||
<ModalWithCard
|
||||
v-if="isOpen"
|
||||
:name="randomStr"
|
||||
width="40%"
|
||||
:errors="errors"
|
||||
@finish="addUser"
|
||||
@close="cancel"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('harvester.virtualMachine.accessCredentials.injectSSH.addUser') }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<LabeledInput
|
||||
v-model="newUsername"
|
||||
:label="t('harvester.virtualMachine.input.username')"
|
||||
class="mb-20"
|
||||
required
|
||||
@keydown.native.enter.prevent="()=>{}"
|
||||
/>
|
||||
</template>
|
||||
</ModalWithCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.vm__credentials-users {
|
||||
.hasUsers.labeled-select.edit.taggable {
|
||||
.labeled-tooltip {
|
||||
.icon.status-icon {
|
||||
top: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import { Banner } from '@components/Banner';
|
||||
import InfoBox from '@shell/components/InfoBox';
|
||||
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { removeObject } from '@shell/utils/array';
|
||||
import { ACCESS_CREDENTIALS } from '../../../config/harvester-map';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
|
||||
const _NEW = '_NEW';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
InfoBox
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
resource: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
},
|
||||
|
||||
disableCreate: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
isQemuInstalled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
ACCESS_CREDENTIALS,
|
||||
toRemove: [],
|
||||
toSave: [],
|
||||
rows: [],
|
||||
newUserOptions: [],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
isEdit() {
|
||||
return this.mode === _EDIT;
|
||||
},
|
||||
|
||||
historyUsersOptions() {
|
||||
const out = JSON.parse(this.resource?.spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_USERS] || '[]');
|
||||
|
||||
out.unshift({
|
||||
label: this.t('harvester.virtualMachine.accessCredentials.injectSSH.addUser'),
|
||||
value: _NEW,
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
userOptions() {
|
||||
return Array.from(new Set([...this.historyUsersOptions, ...this.newUserOptions]));
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler(neu) {
|
||||
this.rows = [...neu];
|
||||
},
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
addCredentials(type) {
|
||||
const neu = {
|
||||
source: type,
|
||||
users: [],
|
||||
sshkeys: [],
|
||||
username: '',
|
||||
newPassword: '',
|
||||
secretName: this.generateSecretName(this.resource.metadata.name),
|
||||
};
|
||||
|
||||
this.rows.push(neu);
|
||||
this.update();
|
||||
},
|
||||
|
||||
remove(row) {
|
||||
removeObject(this.rows, row);
|
||||
this.update();
|
||||
},
|
||||
|
||||
update() {
|
||||
this.$emit('input', this.rows);
|
||||
},
|
||||
|
||||
updateNewUser(newUser) {
|
||||
this.newUserOptions.push(newUser);
|
||||
},
|
||||
|
||||
generateSecretName(name) {
|
||||
return name ? `${ name }-${ randomStr(5).toLowerCase() }` : undefined;
|
||||
},
|
||||
|
||||
componentFor(type) {
|
||||
switch (type) {
|
||||
case ACCESS_CREDENTIALS.RESET_PWD:
|
||||
return require(`./type/basicAuth.vue`).default;
|
||||
case ACCESS_CREDENTIALS.INJECT_SSH:
|
||||
return require(`./type/sshkey.vue`).default;
|
||||
}
|
||||
},
|
||||
|
||||
headerFor(type) {
|
||||
return {
|
||||
userPassword: this.$store.getters['i18n/t']('harvester.virtualMachine.accessCredentials.resetPwd.label'),
|
||||
sshPublicKey: this.$store.getters['i18n/t']('harvester.virtualMachine.accessCredentials.injectSSH.label'),
|
||||
}[type];
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Banner class="mb-20" color="info" :label="t('harvester.virtualMachine.accessCredentials.tips')" />
|
||||
|
||||
<div v-for="(row, i) in rows" :key="row.id">
|
||||
<InfoBox class="volume-source">
|
||||
<button v-if="isEdit" type="button" class="role-link btn btn-sm remove-vol" @click="remove(row)">
|
||||
<i class="icon icon-x" />
|
||||
</button>
|
||||
<h3>
|
||||
<span>
|
||||
{{ headerFor(row.source) }}
|
||||
</span>
|
||||
</h3>
|
||||
<div>
|
||||
<component
|
||||
:is="componentFor(row.source)"
|
||||
v-model="rows[i]"
|
||||
:rows="rows"
|
||||
:resource="resource"
|
||||
:user-options="userOptions"
|
||||
:mode="mode"
|
||||
:idx="i"
|
||||
@update="update"
|
||||
@update:newUser="updateNewUser"
|
||||
/>
|
||||
</div>
|
||||
</InfoBox>
|
||||
</div>
|
||||
|
||||
<div v-if="isEdit && isQemuInstalled" class="mt-20">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm bg-primary mr-15 mb-10"
|
||||
@click="addCredentials(ACCESS_CREDENTIALS.RESET_PWD)"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.accessCredentials.resetPwd.label') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm bg-primary mr-15 mb-10"
|
||||
@click="addCredentials(ACCESS_CREDENTIALS.INJECT_SSH)"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.accessCredentials.injectSSH.label') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.volume-source {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.remove-vol {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 0px;
|
||||
}
|
||||
</style>
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<script>
|
||||
import Password from '@shell/components/form/Password';
|
||||
import AccessCredentialsUsers from '../AccessCredentialsUsers';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditVolume',
|
||||
components: { Password, AccessCredentialsUsers },
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
|
||||
resource: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
userOptions: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
defaultPwdValue: {
|
||||
username: '',
|
||||
newPassword: ''
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('update');
|
||||
},
|
||||
|
||||
updateUser(neu) {
|
||||
this.$set(this.value, 'username', neu);
|
||||
this.update();
|
||||
},
|
||||
|
||||
updateNewUser(neu) {
|
||||
this.$emit('update:newUser', neu);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row" @input="update">
|
||||
<div class="col span-6">
|
||||
<AccessCredentialsUsers
|
||||
v-model="value.username"
|
||||
:resource="resource"
|
||||
:user-options="userOptions"
|
||||
:multiple="false"
|
||||
:mode="mode"
|
||||
@update:user="updateUser"
|
||||
@update:newUser="updateNewUser"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<Password
|
||||
ref="password"
|
||||
v-model="value.newPassword"
|
||||
:mode="mode"
|
||||
:disabled="mode !== 'edit'"
|
||||
:label="t('harvester.virtualMachine.input.password')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.action {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
</style>
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<script>
|
||||
import SSHKey from '../../VirtualMachineSSHKey';
|
||||
import AccessCredentialsUsers from '../AccessCredentialsUsers';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditVolume',
|
||||
components: { SSHKey, AccessCredentialsUsers },
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
|
||||
resource: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
userOptions: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('update');
|
||||
},
|
||||
|
||||
updateUser(neu) {
|
||||
this.$set(this.value, 'users', neu);
|
||||
this.update();
|
||||
},
|
||||
|
||||
updateSSH(neu) {
|
||||
this.$set(this.value, 'sshkeys', neu);
|
||||
this.update();
|
||||
},
|
||||
|
||||
updateNewUser(neu) {
|
||||
this.$emit('update:newUser', neu);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns row">
|
||||
<div class="col span-6">
|
||||
<AccessCredentialsUsers
|
||||
v-model="value.users"
|
||||
:resource="resource"
|
||||
:user-options="userOptions"
|
||||
:mode="mode"
|
||||
:multiple="true"
|
||||
@update:user="updateUser"
|
||||
@update:newUser="updateNewUser"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<SSHKey
|
||||
v-model="value.sshkeys"
|
||||
class="mb-20"
|
||||
:namespace="resource.metadata.namespace"
|
||||
:mode="mode"
|
||||
:searchable="false"
|
||||
@update:sshKey="updateSSH"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
|
||||
|
||||
import { _VIEW } from '@shell/config/query-params';
|
||||
import { CONFIG_MAP } from '@shell/config/types';
|
||||
|
||||
const _NEW = '_NEW';
|
||||
const _NONE = '_NONE';
|
||||
|
||||
export default {
|
||||
components: { YamlEditor, LabeledSelect },
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
configId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
viewCode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
id: '',
|
||||
yamlScript: this.value,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
editorMode() {
|
||||
return this.isView || this.viewCode ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
|
||||
},
|
||||
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
value(neu) {
|
||||
this.yamlScript = neu;
|
||||
},
|
||||
|
||||
configId(neu) {
|
||||
this.id = this.configId;
|
||||
},
|
||||
|
||||
id(neu, old) {
|
||||
const cloudInit = this.$store.getters['harvester/byId'](CONFIG_MAP, neu)?.data?.cloudInit || '';
|
||||
|
||||
this.$emit('updateTemplateId', this.type, neu);
|
||||
if (!neu) {
|
||||
// should not reset yaml when nothing is selected
|
||||
return;
|
||||
} else if (neu === _NEW) {
|
||||
this.$emit('show', this.type);
|
||||
this.id = old;
|
||||
|
||||
return;
|
||||
} else if (neu === _NONE ) {
|
||||
this.yamlScript = '';
|
||||
} else {
|
||||
this.yamlScript = cloudInit;
|
||||
}
|
||||
|
||||
this.$refs['yaml'].updateValue(cloudInit);
|
||||
},
|
||||
|
||||
yamlScript(neu) {
|
||||
this.$emit('update', neu, this.type);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
refresh() {
|
||||
this.$refs.yaml.refresh();
|
||||
},
|
||||
|
||||
updateValue() {
|
||||
this.$refs['yaml'].updateValue(this.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-20">
|
||||
<h3>{{ t(`harvester.virtualMachine.cloudConfig.${type}.title`) }}</h3>
|
||||
<p class="text-muted mb-20">
|
||||
<t :k="`harvester.virtualMachine.cloudConfig.${type}.tip`" :raw="true" />
|
||||
</p>
|
||||
|
||||
<LabeledSelect
|
||||
v-if="!isView"
|
||||
v-model="id"
|
||||
class="mb-20"
|
||||
:options="options"
|
||||
:disabled="viewCode"
|
||||
:label-key="`harvester.virtualMachine.cloudConfig.${type}.label`"
|
||||
/>
|
||||
|
||||
<div class="resource-yaml">
|
||||
<YamlEditor
|
||||
ref="yaml"
|
||||
v-model="yamlScript"
|
||||
class="yaml-editor"
|
||||
:editor-mode="editorMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$yaml-height: 200px;
|
||||
|
||||
::v-deep .resource-yaml {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& .yaml-editor{
|
||||
flex: 1;
|
||||
min-height: $yaml-height;
|
||||
|
||||
& .code-mirror .CodeMirror {
|
||||
min-height: $yaml-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
|
||||
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||
|
||||
import { CONFIG_MAP } from '@shell/config/types';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { _VIEW } from '@shell/config/query-params';
|
||||
import DataTemplate from './DataTemplate';
|
||||
|
||||
const _NEW = '_NEW';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DataTemplate, YamlEditor, LabeledInput, ModalWithCard
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
namespace: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
userScript: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
networkScript: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
viewCode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
errors: [],
|
||||
templateType: '',
|
||||
cloudTemplate: '',
|
||||
cloudTemplateName: '',
|
||||
configUserId: '',
|
||||
configNetworkId: '',
|
||||
optionUser: [],
|
||||
optionNetwork: [],
|
||||
isOpen: false
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const configs = await this.$store.dispatch('harvester/findAll', { type: CONFIG_MAP });
|
||||
|
||||
const optionUser = [];
|
||||
const optionNetwork = [];
|
||||
|
||||
for (const config of configs) {
|
||||
if (config.metadata?.labels?.[HCI_ANNOTATIONS.CLOUD_INIT] === 'user') {
|
||||
optionUser.push({
|
||||
label: config?.id,
|
||||
value: config?.id
|
||||
});
|
||||
}
|
||||
|
||||
if (config.metadata?.labels?.[HCI_ANNOTATIONS.CLOUD_INIT] === 'network') {
|
||||
optionNetwork.push({
|
||||
label: config?.id,
|
||||
value: config?.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
optionUser.unshift({
|
||||
label: this.t('harvester.virtualMachine.cloudConfig.createNew'),
|
||||
value: _NEW,
|
||||
});
|
||||
|
||||
optionNetwork.unshift({
|
||||
label: this.t('harvester.virtualMachine.cloudConfig.createNew'),
|
||||
value: _NEW,
|
||||
});
|
||||
|
||||
this.optionUser = optionUser;
|
||||
this.optionNetwork = optionNetwork;
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
createTypeLable() {
|
||||
const label = `harvester.virtualMachine.cloudConfig.${ this.templateType }.label`;
|
||||
|
||||
return this.t(label);
|
||||
},
|
||||
|
||||
editorMode() {
|
||||
return this.isView ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
|
||||
},
|
||||
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateValue() {
|
||||
this.$refs['userTemplate'].updateValue();
|
||||
this.$refs['networkTemplate'].updateValue();
|
||||
},
|
||||
|
||||
update(value, type) {
|
||||
if (type === 'user') {
|
||||
this.$emit('updateUserData', value);
|
||||
}
|
||||
if (type === 'network') {
|
||||
this.$emit('updateNetworkData', value);
|
||||
}
|
||||
},
|
||||
|
||||
updateTemplateId(type, id) {
|
||||
this.$emit('updateDataTemplateId', type, id);
|
||||
},
|
||||
|
||||
show(templateType) {
|
||||
this.templateType = templateType;
|
||||
this.isOpen = true;
|
||||
},
|
||||
|
||||
async save(buttonCb) {
|
||||
this.errors = [];
|
||||
|
||||
if (!this.cloudTemplateName) {
|
||||
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.name') }, true));
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.cloudTemplate) {
|
||||
const keyLabel = this.templateType === 'user' ? 'harvester.cloudTemplate.userData' : 'harvester.cloudTemplate.networkData';
|
||||
|
||||
this.errors.push(this.t('validation.required', { key: this.t(keyLabel) }, true));
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const templateValue = await this.$store.dispatch('harvester/create', {
|
||||
type: CONFIG_MAP,
|
||||
metadata: {
|
||||
labels: { [HCI_ANNOTATIONS.CLOUD_INIT]: this.templateType },
|
||||
name: this.cloudTemplateName,
|
||||
namespace: this.namespace
|
||||
},
|
||||
data: { cloudInit: this.cloudTemplate },
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await templateValue.save();
|
||||
|
||||
if (res.id) {
|
||||
this.templateType === 'user' ? this.configUserId = res.id : this.configNetworkId = res.id;
|
||||
this.$fetch();
|
||||
}
|
||||
buttonCb(true);
|
||||
this.cancel();
|
||||
} catch (err) {
|
||||
this.errors = [err.message];
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.cloudTemplate = '';
|
||||
this.cloudTemplateName = '';
|
||||
this.$set(this, 'errors', []);
|
||||
this.isOpen = false;
|
||||
},
|
||||
|
||||
refresh() {
|
||||
this.$refs['userTemplate'].refresh();
|
||||
this.$refs['networkTemplate'].refresh();
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 v-if="!isView">
|
||||
{{ t('harvester.virtualMachine.cloudConfig.title') }}
|
||||
</h2>
|
||||
|
||||
<div class="mb-20">
|
||||
<DataTemplate
|
||||
ref="userTemplate"
|
||||
v-model="userScript"
|
||||
type="user"
|
||||
:view-code="viewCode"
|
||||
:mode="mode"
|
||||
:config-id="configUserId"
|
||||
:options="optionUser"
|
||||
@show="show"
|
||||
@update="update"
|
||||
@updateTemplateId="updateTemplateId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-20">
|
||||
<DataTemplate
|
||||
ref="networkTemplate"
|
||||
v-model="networkScript"
|
||||
type="network"
|
||||
:view-code="viewCode"
|
||||
:mode="mode"
|
||||
:config-id="configNetworkId"
|
||||
:options="optionNetwork"
|
||||
@show="show"
|
||||
@update="update"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalWithCard
|
||||
v-if="isOpen"
|
||||
name="createCloudTemplate"
|
||||
width="40%"
|
||||
:errors="errors"
|
||||
@finish="save"
|
||||
@close="cancel"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('harvester.virtualMachine.cloudConfig.createTemplateTitle', { name: createTypeLable }) }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<LabeledInput
|
||||
v-model="cloudTemplateName"
|
||||
:label="t('harvester.virtualMachine.input.name')"
|
||||
class="mb-20"
|
||||
required
|
||||
@keydown.native.enter.prevent="()=>{}"
|
||||
/>
|
||||
|
||||
<div class="yaml">
|
||||
<div class="resource-yaml">
|
||||
<YamlEditor
|
||||
ref="createTemplate"
|
||||
v-model="cloudTemplate"
|
||||
class="yaml-editor"
|
||||
:editor-mode="editorMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ModalWithCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$yaml-height: 350px;
|
||||
|
||||
.yaml {
|
||||
height: $yaml-height;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
::v-deep .resource-yaml {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& .yaml-editor{
|
||||
flex: 1;
|
||||
min-height: $yaml-height;
|
||||
|
||||
& .code-mirror .CodeMirror {
|
||||
min-height: $yaml-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script>
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
import InputOrDisplay from '@shell/components/InputOrDisplay';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditCpuMemory',
|
||||
components: { UnitInput, InputOrDisplay },
|
||||
|
||||
props: {
|
||||
cpu: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
memory: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
localCpu: this.cpu,
|
||||
localMemory: this.memory
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
cupDisplay() {
|
||||
return `${ this.localCpu } C`;
|
||||
},
|
||||
|
||||
memoryDisplay() {
|
||||
return `${ this.localMemory }`;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
cpu(neu) {
|
||||
this.localCpu = neu;
|
||||
},
|
||||
memory(neu) {
|
||||
if (neu && !neu.includes('null')) {
|
||||
this.localMemory = neu;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
change() {
|
||||
let memory = '';
|
||||
|
||||
if (String(this.localMemory).includes('Gi')) {
|
||||
memory = this.localMemory;
|
||||
} else {
|
||||
memory = `${ this.localMemory }Gi`;
|
||||
}
|
||||
if (memory.includes('null')) {
|
||||
memory = null;
|
||||
}
|
||||
this.$emit('updateCpuMemory', this.localCpu, memory);
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<InputOrDisplay name="CPU" :value="cupDisplay" :mode="mode" class="mb-10">
|
||||
<UnitInput
|
||||
v-model="localCpu"
|
||||
v-int-number
|
||||
label="CPU"
|
||||
suffix="C"
|
||||
:delay="0"
|
||||
required
|
||||
:disabled="disabled"
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
@input="change"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<InputOrDisplay :name="t('harvester.virtualMachine.input.memory')" :value="memoryDisplay" :mode="mode" class="mb-10">
|
||||
<UnitInput
|
||||
v-model="localMemory"
|
||||
v-int-number
|
||||
:label="t('harvester.virtualMachine.input.memory')"
|
||||
:mode="mode"
|
||||
:input-exponent="3"
|
||||
:delay="0"
|
||||
:increment="1024"
|
||||
:output-modifier="true"
|
||||
:disabled="disabled"
|
||||
required
|
||||
class="mb-20"
|
||||
@input="change"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import HarvesterEditNetwork from '../base.vue';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
|
||||
describe('component: HarvesterEditNetwork', () => {
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// it('should display all the inputs', () => {
|
||||
// const wrapper = mount(HarvesterEditNetwork, { propsData: { mode: _EDIT } });
|
||||
|
||||
// const inputWraps = wrapper.findAll('[data-testid^=input-hen-]');
|
||||
|
||||
// expect(inputWraps).toHaveLength(5);
|
||||
// });
|
||||
|
||||
it.each([
|
||||
'name',
|
||||
])('should emit an update on %p input', (field) => {
|
||||
const wrapper = mount(HarvesterEditNetwork, { propsData: { mode: _EDIT } });
|
||||
const input = wrapper.find(`[data-testid="input-hen-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
|
||||
input.setValue(newValue);
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'model',
|
||||
'networkName',
|
||||
'type',
|
||||
])('should emit an update on %p selection change', async(field) => {
|
||||
const wrapper = mount(HarvesterEditNetwork, { propsData: { mode: _EDIT } });
|
||||
const select = wrapper.find(`[data-testid="input-hen-${ field }"]`);
|
||||
|
||||
select.find('button').trigger('click');
|
||||
await wrapper.trigger('keydown.down');
|
||||
await wrapper.trigger('keydown.enter');
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
<script>
|
||||
import InputOrDisplay from '@shell/components/InputOrDisplay';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { _CREATE, _VIEW } from '@shell/config/query-params';
|
||||
import { MANAGEMENT_NETWORK } from '../../../mixins/harvester-vm';
|
||||
|
||||
const MODEL = [{
|
||||
label: 'virtio',
|
||||
value: 'virtio'
|
||||
}, {
|
||||
label: 'e1000',
|
||||
value: 'e1000'
|
||||
}, {
|
||||
label: 'e1000e',
|
||||
value: 'e1000e'
|
||||
}, {
|
||||
label: 'ne2k_pci',
|
||||
value: 'ne2k_pci'
|
||||
}, {
|
||||
label: 'pcnet',
|
||||
value: 'pcnet'
|
||||
}, {
|
||||
label: 'rtl8139',
|
||||
value: 'rtl8139'
|
||||
}];
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditNetwork',
|
||||
components: {
|
||||
LabeledInput, LabeledSelect, InputOrDisplay
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
networkOption: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
|
||||
isSingle: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const isMasquerade = this.value.isPod;
|
||||
|
||||
if (isMasquerade) {
|
||||
this.value.networkName = MANAGEMENT_NETWORK;
|
||||
}
|
||||
|
||||
return {
|
||||
isMasquerade,
|
||||
hasManagementNetwork: false,
|
||||
showAdvanced: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isDisabled() {
|
||||
return this.isMasquerade && !this.value.newCreateId && !this.isCreate;
|
||||
},
|
||||
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
|
||||
isCreate() {
|
||||
return this.mode === _CREATE;
|
||||
},
|
||||
|
||||
modelOption() {
|
||||
return MODEL;
|
||||
},
|
||||
|
||||
allNetworkOption() {
|
||||
const vlanNetwork = clone(this.networkOption);
|
||||
const managementNetwork = [{
|
||||
label: MANAGEMENT_NETWORK,
|
||||
value: MANAGEMENT_NETWORK
|
||||
}];
|
||||
|
||||
if (this.value.newCreateId && this.hasManagementNetwork) {
|
||||
return vlanNetwork;
|
||||
} else if (this.value.newCreateId && !this.hasManagementNetwork) {
|
||||
return [...managementNetwork, ...vlanNetwork];
|
||||
} else if (this.isMasquerade) {
|
||||
return managementNetwork;
|
||||
} else {
|
||||
return vlanNetwork;
|
||||
}
|
||||
},
|
||||
|
||||
typeOption() {
|
||||
const masquerade = [{
|
||||
label: 'masquerade',
|
||||
value: 'masquerade'
|
||||
}, {
|
||||
label: 'bridge',
|
||||
value: 'bridge'
|
||||
}];
|
||||
|
||||
const other = [{
|
||||
label: 'bridge',
|
||||
value: 'bridge'
|
||||
}];
|
||||
|
||||
return this.isMasquerade ? masquerade : other;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
rows: {
|
||||
handler(neu) {
|
||||
const hasManagementNetwork = !!neu.some(N => N.isPod);
|
||||
|
||||
this.$set(this, 'hasManagementNetwork', hasManagementNetwork);
|
||||
},
|
||||
immediate: true,
|
||||
deep: true
|
||||
},
|
||||
|
||||
isSingle(neu) {
|
||||
if (!neu) {
|
||||
this.$set(this.value, 'macAddress', '');
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Patch k8s value based on type of network
|
||||
*/
|
||||
updateNetworkName(neu) {
|
||||
if (neu === MANAGEMENT_NETWORK) {
|
||||
this.value.isPod = true;
|
||||
this.value.macAddress = '';
|
||||
} else {
|
||||
this.value.isPod = false;
|
||||
}
|
||||
|
||||
this.$set(this, 'isMasquerade', this.value.isPod);
|
||||
|
||||
if (this.value.isPod) {
|
||||
this.value.type = 'masquerade';
|
||||
} else {
|
||||
this.value.type = 'bridge';
|
||||
}
|
||||
this.update();
|
||||
},
|
||||
|
||||
update() {
|
||||
this.$emit('update');
|
||||
},
|
||||
|
||||
toggleAdvanced() {
|
||||
this.showAdvanced = !this.showAdvanced;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hen-name"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.name')"
|
||||
:value="value.name"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model="value.name"
|
||||
:label="t('harvester.fields.name')"
|
||||
required
|
||||
:mode="mode"
|
||||
:disabled="isDisabled"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="input-hen-model"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.model')"
|
||||
:value="value.model"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.model"
|
||||
:label="t('harvester.fields.model')"
|
||||
:disabled="isDisabled"
|
||||
:options="modelOption"
|
||||
:mode="mode"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" :class="{'mb-20': !isMasquerade}">
|
||||
<div
|
||||
data-testid="input-hen-networkName"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.network')"
|
||||
:value="value.networkName"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.networkName"
|
||||
:label="t('harvester.fields.network')"
|
||||
:options="allNetworkOption"
|
||||
:mode="mode"
|
||||
required
|
||||
:disabled="isDisabled"
|
||||
@input="updateNetworkName"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="input-hen-type"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.type')"
|
||||
:value="value.type"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.type"
|
||||
:label="t('harvester.fields.type')"
|
||||
:options="typeOption"
|
||||
:mode="mode"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isMasquerade && isSingle">
|
||||
<div class="row mb-20">
|
||||
<a role="button" @click="toggleAdvanced">
|
||||
{{ showAdvanced ? t('harvester.generic.hideMore') : t('harvester.generic.showMore') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div
|
||||
v-if="showAdvanced"
|
||||
data-testid="input-hen-macAddress"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.macAddress')"
|
||||
:value="value.macAddress"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model="value.macAddress"
|
||||
label-key="harvester.fields.macAddress"
|
||||
:mode="mode"
|
||||
:tooltip="t('harvester.virtualMachine.volume.macTip')"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script>
|
||||
import InfoBox from '@shell/components/InfoBox';
|
||||
import Base from './base';
|
||||
|
||||
import { NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||
import { HCI as HCI_ANNOTATIONS } from '../../../config/labels-annotations';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { removeObject } from '@shell/utils/array';
|
||||
import { _VIEW } from '@shell/config/query-params';
|
||||
|
||||
export default {
|
||||
components: { InfoBox, Base },
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
isSingle: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
rows: this.addKeyId(clone(this.value)),
|
||||
nameIdx: 1
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
|
||||
networkOption() {
|
||||
const choices = this.$store.getters['harvester/all'](NETWORK_ATTACHMENT).filter((row) => {
|
||||
return !row.metadata?.annotations?.[HCI_ANNOTATIONS.STORAGE_NETWORK];
|
||||
});
|
||||
|
||||
const out = sortBy(
|
||||
choices.map((N) => {
|
||||
const label = N.isNotReady ? `${ N.id } (${ this.t('generic.notReady') })` : N.id;
|
||||
|
||||
return {
|
||||
label,
|
||||
value: N.id,
|
||||
disabled: N.isNotReady,
|
||||
};
|
||||
}),
|
||||
'label'
|
||||
);
|
||||
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
value(neu) {
|
||||
this.rows = neu;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
add(type) {
|
||||
const name = this.generateName();
|
||||
|
||||
const neu = {
|
||||
name,
|
||||
networkName: '',
|
||||
model: 'virtio',
|
||||
type: 'bridge',
|
||||
newCreateId: randomStr(10),
|
||||
rowKeyId: randomStr(10)
|
||||
};
|
||||
|
||||
this.rows.push(neu);
|
||||
this.update();
|
||||
},
|
||||
|
||||
remove(vol) {
|
||||
removeObject(this.rows, vol);
|
||||
this.update();
|
||||
},
|
||||
|
||||
addKeyId(row) {
|
||||
return row.map((R) => {
|
||||
return {
|
||||
...R,
|
||||
rowKeyId: randomStr(10)
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
generateName() {
|
||||
let name = '';
|
||||
let hasUsed = true;
|
||||
|
||||
while (hasUsed) {
|
||||
name = `nic-${ this.nameIdx }`;
|
||||
hasUsed = this.rows.find( O => O.name === name);
|
||||
this.nameIdx++;
|
||||
}
|
||||
|
||||
return name;
|
||||
},
|
||||
|
||||
update() {
|
||||
this.$emit('input', this.rows);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<InfoBox v-for="(row, i) in rows" :key="i" class="infoBox">
|
||||
<button v-if="!isView" type="button" class="role-link remove-vol" @click="remove(row)">
|
||||
<i class="icon icon-x" />
|
||||
</button>
|
||||
|
||||
<h3> {{ t('harvester.virtualMachine.network.title') }} </h3>
|
||||
|
||||
<Base
|
||||
:key="rows[i].rowKeyId"
|
||||
v-model="rows[i]"
|
||||
:rows="rows"
|
||||
:mode="mode"
|
||||
:is-single="isSingle"
|
||||
:network-option="networkOption"
|
||||
@update="update"
|
||||
/>
|
||||
</InfoBox>
|
||||
|
||||
<button v-if="!isView" type="button" class="btn btn-sm bg-primary" @click="add">
|
||||
{{ t('harvester.virtualMachine.network.addNetwork') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.infoBox{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.remove-vol {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 16px;
|
||||
padding:0px;
|
||||
max-height: 28px;
|
||||
min-height: 28px;
|
||||
}
|
||||
</style>
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
props: {
|
||||
/**
|
||||
* deviceId/vendorId is unique per type of device - there may be multiple pciDevice CRD objects for a given device
|
||||
* {
|
||||
* [deviceId/vendorId]: {
|
||||
* nodes: array of devicecrd.status.nodeName's for given device,
|
||||
* deviceCRDs: array of all instances (pciDevice CRD) of given device
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
enabledDevices: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
/**
|
||||
* {
|
||||
* [node name]: [devices]
|
||||
* }
|
||||
*/
|
||||
devicesByNode: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
/**
|
||||
* {
|
||||
* [deviceCRD.status.resourceName]: {
|
||||
* count: number of this device in use by other vms
|
||||
* usedBy: [names of all vms using this device]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
devicesInUse: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
allNodeNames() {
|
||||
return Object.keys(this.devicesByNode);
|
||||
},
|
||||
|
||||
allDeviceIds() {
|
||||
return Object.keys(this.uniqueDevices);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
nodeNameFromId(id) {
|
||||
return this.devicesByNode[id]?.name;
|
||||
},
|
||||
|
||||
nodeHasDevice(nodeName, deviceCRD) {
|
||||
return deviceCRD.status.nodeName === nodeName;
|
||||
},
|
||||
|
||||
noneAvailable(deviceCRD) {
|
||||
const name = deviceCRD.metadata?.name;
|
||||
|
||||
return !!this.devicesInUse[name];
|
||||
},
|
||||
|
||||
deviceTooltip(deviceCRD) {
|
||||
return `${ deviceCRD?.status?.resourceName }<br/>${ deviceCRD?.status?.description }`;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compat-matrix">
|
||||
<div class="device-col node-names">
|
||||
<div class="blank-corner">
|
||||
<div class="text-right">
|
||||
{{ t('harvester.pci.matrixDeviceClaimName') }}
|
||||
</div>
|
||||
<div>{{ t('harvester.pci.matrixHostName') }}</div>
|
||||
</div>
|
||||
<div v-for="nodeName in allNodeNames" :key="nodeName" class="node-label">
|
||||
<span> {{ nodeName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="deviceCRD in enabledDevices" :key="deviceCRD.metadata.name" class="device-col">
|
||||
<div v-clean-tooltip="deviceTooltip(deviceCRD)" class="compat-cell device-label" :class="{'text-muted': noneAvailable(deviceCRD)}">
|
||||
{{ deviceCRD.metadata.name }}
|
||||
</div>
|
||||
<div
|
||||
v-for="nodeName in allNodeNames"
|
||||
:key="nodeName"
|
||||
class="compat-cell"
|
||||
:class="{'has-device': nodeHasDevice(nodeName, deviceCRD)}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='scss'>
|
||||
.compat-matrix {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.device-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
&>*{
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.compat-cell {
|
||||
flex-basis: 1em;
|
||||
padding: 0px 10px 0px 10px;
|
||||
|
||||
&.has-device {
|
||||
background-color: var(--info-banner-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.node-label, .device-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--input-label);
|
||||
}
|
||||
|
||||
.node-label{
|
||||
padding: 0 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.node-label, .device-label, .compat-cell, .blank-corner {
|
||||
flex-basis: calc(2em + 10px);
|
||||
}
|
||||
|
||||
.blank-corner {
|
||||
background: linear-gradient(
|
||||
to top right,
|
||||
#fff 0%,
|
||||
#fff calc(50% - 1px),
|
||||
var(--body-text) 50%,
|
||||
#fff calc(50% + 1px),
|
||||
#fff 100%
|
||||
);
|
||||
|
||||
DIV.text-right {
|
||||
padding-left: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
<script>
|
||||
import ResourceTable from '@shell/components/ResourceTable';
|
||||
import FilterBySriov from '../../../components/FilterBySriov';
|
||||
import { HCI } from '../../../types';
|
||||
import { STATE, SIMPLE_NAME } from '@shell/config/table-headers';
|
||||
import { defaultTableSortGenerationFn } from '@shell/components/ResourceTable.vue';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
|
||||
export default {
|
||||
name: 'ListPciDevices',
|
||||
|
||||
components: { ResourceTable, FilterBySriov },
|
||||
|
||||
props: {
|
||||
schema: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
devices: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
},
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const _hash = {
|
||||
pciclaims: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.PCI_CLAIM }),
|
||||
sriovs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SR_IOV }),
|
||||
};
|
||||
|
||||
await allHash(_hash);
|
||||
},
|
||||
|
||||
data() {
|
||||
const isSingleProduct = this.$store.getters['isSingleProduct'];
|
||||
const headers = [
|
||||
{ ...STATE },
|
||||
SIMPLE_NAME,
|
||||
{
|
||||
name: 'description',
|
||||
labelKey: 'tableHeaders.description',
|
||||
value: 'status.description',
|
||||
sort: ['status.description']
|
||||
},
|
||||
{
|
||||
name: 'node',
|
||||
labelKey: 'tableHeaders.node',
|
||||
value: 'status.nodeName',
|
||||
sort: ['status.nodeName']
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
label: 'Address',
|
||||
value: 'status.address',
|
||||
sort: ['status.address']
|
||||
},
|
||||
{
|
||||
name: 'vendorid',
|
||||
label: 'Vendor ID',
|
||||
value: 'status.vendorId',
|
||||
sort: ['status.vendorId', 'status.deviceId']
|
||||
},
|
||||
{
|
||||
name: 'deviceid',
|
||||
label: 'Device ID',
|
||||
value: 'status.deviceId',
|
||||
sort: ['status.deviceId', 'status.vendorId']
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
if (!isSingleProduct) {
|
||||
headers.push( {
|
||||
name: 'claimed',
|
||||
label: 'Claimed By',
|
||||
value: 'passthroughClaim.userName',
|
||||
sort: ['passthroughClaim.userName'],
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
headers,
|
||||
rows: [],
|
||||
parentSriov: null,
|
||||
filterRows: []
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
devices: {
|
||||
handler(v) {
|
||||
this.rows = v;
|
||||
this.filterRows = this.rows;
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
parentSriovOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const allSriovs = this.$store.getters[`${ inStore }/all`](HCI.SR_IOV) || [];
|
||||
|
||||
return allSriovs.map((sriov) => {
|
||||
return sriov.id;
|
||||
});
|
||||
},
|
||||
parentSriovLabel() {
|
||||
return HCI_ANNOTATIONS.PARENT_SRIOV;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
enableGroup(rows = []) {
|
||||
const row = rows[0];
|
||||
|
||||
if (row) {
|
||||
row.enablePassthroughBulk(rows);
|
||||
}
|
||||
},
|
||||
disableGroup(rows = []) {
|
||||
rows.forEach((row) => {
|
||||
if (row.passthroughClaim) {
|
||||
row.disablePassthrough();
|
||||
}
|
||||
});
|
||||
},
|
||||
groupIsAllEnabled(rows = []) {
|
||||
return !rows.find(device => !device.passthroughClaim);
|
||||
},
|
||||
|
||||
changeRows(filterRows, parentSriov) {
|
||||
this.$set(this, 'filterRows', filterRows);
|
||||
this.$set(this, 'parentSriov', parentSriov);
|
||||
},
|
||||
|
||||
sortGenerationFn() {
|
||||
let base = defaultTableSortGenerationFn(this.schema, this.$store);
|
||||
|
||||
if (this.parentSriov) {
|
||||
base += this.parentSriov;
|
||||
}
|
||||
|
||||
return base;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ResourceTable
|
||||
:headers="headers"
|
||||
:schema="schema"
|
||||
:rows="filterRows"
|
||||
:use-query-params-for-simple-filtering="true"
|
||||
:sort-generation-fn="sortGenerationFn"
|
||||
:rows-per-page="10"
|
||||
>
|
||||
<template #group-by="{group}">
|
||||
<div :ref="group.key" v-trim-whitespace class="group-tab">
|
||||
<button v-if="groupIsAllEnabled(group.rows)" type="button" class="btn btn-sm role-secondary mr-5" @click="e=>{disableGroup(group.rows); e.target.blur()}">
|
||||
{{ t('harvester.pci.disableGroup') }}
|
||||
</button>
|
||||
<button v-else type="button" class="btn btn-sm role-secondary mr-5" @click="e=>{enableGroup(group.rows); e.target.blur()}">
|
||||
{{ t('harvester.pci.enableGroup') }}
|
||||
</button>
|
||||
<span v-clean-html="group.key" />
|
||||
</div>
|
||||
</template>
|
||||
<template #cell:claimed="{row}">
|
||||
<span v-if="row.isEnabled">{{ row.claimedBy }}</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
<template #more-header-middle>
|
||||
<FilterBySriov
|
||||
ref="filterByParentSRIOV"
|
||||
:parent-sriov-options="parentSriovOptions"
|
||||
:parent-sriov-label="parentSriovLabel"
|
||||
:label="t('harvester.sriov.parentSriov')"
|
||||
:rows="rows"
|
||||
@change-rows="changeRows"
|
||||
/>
|
||||
</template>
|
||||
</ResourceTable>
|
||||
</template>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script>
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI } from '../../../types';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import Banner from '@components/Banner/Banner.vue';
|
||||
import CompatibilityMatrix from './CompatibilityMatrix';
|
||||
import DeviceList from './DeviceList';
|
||||
|
||||
import remove from 'lodash/remove';
|
||||
import { get, set } from '@shell/utils/object';
|
||||
|
||||
export default {
|
||||
name: 'VirtualMachinePCIDevices',
|
||||
components: {
|
||||
LabeledSelect,
|
||||
DeviceList,
|
||||
CompatibilityMatrix,
|
||||
Banner
|
||||
},
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: _EDIT
|
||||
},
|
||||
// spec.template.spec
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
|
||||
vm: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const hash = {
|
||||
// claims fetched here so synchronous pciDevice model property works
|
||||
pciDevices: this.$store.dispatch('harvester/findAll', { type: HCI.PCI_DEVICE }),
|
||||
claims: this.$store.dispatch('harvester/findAll', { type: HCI.PCI_CLAIM }),
|
||||
vms: this.$store.dispatch(`harvester/findAll`, { type: HCI.VM })
|
||||
|
||||
};
|
||||
|
||||
const res = await allHash(hash);
|
||||
|
||||
for (const key in res) {
|
||||
this[key] = res[key];
|
||||
}
|
||||
|
||||
const selectedDevices = [];
|
||||
const oldFormatDevices = [];
|
||||
|
||||
(this.value?.domain?.devices?.hostDevices || []).forEach(({ name, deviceName }) => {
|
||||
const checkName = (deviceName || '').split('/')?.[1];
|
||||
|
||||
if (checkName && name.includes(checkName)) {
|
||||
oldFormatDevices.push(name);
|
||||
} else if (this.enabledDevices.find(device => device?.metadata?.name === name)) {
|
||||
selectedDevices.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
if (oldFormatDevices.length > 0) {
|
||||
this.oldFormatDevices = oldFormatDevices;
|
||||
} else {
|
||||
this.selectedDevices = selectedDevices;
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pciDevices: [],
|
||||
claims: [],
|
||||
vms: [],
|
||||
selectedDevices: [],
|
||||
pciDeviceSchema: this.$store.getters['harvester/schemaFor'](HCI.PCI_DEVICE),
|
||||
showMatrix: false,
|
||||
oldFormatDevices: [],
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
selectedDevices(neu) {
|
||||
const formatted = neu.map((selectedDevice) => {
|
||||
const deviceCRD = this.enabledDevices.find(device => device.metadata.name === selectedDevice);
|
||||
const deviceName = deviceCRD?.status?.resourceName;
|
||||
|
||||
return {
|
||||
deviceName,
|
||||
name: deviceCRD?.metadata.name,
|
||||
};
|
||||
});
|
||||
|
||||
set(this.value.domain.devices, 'hostDevices', formatted);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// user can only select devices for whcih pci passthrough is enabled/claimed by them - determined by finding the associated passthrough CRD
|
||||
enabledDevices() {
|
||||
return this.pciDevices.filter((device) => {
|
||||
return device.isEnabled && device.claimedByMe;
|
||||
}) || [];
|
||||
},
|
||||
|
||||
// find devices in use by other VMs and sum the number of each device already in use
|
||||
devicesInUse() {
|
||||
const inUse = this.vms.reduce((inUse, vm) => {
|
||||
// dont count devices in this vm as 'disabled' if they're just being used in the vm currently being edited
|
||||
if (vm.metadata.name === this.vm?.metadata?.name) {
|
||||
return inUse;
|
||||
}
|
||||
const devices = get(vm, 'spec.template.spec.domain.devices.hostDevices') || [];
|
||||
|
||||
devices.forEach((device) => {
|
||||
inUse[device.name] = { usedBy: [vm.metadata.name] };
|
||||
});
|
||||
|
||||
return inUse;
|
||||
}, {});
|
||||
|
||||
return inUse;
|
||||
},
|
||||
|
||||
devicesByNode() {
|
||||
const out = {};
|
||||
|
||||
this.enabledDevices.forEach((deviceCRD) => {
|
||||
const nodeName = deviceCRD.status?.nodeName;
|
||||
|
||||
if (!out[nodeName]) {
|
||||
out[nodeName] = [deviceCRD];
|
||||
} else {
|
||||
out[nodeName].push(deviceCRD);
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
// determine which nodes contain all devices selected
|
||||
compatibleNodes() {
|
||||
const out = [...Object.keys(this.devicesByNode)];
|
||||
|
||||
this.selectedDevices.forEach((deviceUid) => {
|
||||
remove(out, (nodeName) => {
|
||||
const device = this.enabledDevices.find(deviceCRD => deviceCRD.metadata.name === deviceUid);
|
||||
|
||||
return device.status.nodeName !== nodeName;
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
// format an array of available devices for the dropdown
|
||||
deviceOpts() {
|
||||
const filteredOptions = this.enabledDevices.filter((deviceCRD) => {
|
||||
if (this.selectedDevices.length > 0) {
|
||||
const selectedDevice = this.enabledDevices.find(device => device.metadata.name === this.selectedDevices[0]);
|
||||
|
||||
return !this.devicesInUse[deviceCRD?.metadata.name] && deviceCRD.status.nodeName === selectedDevice.status.nodeName;
|
||||
}
|
||||
|
||||
return !this.devicesInUse[deviceCRD?.metadata.name];
|
||||
});
|
||||
|
||||
return filteredOptions.map((deviceCRD) => {
|
||||
return {
|
||||
value: deviceCRD?.metadata.name,
|
||||
label: deviceCRD?.metadata.name,
|
||||
displayLabel: deviceCRD?.status?.resourceName,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
oldFormatDevicesHTML() {
|
||||
return this.oldFormatDevices.map((device) => {
|
||||
return `<li>${ device }</li>`;
|
||||
}).join('');
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
nodeNameFromUid(uid) {
|
||||
for (const deviceUid in this.uniqueDevices) {
|
||||
const nodes = this.uniqueDevices[deviceUid].nodes;
|
||||
const thisNode = nodes.find(node => node.systemUUID === uid);
|
||||
|
||||
if (thisNode) {
|
||||
return thisNode.name;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="oldFormatDevices.length > 0"
|
||||
class="row"
|
||||
>
|
||||
<div class="col span-12">
|
||||
<Banner color="warning">
|
||||
<p v-clean-html="t('harvester.pci.oldFormatDevices.help', {oldFormatDevicesHTML}, true)" />
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<Banner color="info">
|
||||
<t k="harvester.pci.howToUseDevice" />
|
||||
</Banner>
|
||||
<Banner v-if="selectedDevices.length > 0" color="info">
|
||||
<t k="harvester.pci.deviceInTheSameHost" />
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="enabledDevices.length">
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="selectedDevices"
|
||||
label="Available PCI Devices"
|
||||
searchable
|
||||
multiple
|
||||
taggable
|
||||
:options="deviceOpts"
|
||||
:mode="mode"
|
||||
>
|
||||
<template #option="option">
|
||||
<span>{{ option.value }} <span class="text-label">({{ option.displayLabel }})</span></span>
|
||||
</template>
|
||||
</LabeledSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="compatibleNodes.length && selectedDevices.length" class="row">
|
||||
<div class="col span-12 text-muted">
|
||||
Compatible hosts:
|
||||
<!-- eslint-disable-next-line vue/no-parsing-error -->
|
||||
<span v-for="(node, idx) in compatibleNodes" :key="node">{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="selectedDevices.length" class="text-error">
|
||||
{{ t('harvester.pci.impossibleSelection') }}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm role-link pl-0" @click="e=>{showMatrix = !showMatrix; e.target.blur()}">
|
||||
{{ showMatrix ? t('harvester.pci.hideCompatibility') : t('harvester.pci.showCompatibility') }}
|
||||
</button>
|
||||
<div v-if="showMatrix" class="row mt-20">
|
||||
<div class="col span-12">
|
||||
<CompatibilityMatrix :enabled-devices="enabledDevices" :devices-by-node="devicesByNode" :devices-in-use="devicesInUse" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-12">
|
||||
<DeviceList :schema="pciDeviceSchema" :devices="pciDevices" @submit.prevent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterReserved',
|
||||
components: { UnitInput },
|
||||
|
||||
props: {
|
||||
reservedMemory: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return { memory: this.reservedMemory };
|
||||
},
|
||||
|
||||
watch: {
|
||||
reservedMemory(memory) {
|
||||
this.memory = memory;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
change() {
|
||||
const { memory } = this;
|
||||
|
||||
this.$emit('updateReserved', { memory });
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UnitInput
|
||||
v-model="memory"
|
||||
v-int-number
|
||||
:label="t('harvester.virtualMachine.input.reservedMemory')"
|
||||
:mode="mode"
|
||||
:input-exponent="2"
|
||||
:increment="1024"
|
||||
:output-modifier="true"
|
||||
@input="change"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||
|
||||
import { HCI } from '../../types';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { _VIEW } from '@shell/config/query-params';
|
||||
|
||||
import { NAMESPACE } from '@shell/config/types';
|
||||
|
||||
const _NEW = '_NEW';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LabeledInput,
|
||||
ModalWithCard,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
},
|
||||
|
||||
disableCreate: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
namespace: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
createNamespace: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
checkedSsh: this.value,
|
||||
publicKey: '',
|
||||
sshName: '',
|
||||
randomStr: randomStr(5).toLowerCase(),
|
||||
errors: [],
|
||||
isAll: false,
|
||||
checkAll: false,
|
||||
isOpen: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
schema() {
|
||||
return this.$store.getters['harvester/schemaFor']( HCI.SSH );
|
||||
},
|
||||
|
||||
isCreatable() {
|
||||
if ( this.schema && !this.schema?.collectionMethods.find((x) => ['blocked-post', 'post'].includes(x.toLowerCase())) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true ;
|
||||
},
|
||||
|
||||
sshOption() {
|
||||
const out = this.$store.getters['harvester/all'](HCI.SSH).map( (O) => {
|
||||
return {
|
||||
label: O.id,
|
||||
value: O.id
|
||||
};
|
||||
});
|
||||
|
||||
if (!(this.disableCreate || this.mode === _VIEW) && this.isCreatable) {
|
||||
out.unshift({
|
||||
label: this.t('harvester.virtualMachine.createSSHKey'),
|
||||
value: _NEW,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
publicKey(neu) {
|
||||
const trimNeu = neu.trim();
|
||||
const splitSSH = trimNeu.split(/\s+/);
|
||||
|
||||
if (splitSSH.length === 3 && !this.sshName) {
|
||||
const keyComment = splitSSH[2];
|
||||
|
||||
this.randomStr = randomStr(10).toLowerCase();
|
||||
this.sshName = keyComment.includes('@') ? keyComment.split('@')[0] : keyComment;
|
||||
}
|
||||
},
|
||||
|
||||
value(neu) {
|
||||
this.checkedSsh = neu;
|
||||
},
|
||||
|
||||
checkedSsh(val, old) {
|
||||
if ( val.includes(_NEW)) {
|
||||
this.$set(this, 'checkedSsh', old);
|
||||
this.update();
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
show() {
|
||||
this.isOpen = true;
|
||||
},
|
||||
|
||||
hide() {
|
||||
this.isOpen = false;
|
||||
},
|
||||
|
||||
async createNamespaceIfNeeded() {
|
||||
if (!this.createNamespace || this.disableCreate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const namespaces = await this.$store.dispatch('harvester/findAll', { type: NAMESPACE });
|
||||
|
||||
const exists = namespaces?.find((n) => n.name === this.namespace);
|
||||
|
||||
if (!exists) {
|
||||
const ns = await this.$store.dispatch('harvester/createNamespace', { name: this.namespace }, { root: true });
|
||||
|
||||
ns.applyDefaults();
|
||||
await ns.save();
|
||||
}
|
||||
},
|
||||
|
||||
async createSSHKey() {
|
||||
const sshValue = await this.$store.dispatch('harvester/create', {
|
||||
metadata: {
|
||||
name: this.sshName,
|
||||
namespace: this.namespace
|
||||
},
|
||||
spec: { publicKey: this.publicKey },
|
||||
type: HCI.SSH
|
||||
});
|
||||
|
||||
const res = await sshValue.save();
|
||||
|
||||
if (res.id) {
|
||||
this.checkedSsh.push(`${ this.namespace }/${ this.sshName }`);
|
||||
}
|
||||
},
|
||||
|
||||
async save(buttonCb) {
|
||||
this.errors = [];
|
||||
|
||||
if (!this.sshName) {
|
||||
const fieldName = this.t('harvester.virtualMachine.input.name');
|
||||
const message = this.t('validation.required', { key: fieldName });
|
||||
|
||||
this.errors.push(message);
|
||||
}
|
||||
|
||||
if (!this.publicKey) {
|
||||
const fieldName = this.t('harvester.virtualMachine.input.sshKeyValue');
|
||||
const message = this.t('validation.required', { key: fieldName });
|
||||
|
||||
this.errors.push(message);
|
||||
}
|
||||
|
||||
if (this.sshName.length > 63) {
|
||||
const message = this.t('harvester.validation.custom.tooLongName', { max: 63 });
|
||||
|
||||
this.errors.push(message);
|
||||
}
|
||||
|
||||
if (this.errors.length > 0) {
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.createNamespaceIfNeeded();
|
||||
|
||||
await this.createSSHKey();
|
||||
|
||||
buttonCb(true);
|
||||
this.cancel();
|
||||
} catch (err) {
|
||||
this.errors = [err.message];
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.hide();
|
||||
this.resetFields();
|
||||
},
|
||||
|
||||
resetFields() {
|
||||
this.sshName = '';
|
||||
this.publicKey = '';
|
||||
this.errors = [];
|
||||
},
|
||||
|
||||
update() {
|
||||
this.$emit('update:sshKey', clone(this.checkedSsh));
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LabeledSelect
|
||||
v-model="checkedSsh"
|
||||
:label="t('harvester.virtualMachine.input.sshKey')"
|
||||
:taggable="true"
|
||||
:mode="mode"
|
||||
:multiple="true"
|
||||
:searchable="searchable"
|
||||
:disabled="disabled"
|
||||
:options="sshOption"
|
||||
@input="update"
|
||||
/>
|
||||
|
||||
<ModalWithCard
|
||||
v-if="isOpen"
|
||||
:name="randomStr"
|
||||
width="40%"
|
||||
:errors="errors"
|
||||
@finish="save"
|
||||
@close="cancel"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('harvester.virtualMachine.sshTitle') }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<LabeledInput
|
||||
v-model="sshName"
|
||||
:label="t('harvester.virtualMachine.input.name')"
|
||||
class="mb-20"
|
||||
required
|
||||
@keydown.native.enter.prevent="()=>{}"
|
||||
/>
|
||||
|
||||
<LabeledInput
|
||||
v-model="publicKey"
|
||||
:label="t('harvester.virtualMachine.input.sshKeyValue')"
|
||||
:min-height="160"
|
||||
class="mb-20"
|
||||
type="multiline"
|
||||
required
|
||||
/>
|
||||
</template>
|
||||
</ModalWithCard>
|
||||
</div>
|
||||
</template>
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
<script>
|
||||
import ResourceTable from '@shell/components/ResourceTable';
|
||||
import FilterBySriov from '../../../components/FilterBySriov';
|
||||
import { HCI } from '../../../types';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { STATE, SIMPLE_NAME } from '@shell/config/table-headers';
|
||||
import { defaultTableSortGenerationFn } from '@shell/components/ResourceTable.vue';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
|
||||
export default {
|
||||
name: 'VGpuDeviceList',
|
||||
|
||||
components: { ResourceTable, FilterBySriov },
|
||||
|
||||
props: {
|
||||
schema: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
devices: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
},
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const hash = { sriovgpus: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SR_IOVGPU_DEVICE }) };
|
||||
|
||||
await allHash(hash);
|
||||
},
|
||||
|
||||
data() {
|
||||
const headers = [
|
||||
{ ...STATE },
|
||||
SIMPLE_NAME,
|
||||
{
|
||||
name: 'vGPUTypeName',
|
||||
label: 'vGPU Type',
|
||||
value: 'spec.vGPUTypeName',
|
||||
sort: ['spec.vGPUTypeName']
|
||||
},
|
||||
{
|
||||
name: 'node',
|
||||
labelKey: 'tableHeaders.node',
|
||||
value: 'spec.nodeName',
|
||||
sort: ['spec.nodeName']
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
label: 'Address',
|
||||
value: 'spec.address',
|
||||
sort: ['spec.address']
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
headers,
|
||||
rows: [],
|
||||
parentSriov: null,
|
||||
filterRows: []
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
devices: {
|
||||
handler(v) {
|
||||
this.rows = v;
|
||||
this.filterRows = this.rows;
|
||||
},
|
||||
immediate: true,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
parentSriovOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const allSriovs = this.$store.getters[`${ inStore }/all`](HCI.SR_IOVGPU_DEVICE) || [];
|
||||
|
||||
return allSriovs.map((sriov) => {
|
||||
return sriov.id;
|
||||
});
|
||||
},
|
||||
parentSriovLabel() {
|
||||
return HCI_ANNOTATIONS.PARENT_SRIOV_GPU;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeRows(filterRows, parentSriov) {
|
||||
this.$set(this, 'filterRows', filterRows);
|
||||
this.$set(this, 'parentSriov', parentSriov);
|
||||
},
|
||||
|
||||
sortGenerationFn() {
|
||||
let base = defaultTableSortGenerationFn(this.schema, this.$store);
|
||||
|
||||
if (this.parentSriov) {
|
||||
base += this.parentSriov;
|
||||
}
|
||||
|
||||
return base;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ResourceTable
|
||||
:headers="headers"
|
||||
:schema="schema"
|
||||
:rows="filterRows"
|
||||
:use-query-params-for-simple-filtering="true"
|
||||
:sort-generation-fn="sortGenerationFn"
|
||||
:rows-per-page="10"
|
||||
>
|
||||
<template #more-header-middle>
|
||||
<FilterBySriov
|
||||
ref="filterByParentSRIOV"
|
||||
:parent-sriov-options="parentSriovOptions"
|
||||
:parent-sriov-label="parentSriovLabel"
|
||||
:label="t('harvester.sriovgpu.parentSriov')"
|
||||
:rows="rows"
|
||||
@change-rows="changeRows"
|
||||
/>
|
||||
</template>
|
||||
</ResourceTable>
|
||||
</template>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script>
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI } from '../../../types';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import Banner from '@components/Banner/Banner.vue';
|
||||
import VGpuDeviceList from './VGpuDeviceList';
|
||||
|
||||
import remove from 'lodash/remove';
|
||||
import { get, set } from '@shell/utils/object';
|
||||
|
||||
export default {
|
||||
name: 'VirtualMachinedevices',
|
||||
components: {
|
||||
Banner,
|
||||
LabeledSelect,
|
||||
VGpuDeviceList
|
||||
},
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: _EDIT
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
|
||||
vm: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const hash = {
|
||||
devices: this.$store.dispatch('harvester/findAll', { type: HCI.VGPU_DEVICE }),
|
||||
vms: this.$store.dispatch(`harvester/findAll`, { type: HCI.VM })
|
||||
};
|
||||
|
||||
const res = await allHash(hash);
|
||||
|
||||
for (const key in res) {
|
||||
this[key] = res[key];
|
||||
}
|
||||
|
||||
(this.value?.domain?.devices?.gpus || []).forEach(({ name }) => {
|
||||
if (this.enabledDevices.find(device => device?.metadata?.name === name)) {
|
||||
this.selectedDevices.push(name);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
deviceSchema: this.$store.getters['harvester/schemaFor'](HCI.VGPU_DEVICE),
|
||||
devices: [],
|
||||
vms: [],
|
||||
selectedDevices: [],
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
selectedDevices(neu) {
|
||||
const formatted = neu.map((selectedDevice) => {
|
||||
const deviceCRD = this.enabledDevices.find(device => device.metadata.name === selectedDevice);
|
||||
const deviceName = `nvidia.com/${ deviceCRD?.status?.configureVGPUTypeName?.replace(/\s+/g, '_') }`;
|
||||
|
||||
return {
|
||||
deviceName,
|
||||
name: deviceCRD?.metadata.name,
|
||||
};
|
||||
});
|
||||
|
||||
set(this.value.domain.devices, 'gpus', formatted);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
enabledDevices() {
|
||||
return this.devices.filter((device) => {
|
||||
return device.isEnabled;
|
||||
}) || [];
|
||||
},
|
||||
|
||||
devicesInUse() {
|
||||
const inUse = this.vms.reduce((inUse, vm) => {
|
||||
if (vm.metadata.name === this.vm?.metadata?.name) {
|
||||
return inUse;
|
||||
}
|
||||
const devices = get(vm, 'spec.template.spec.domain.devices.hostDevices') || [];
|
||||
|
||||
devices.forEach((device) => {
|
||||
inUse[device.name] = { usedBy: [vm.metadata.name] };
|
||||
});
|
||||
|
||||
return inUse;
|
||||
}, {});
|
||||
|
||||
return inUse;
|
||||
},
|
||||
|
||||
devicesByNode() {
|
||||
const out = {};
|
||||
|
||||
this.enabledDevices.forEach((deviceCRD) => {
|
||||
const nodeName = deviceCRD.spec?.nodeName;
|
||||
|
||||
if (!out[nodeName]) {
|
||||
out[nodeName] = [deviceCRD];
|
||||
} else {
|
||||
out[nodeName].push(deviceCRD);
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
compatibleNodes() {
|
||||
const out = [...Object.keys(this.devicesByNode)];
|
||||
|
||||
this.selectedDevices.forEach((deviceUid) => {
|
||||
remove(out, (nodeName) => {
|
||||
const device = this.enabledDevices.find(deviceCRD => deviceCRD.metadata.name === deviceUid);
|
||||
|
||||
return device.spec.nodeName !== nodeName;
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
deviceOpts() {
|
||||
const filteredOptions = this.enabledDevices.filter((deviceCRD) => {
|
||||
if (this.selectedDevices.length > 0) {
|
||||
const selectedDevice = this.enabledDevices.find(device => device.metadata.name === this.selectedDevices[0]);
|
||||
|
||||
return !this.devicesInUse[deviceCRD?.metadata.name] && deviceCRD.spec.nodeName === selectedDevice.spec.nodeName;
|
||||
}
|
||||
|
||||
return !this.devicesInUse[deviceCRD?.metadata.name];
|
||||
});
|
||||
|
||||
return filteredOptions.map((deviceCRD) => {
|
||||
return {
|
||||
value: deviceCRD?.metadata.name,
|
||||
label: deviceCRD?.metadata.name,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<Banner color="info">
|
||||
<t k="harvester.vgpu.howToUseDevice" />
|
||||
</Banner>
|
||||
<Banner v-if="selectedDevices.length > 0" color="info">
|
||||
<t k="harvester.vgpu.deviceInTheSameHost" />
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="enabledDevices.length">
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="selectedDevices"
|
||||
label="Available vGPU Devices"
|
||||
searchable
|
||||
multiple
|
||||
taggable
|
||||
:options="deviceOpts"
|
||||
:mode="mode"
|
||||
>
|
||||
<template #option="option">
|
||||
<span>{{ option.value }}</span>
|
||||
</template>
|
||||
</LabeledSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="compatibleNodes.length && selectedDevices.length" class="row">
|
||||
<div class="col span-12 text-muted">
|
||||
Compatible hosts:
|
||||
<!-- eslint-disable-next-line vue/no-parsing-error -->
|
||||
<span v-for="(node, idx) in compatibleNodes" :key="node">{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="selectedDevices.length" class="text-error">
|
||||
{{ t('harvester.vgpu.impossibleSelection') }}
|
||||
</div>
|
||||
</template>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-12">
|
||||
<VGpuDeviceList :schema="deviceSchema" :devices="devices" @submit.prevent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,498 @@
|
||||
<script>
|
||||
import draggable from 'vuedraggable';
|
||||
import InfoBox from '@shell/components/InfoBox';
|
||||
import { Banner } from '@components/Banner';
|
||||
import BadgeStateFormatter from '@shell/components/formatter/BadgeStateFormatter';
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||
|
||||
import { PVC, STORAGE_CLASS } from '@shell/config/types';
|
||||
import { HCI } from '../../../types';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { removeObject } from '@shell/utils/array';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { SOURCE_TYPE } from '../../../config/harvester-map';
|
||||
import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params';
|
||||
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
|
||||
import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner, BadgeStateFormatter, draggable, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard
|
||||
},
|
||||
|
||||
props: {
|
||||
vm: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: _CREATE
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
namespace: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
existingVolumeDisabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
validateRequired: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
customVolumeMode: {
|
||||
type: String,
|
||||
default: 'Block'
|
||||
},
|
||||
|
||||
customAccessMode: {
|
||||
type: String,
|
||||
default: 'ReadWriteMany'
|
||||
},
|
||||
|
||||
resourceType: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
await this.$store.dispatch('harvester/findAll', { type: PVC });
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
SOURCE_TYPE,
|
||||
rows: clone(this.value),
|
||||
nameIdx: 1,
|
||||
vol: null,
|
||||
isOpen: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
dev() {
|
||||
try {
|
||||
return this.$store.getters['prefs/get'](PLUGIN_DEVELOPER);
|
||||
} catch {
|
||||
return this.$store.getters['prefs/get'](DEV);
|
||||
}
|
||||
},
|
||||
|
||||
isVirtualType() {
|
||||
return this.resourceType === HCI.VM;
|
||||
},
|
||||
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
|
||||
isEdit() {
|
||||
return this.mode === _EDIT;
|
||||
},
|
||||
|
||||
isCreate() {
|
||||
return this.mode === _CREATE;
|
||||
},
|
||||
|
||||
showVolumeTip() {
|
||||
const imageName = this.getImageDisplayName(this.rows[0]?.image);
|
||||
|
||||
if (this.rows.length === 1 && this.rows[0].type === 'cd-rom' && /.iso$/i.test(imageName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
pvcs() {
|
||||
return this.$store.getters['harvester/all'](PVC) || [];
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler(neu) {
|
||||
const rows = neu.map((V) => {
|
||||
if (!this.isCreate && V.source !== SOURCE_TYPE.CONTAINER && !V.newCreateId) {
|
||||
V.to = {
|
||||
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-namespace-id`,
|
||||
params: {
|
||||
resource: HCI.VOLUME,
|
||||
namespace: this.namespace,
|
||||
id: V.realName
|
||||
},
|
||||
query: { mode: _EDIT }
|
||||
};
|
||||
|
||||
V.pvc = this.pvcs.find((pvc) => pvc.metadata.name === V.realName);
|
||||
}
|
||||
|
||||
return V;
|
||||
});
|
||||
|
||||
this.$set(this, 'rows', rows);
|
||||
},
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
addVolume(type) {
|
||||
const name = this.generateName();
|
||||
const neu = {
|
||||
id: randomStr(5),
|
||||
name,
|
||||
source: type,
|
||||
size: '10Gi',
|
||||
type: 'disk',
|
||||
accessMode: this.customAccessMode,
|
||||
volumeMode: this.customVolumeMode,
|
||||
volumeName: '',
|
||||
bus: 'virtio',
|
||||
newCreateId: randomStr(10), // judge whether it is a disk that has been created
|
||||
};
|
||||
|
||||
if (type === SOURCE_TYPE.NEW) {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const defaultStorage = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS).find( (O) => O.isDefault);
|
||||
|
||||
neu.storageClassName = defaultStorage?.metadata?.name || 'longhorn';
|
||||
}
|
||||
|
||||
this.rows.push(neu);
|
||||
this.update();
|
||||
},
|
||||
|
||||
generateName() {
|
||||
let name = '';
|
||||
let hasName = true;
|
||||
|
||||
while (hasName) {
|
||||
name = `disk-${ this.nameIdx }`;
|
||||
hasName = this.rows.find((O) => O.name === name);
|
||||
this.nameIdx++;
|
||||
}
|
||||
|
||||
return name;
|
||||
},
|
||||
|
||||
removeVolume(vol) {
|
||||
this.vol = vol;
|
||||
if (!vol.newCreateId && this.isEdit && this.isVirtualType) {
|
||||
this.isOpen = true;
|
||||
} else {
|
||||
removeObject(this.rows, vol);
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
unplugVolume(volume) {
|
||||
this.vm.unplugVolume(volume.name);
|
||||
},
|
||||
|
||||
componentFor(type) {
|
||||
switch (type) {
|
||||
case SOURCE_TYPE.NEW:
|
||||
return require(`./type/volume.vue`).default;
|
||||
case SOURCE_TYPE.IMAGE:
|
||||
return require(`./type/vmImage.vue`).default;
|
||||
case SOURCE_TYPE.ATTACH_VOLUME:
|
||||
return require(`./type/existing.vue`).default;
|
||||
case SOURCE_TYPE.CONTAINER:
|
||||
return require(`./type/container.vue`).default;
|
||||
}
|
||||
},
|
||||
|
||||
headerFor(type) {
|
||||
return {
|
||||
[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.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'),
|
||||
}[type];
|
||||
},
|
||||
|
||||
update() {
|
||||
this.$emit('input', this.rows);
|
||||
},
|
||||
|
||||
deleteVolume() {
|
||||
removeObject(this.rows, this.vol);
|
||||
this.update();
|
||||
this.cancel();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.isOpen = false;
|
||||
},
|
||||
|
||||
changeSort(idx, type) {
|
||||
// true: down, false: up
|
||||
this.rows.splice(type ? idx : idx - 1, 1, ...this.rows.splice(type ? idx + 1 : idx, 1, this.rows[type ? idx : idx - 1]));
|
||||
this.update();
|
||||
},
|
||||
|
||||
getImageDisplayName(id) {
|
||||
return this.$store.getters['harvester/all'](HCI.IMAGE).find((image) => image.id === id)?.spec?.displayName;
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Banner
|
||||
v-if="!isView"
|
||||
color="info"
|
||||
label-key="harvester.virtualMachine.volume.dragTip"
|
||||
/>
|
||||
<draggable
|
||||
v-model="rows"
|
||||
:disabled="isView"
|
||||
@end="update"
|
||||
>
|
||||
<transition-group>
|
||||
<div
|
||||
v-for="(volume, i) in rows"
|
||||
:key="volume.id"
|
||||
>
|
||||
<InfoBox class="box">
|
||||
<button
|
||||
v-if="!isView"
|
||||
type="button"
|
||||
class="role-link btn btn-sm remove"
|
||||
@click="removeVolume(volume)"
|
||||
>
|
||||
<i class="icon icon-x" />
|
||||
</button>
|
||||
<button
|
||||
v-if="volume.hotpluggable && isView"
|
||||
type="button"
|
||||
class="role-link btn remove"
|
||||
@click="unplugVolume(volume)"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.unplug.detachVolume') }}
|
||||
</button>
|
||||
<h3>
|
||||
<span
|
||||
v-if="volume.to && isVirtualType"
|
||||
class="title"
|
||||
>
|
||||
<router-link :to="volume.to">
|
||||
{{ t('harvester.virtualMachine.volume.edit') }} {{ headerFor(volume.source) }}
|
||||
</router-link>
|
||||
|
||||
<BadgeStateFormatter
|
||||
v-if="volume.pvc"
|
||||
class="ml-10 state"
|
||||
:arbitrary="true"
|
||||
:row="volume.pvc"
|
||||
:value="volume.pvc.state"
|
||||
/>
|
||||
<a
|
||||
v-if="dev && !!volume.pvc && !!volume.pvc.resourceExternalLink"
|
||||
v-clean-tooltip="t(volume.pvc.resourceExternalLink.tipsKey || 'generic.resourceExternalLinkTips')"
|
||||
class="ml-5 resource-external"
|
||||
rel="nofollow noopener noreferrer"
|
||||
target="_blank"
|
||||
:href="volume.pvc.resourceExternalLink.url"
|
||||
>
|
||||
<i class="icon icon-external-link" />
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<span v-else>
|
||||
{{ headerFor(volume.source) }}
|
||||
</span>
|
||||
</h3>
|
||||
<div>
|
||||
<component
|
||||
:is="componentFor(volume.source)"
|
||||
v-model="rows[i]"
|
||||
:rows="rows"
|
||||
:namespace="namespace"
|
||||
:is-create="isCreate"
|
||||
:is-edit="isEdit"
|
||||
:is-view="isView"
|
||||
:is-virtual-type="isVirtualType"
|
||||
:mode="mode"
|
||||
:idx="i"
|
||||
:validate-required="validateRequired"
|
||||
@update="update"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bootOrder">
|
||||
<div
|
||||
v-if="!isView"
|
||||
class="mr-15"
|
||||
>
|
||||
<button
|
||||
:disabled="i === 0"
|
||||
class="btn btn-sm role-primary"
|
||||
@click.prevent="changeSort(i, false)"
|
||||
>
|
||||
<i class="icon icon-lg icon-chevron-up"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:disabled="i === rows.length -1"
|
||||
class="btn btn-sm role-primary"
|
||||
@click.prevent="changeSort(i, true)"
|
||||
>
|
||||
<i class="icon icon-lg icon-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-muted">
|
||||
bootOrder: {{ i + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
v-if="volume.volumeStatus && !isCreate"
|
||||
class="mt-15 volume-status"
|
||||
color="warning"
|
||||
:label="volume.volumeStatus"
|
||||
/>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
<Banner
|
||||
v-if="showVolumeTip"
|
||||
color="warning"
|
||||
:label="t('harvester.virtualMachine.volume.volumeTip')"
|
||||
/>
|
||||
|
||||
<div v-if="!isView">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm bg-primary mr-15 mb-10"
|
||||
:disabled="rows.length === 0"
|
||||
@click="addVolume(SOURCE_TYPE.NEW)"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.volume.addVolume') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!existingVolumeDisabled"
|
||||
type="button"
|
||||
class="btn btn-sm bg-primary mr-15 mb-10"
|
||||
@click="addVolume(SOURCE_TYPE.ATTACH_VOLUME)"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.volume.addExistingVolume') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm bg-primary mr-15 mb-10"
|
||||
@click="addVolume(SOURCE_TYPE.IMAGE)"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.volume.addVmImage') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm bg-primary mb-10"
|
||||
@click="addVolume(SOURCE_TYPE.CONTAINER)"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.volume.addContainer') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ModalWithCard
|
||||
v-if="isOpen"
|
||||
name="deleteTip"
|
||||
:width="400"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('harvester.virtualMachine.volume.unmount.title') }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<span>{{ t('harvester.virtualMachine.volume.unmount.message') }}</span>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="btn role-secondary mr-20"
|
||||
@click.prevent="cancel"
|
||||
>
|
||||
{{ t('generic.no') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn bg-primary mr-20"
|
||||
@click.prevent="deleteVolume"
|
||||
>
|
||||
{{ t('generic.yes') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</ModalWithCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
|
||||
.state {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.bootOrder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.volume-status:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.resource-external {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import HarvesterEditContainer from '../container.vue';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
|
||||
describe('component: HarvesterEditContainer', () => {
|
||||
it('should display all the inputs', () => {
|
||||
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
|
||||
|
||||
const inputWraps = wrapper.findAll('[data-testid^=input-hec-]');
|
||||
|
||||
expect(inputWraps).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'name',
|
||||
'container',
|
||||
])('should emit an update on %p input', (field) => {
|
||||
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
|
||||
const input = wrapper.find(`[data-testid="input-hec-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
|
||||
input.setValue(newValue);
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'type',
|
||||
'bus',
|
||||
])('should emit an update on %p selection change', async(field) => {
|
||||
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
|
||||
const select = wrapper.find(`[data-testid="input-hec-${ field }"]`);
|
||||
|
||||
select.find('button').trigger('click');
|
||||
await wrapper.trigger('keydown.down');
|
||||
await wrapper.trigger('keydown.enter');
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import HarvesterEditExisting from '../existing.vue';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
|
||||
describe('component: HarvesterEditExisting', () => {
|
||||
it('should display all the inputs', () => {
|
||||
const wrapper = mount(HarvesterEditExisting, {
|
||||
propsData: {
|
||||
mode: _EDIT, value: {}, rows: []
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const inputWraps = wrapper.findAll('[data-testid^=input-hee-]');
|
||||
|
||||
expect(inputWraps).toHaveLength(5);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'name',
|
||||
])('should emit an update on %p input', (field) => {
|
||||
const wrapper = mount(HarvesterEditExisting, {
|
||||
propsData: {
|
||||
mode: _EDIT, value: {}, rows: []
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const input = wrapper.find(`[data-testid="input-hee-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
|
||||
input.setValue(newValue);
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// TODO: #6179: Restore this test when the input is allowed to edit
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip.each([
|
||||
'size',
|
||||
])('should emit an update on %p input and blur', (field) => {
|
||||
const wrapper = mount(HarvesterEditExisting, {
|
||||
propsData: {
|
||||
mode: _EDIT, value: {}, rows: []
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const input = wrapper.find(`[data-testid="input-hee-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
|
||||
input.setValue(newValue);
|
||||
input.trigger('blur');
|
||||
|
||||
expect(wrapper.emitted('input')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'type',
|
||||
'volumeName',
|
||||
// 'bus', // disabled
|
||||
])('should emit an update on %p selection change', async(field) => {
|
||||
const wrapper = mount(HarvesterEditExisting, {
|
||||
propsData: {
|
||||
mode: _EDIT, value: {}, rows: []
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const select = wrapper.find(`[data-testid="input-hee-${ field }"]`);
|
||||
|
||||
select.find('button').trigger('click');
|
||||
await wrapper.trigger('keydown.down');
|
||||
await wrapper.trigger('keydown.enter');
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import HarvesterEditVMImage from '../vmImage.vue';
|
||||
import { _CREATE } from '@shell/config/query-params';
|
||||
|
||||
describe('component: HarvesterEditVMImage', () => {
|
||||
it('should display all the inputs', () => {
|
||||
const wrapper = mount(HarvesterEditVMImage, {
|
||||
propsData: {
|
||||
mode: _CREATE,
|
||||
isVirtualType: false,
|
||||
newCreateId: '123',
|
||||
validateRequired: true,
|
||||
idx: 1
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const inputWraps = wrapper.findAll('[data-testid^=input-hevi-]');
|
||||
|
||||
expect(inputWraps).toHaveLength(5);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'name',
|
||||
])('should emit an update on %p input', (field) => {
|
||||
const wrapper = mount(HarvesterEditVMImage, {
|
||||
propsData: {
|
||||
mode: _CREATE,
|
||||
isVirtualType: false,
|
||||
newCreateId: '123',
|
||||
validateRequired: true,
|
||||
idx: 1
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
|
||||
input.setValue(newValue);
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'size',
|
||||
])('should emit an update on %p input blur', (field) => {
|
||||
const wrapper = mount(HarvesterEditVMImage, {
|
||||
propsData: {
|
||||
mode: _CREATE,
|
||||
isVirtualType: false,
|
||||
newCreateId: '123',
|
||||
validateRequired: true,
|
||||
idx: 1
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
|
||||
input.setValue(newValue);
|
||||
input.trigger('blur');
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'type',
|
||||
'image',
|
||||
'bus',
|
||||
])('should emit an update on %p selection change', async(field) => {
|
||||
const wrapper = mount(HarvesterEditVMImage, {
|
||||
propsData: {
|
||||
mode: _CREATE,
|
||||
isVirtualType: false,
|
||||
newCreateId: '123',
|
||||
validateRequired: true,
|
||||
idx: 1
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
const select = wrapper.find(`[data-testid="input-hevi-${ field }"]`);
|
||||
|
||||
select.find('button').trigger('click');
|
||||
await wrapper.trigger('keydown.down');
|
||||
await wrapper.trigger('keydown.enter');
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import HarvesterEditVolume from '../volume.vue';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
|
||||
describe('component: HarvesterEditVolume', () => {
|
||||
it('should display all the inputs', () => {
|
||||
const wrapper = mount(HarvesterEditVolume, {
|
||||
propsData: { validateRequired: true },
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const inputWraps = wrapper.findAll('[data-testid^=input-hev-]');
|
||||
|
||||
expect(inputWraps).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'name',
|
||||
'size',
|
||||
])('should emit an update on %p input blur', (field) => {
|
||||
const wrapper = mount(HarvesterEditVolume, {
|
||||
propsData: { mode: _EDIT, validateRequired: true },
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
const input = wrapper.find(`[data-testid="input-hev-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
|
||||
input.setValue(newValue);
|
||||
input.trigger('blur');
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'type',
|
||||
'bus'
|
||||
])('should emit an update on %p selection change', async(field) => {
|
||||
const wrapper = mount(HarvesterEditVolume, {
|
||||
propsData: { mode: _EDIT, validateRequired: true },
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
const select = wrapper.find(`[data-testid="input-hev-${ field }"]`);
|
||||
|
||||
select.find('button').trigger('click');
|
||||
await wrapper.trigger('keydown.down');
|
||||
await wrapper.trigger('keydown.enter');
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<script>
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import InputOrDisplay from '@shell/components/InputOrDisplay';
|
||||
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditContainer',
|
||||
components: {
|
||||
LabeledInput, LabeledSelect, InputOrDisplay
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
VOLUME_TYPE,
|
||||
InterfaceOption
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
'value.type'(neu) {
|
||||
if (neu === 'cd-rom') {
|
||||
this.$set(this.value, 'bus', 'sata');
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('update');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hec-name"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.name')"
|
||||
:value="value.name"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model="value.name"
|
||||
:label="t('harvester.fields.name')"
|
||||
required
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="input-hec-type"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.type')"
|
||||
:value="value.type"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.type"
|
||||
:label="t('harvester.fields.type')"
|
||||
:options="VOLUME_TYPE"
|
||||
:mode="mode"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hec-container"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.virtualMachine.volume.dockerImage')"
|
||||
:value="value.container"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model="value.container"
|
||||
:label="t('harvester.virtualMachine.volume.dockerImage')"
|
||||
:mode="mode"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="input-hec-bus"
|
||||
class="col span-3"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.virtualMachine.volume.bus')"
|
||||
:value="value.bus"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.bus"
|
||||
:label="t('harvester.virtualMachine.volume.bus')"
|
||||
:options="InterfaceOption"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script>
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import InputOrDisplay from '@shell/components/InputOrDisplay';
|
||||
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { PVC } from '@shell/config/types';
|
||||
import { HCI } from '../../../../types';
|
||||
import { _CREATE } from '@shell/config/query-params';
|
||||
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditExisting',
|
||||
components: {
|
||||
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: _CREATE
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
namespace: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
idx: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
rows: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
if (this.value.realName) {
|
||||
this.value.volumeName = this.value.realName;
|
||||
}
|
||||
|
||||
return {
|
||||
VOLUME_TYPE,
|
||||
InterfaceOption,
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isDisabled() {
|
||||
return !this.value.newCreateId && this.isEdit;
|
||||
},
|
||||
|
||||
allPVCs() {
|
||||
const allPVCs = this.$store.getters['harvester/all'](PVC);
|
||||
|
||||
return allPVCs.filter((P) => {
|
||||
return this.namespace ? this.namespace === P.metadata.namespace : true;
|
||||
}) || [];
|
||||
},
|
||||
|
||||
image() {
|
||||
const imageResource = this.$store.getters['harvester/all'](HCI.IMAGE).find(I => I.id === this.pvcResource?.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID]);
|
||||
|
||||
if (!imageResource) {
|
||||
return;
|
||||
}
|
||||
|
||||
return `${ imageResource.metadata.namespace }/${ imageResource.spec.displayName }`;
|
||||
},
|
||||
|
||||
pvcResource() {
|
||||
return this.allPVCs.find( P => P.metadata.name === this.value.volumeName );
|
||||
},
|
||||
|
||||
volumeOption() {
|
||||
return sortBy(
|
||||
this.allPVCs
|
||||
.filter( (pvc) => {
|
||||
let isAvailable = true;
|
||||
let isBeingUsed = false;
|
||||
|
||||
this.rows.forEach( (O) => {
|
||||
if (O.volumeName === pvc.metadata.name) {
|
||||
isAvailable = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (this.idx === 0 && !pvc.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pvc.attachVM && isAvailable && pvc.attachVM?.id === this.vm?.id && this.isEdit) {
|
||||
isBeingUsed = false;
|
||||
} else if (pvc.attachVM) {
|
||||
isBeingUsed = true;
|
||||
}
|
||||
|
||||
return isAvailable && !isBeingUsed && pvc.isAvailable;
|
||||
})
|
||||
.map((pvc) => {
|
||||
return {
|
||||
label: pvc.metadata.name,
|
||||
value: pvc.metadata.name
|
||||
};
|
||||
}),
|
||||
'label'
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'value.volumeName'(neu) {
|
||||
const pvcResource = this.allPVCs.find( P => P.metadata.name === neu);
|
||||
|
||||
if (!pvcResource) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value.accessModes = pvcResource.spec.accessModes[0];
|
||||
this.value.size = pvcResource.spec.resources.requests.storage;
|
||||
this.value.storageClassName = pvcResource.spec.storageClassName;
|
||||
this.value.volumeMode = pvcResource.spec.volumeMode;
|
||||
},
|
||||
|
||||
'value.type'(neu) {
|
||||
if (neu === 'cd-rom') {
|
||||
this.$set(this.value, 'bus', 'sata');
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
pvcResource: {
|
||||
handler(pvc) {
|
||||
if (!this.value.volumeName && pvc?.metadata?.name) {
|
||||
this.value.volumeName = pvc.metadata.name;
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('update');
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hee-name"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.name')"
|
||||
:value="value.name"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model="value.name"
|
||||
:label="t('harvester.fields.name')"
|
||||
:mode="mode"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="input-hee-type"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.type')"
|
||||
:value="value.type"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.type"
|
||||
:label="t('harvester.fields.type')"
|
||||
:mode="mode"
|
||||
:options="VOLUME_TYPE"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hee-volumeName"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.volume')"
|
||||
:value="value.volumeName"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.volumeName"
|
||||
:disabled="isDisabled"
|
||||
:label="t('harvester.fields.volume')"
|
||||
:mode="mode"
|
||||
:options="volumeOption"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="input-hee-size"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.size')"
|
||||
:value="value.size"
|
||||
:mode="mode"
|
||||
>
|
||||
<UnitInput
|
||||
v-model="value.size"
|
||||
:output-modifier="true"
|
||||
:increment="1024"
|
||||
:input-exponent="3"
|
||||
:label="t('harvester.fields.size')"
|
||||
:mode="mode"
|
||||
:disabled="true"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
v-if="!!image"
|
||||
data-testid="input-hee-image"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.image')"
|
||||
:value="image"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model="image"
|
||||
:label="t('harvester.fields.image')"
|
||||
:mode="mode"
|
||||
:disabled="true"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="input-hee-bus"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.virtualMachine.volume.bus')"
|
||||
:value="value.bus"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.bus"
|
||||
:label="t('harvester.virtualMachine.volume.bus')"
|
||||
:mode="mode"
|
||||
:options="InterfaceOption"
|
||||
:disabled="true"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script>
|
||||
import { findBy } from '@shell/utils/array';
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import InputOrDisplay from '@shell/components/InputOrDisplay';
|
||||
import { PVC } from '@shell/config/types';
|
||||
import { HCI } from '../../../../types';
|
||||
import { formatSi, parseSi } from '@shell/utils/units';
|
||||
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditVMImage',
|
||||
|
||||
components: {
|
||||
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
namespace: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
|
||||
idx: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
isCreate: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
validateRequired: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
|
||||
isVirtualType: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
VOLUME_TYPE,
|
||||
InterfaceOption,
|
||||
loading: false,
|
||||
images: [],
|
||||
};
|
||||
},
|
||||
|
||||
fetch() {
|
||||
this.images = this.$store.getters['harvester/all'](HCI.IMAGE);
|
||||
},
|
||||
|
||||
computed: {
|
||||
imagesOption() {
|
||||
return this.images.filter(c => c.isReady).sort((a, b) => a.creationTimestamp > b.creationTimestamp ? -1 : 1).map( (I) => {
|
||||
return {
|
||||
label: `${ I.metadata.namespace }/${ I.spec.displayName }`,
|
||||
value: I.id
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
imageName() {
|
||||
const image = this.imagesOption.find(I => I.value === this.value.image);
|
||||
|
||||
return image ? image.label : '-';
|
||||
},
|
||||
|
||||
pvcsResource() {
|
||||
const allPVCs = this.$store.getters['harvester/all'](PVC) || [];
|
||||
|
||||
return allPVCs.find((P) => {
|
||||
return this.namespace ? P.id === `${ this.namespace }/${ this.value.volumeName }` : true;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
'value.type'(neu) {
|
||||
if (neu === 'cd-rom') {
|
||||
this.$set(this.value, 'bus', 'sata');
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
'value.image'(neu) {
|
||||
this.checkImageExists(neu);
|
||||
},
|
||||
|
||||
imagesOption() {
|
||||
this.checkImageExists(this.value.image);
|
||||
},
|
||||
|
||||
pvcsResource: {
|
||||
handler(pvc) {
|
||||
if (pvc?.spec?.resources?.requests?.storage && this.isVirtualType) {
|
||||
const parseValue = parseSi(pvc.spec.resources.requests.storage);
|
||||
|
||||
const formatSize = formatSi(parseValue, {
|
||||
increment: 1024,
|
||||
addSuffix: false,
|
||||
maxExponent: 3,
|
||||
minExponent: 3,
|
||||
});
|
||||
|
||||
this.value.size = `${ formatSize }Gi`;
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('update');
|
||||
},
|
||||
|
||||
onImageChange() {
|
||||
const imageResource = this.$store.getters['harvester/all'](HCI.IMAGE)?.find( I => this.value.image === I.id);
|
||||
|
||||
if (this.idx === 0) {
|
||||
if (/iso$/i.test(imageResource?.imageSuffix)) {
|
||||
this.$set(this.value, 'type', 'cd-rom');
|
||||
this.$set(this.value, 'bus', 'sata');
|
||||
} else {
|
||||
this.$set(this.value, 'type', 'disk');
|
||||
this.$set(this.value, 'bus', 'virtio');
|
||||
}
|
||||
}
|
||||
|
||||
this.update();
|
||||
},
|
||||
|
||||
onOpen() {
|
||||
this.images = this.$store.getters['harvester/all'](HCI.IMAGE);
|
||||
},
|
||||
|
||||
checkImageExists(imageId) {
|
||||
if (!!imageId && this.imagesOption.length > 0 && !findBy(this.imagesOption, 'value', imageId)) {
|
||||
this.$store.dispatch('growl/error', {
|
||||
title: this.$store.getters['i18n/t']('harvester.vmTemplate.tips.notExistImage.title', { name: imageId }),
|
||||
message: this.$store.getters['i18n/t']('harvester.vmTemplate.tips.notExistImage.message')
|
||||
}, { root: true });
|
||||
|
||||
this.$set(this.value, 'image', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hevi-name"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay :name="t('harvester.fields.name')" :value="value.name" :mode="mode">
|
||||
<LabeledInput
|
||||
v-model="value.name"
|
||||
:label="t('harvester.fields.name')"
|
||||
required
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="input-hevi-type"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.type')"
|
||||
:value="value.type"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.type"
|
||||
:label="t('harvester.fields.type')"
|
||||
:options="VOLUME_TYPE"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hevi-image"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.image')"
|
||||
:value="imageName"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.image"
|
||||
:disabled="idx === 0 && !isCreate && !value.newCreateId && isVirtualType"
|
||||
:label="t('harvester.fields.image')"
|
||||
:options="imagesOption"
|
||||
:mode="mode"
|
||||
:searchable="true"
|
||||
:required="validateRequired"
|
||||
@input="onImageChange"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="input-hevi-size"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.size')"
|
||||
:value="value.size"
|
||||
:mode="mode"
|
||||
>
|
||||
<UnitInput
|
||||
v-model="value.size"
|
||||
v-int-number
|
||||
:output-modifier="true"
|
||||
:increment="1024"
|
||||
:input-exponent="3"
|
||||
:label="t('harvester.fields.size')"
|
||||
:mode="mode"
|
||||
:required="validateRequired"
|
||||
suffix="GiB"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hevi-bus"
|
||||
class="col span-3"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.virtualMachine.volume.bus')"
|
||||
:value="value.bus"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.bus"
|
||||
:label="t('harvester.virtualMachine.volume.bus')"
|
||||
:mode="mode"
|
||||
:options="InterfaceOption"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script>
|
||||
import Loading from '@shell/components/Loading';
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
import InputOrDisplay from '@shell/components/InputOrDisplay';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
|
||||
import { PVC, STORAGE_CLASS } from '@shell/config/types';
|
||||
import { formatSi, parseSi } from '@shell/utils/units';
|
||||
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditVolume',
|
||||
components: {
|
||||
InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput,
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
namespace: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
validateRequired: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
|
||||
isVirtualType: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
VOLUME_TYPE,
|
||||
InterfaceOption,
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
pvcsResource() {
|
||||
const allPVCs = this.$store.getters['harvester/all'](PVC) || [];
|
||||
|
||||
return allPVCs.find(P => P.id === `${ this.namespace }/${ this.value.volumeName }`);
|
||||
},
|
||||
|
||||
isDisabled() {
|
||||
return !this.value.newCreateId && this.isEdit && this.isVirtualType;
|
||||
},
|
||||
|
||||
storageClassOptions() {
|
||||
const storages = this.$store.getters[`harvester/all`](STORAGE_CLASS) || [];
|
||||
|
||||
const out = storages.filter(s => !s.parameters?.backingImage).map((s) => {
|
||||
const label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
|
||||
|
||||
return {
|
||||
label,
|
||||
value: s.name,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'value.type'(neu) {
|
||||
if (neu === 'cd-rom') {
|
||||
this.$set(this.value, 'bus', 'sata');
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
pvcsResource: {
|
||||
handler(pvc) {
|
||||
if (pvc?.spec?.resources?.requests?.storage) {
|
||||
const parseValue = parseSi(pvc.spec.resources.requests.storage);
|
||||
|
||||
const formatSize = formatSi(parseValue, {
|
||||
increment: 1024,
|
||||
addSuffix: false,
|
||||
maxExponent: 3,
|
||||
minExponent: 3,
|
||||
});
|
||||
|
||||
this.value.size = `${ formatSize }Gi`;
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('update');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Loading mode="relative" :loading="loading" />
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
class="col span-6"
|
||||
data-testid="input-hev-name"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.name')"
|
||||
:value="value.name"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model="value.name"
|
||||
:label="t('harvester.fields.name')"
|
||||
:mode="mode"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col span-6"
|
||||
data-testid="input-hev-type"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.type')"
|
||||
:value="value.type"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.type"
|
||||
:label="t('harvester.fields.type')"
|
||||
:options="VOLUME_TYPE"
|
||||
required
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hav-storage"
|
||||
class="col span-6"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.storage.storageClass.label')"
|
||||
:value="value.storageClassName"
|
||||
:mode="mode"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.storageClassName"
|
||||
:options="storageClassOptions"
|
||||
:label="t('harvester.storage.storageClass.label')"
|
||||
:mode="mode"
|
||||
:disabled="isDisabled"
|
||||
:required="validateRequired"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
<div
|
||||
class="col span-6"
|
||||
data-testid="input-hev-size"
|
||||
>
|
||||
<InputOrDisplay
|
||||
:name="t('harvester.fields.size')"
|
||||
:value="value.size"
|
||||
:mode="mode"
|
||||
>
|
||||
<UnitInput
|
||||
v-model="value.size"
|
||||
v-int-number
|
||||
:output-modifier="true"
|
||||
:increment="1024"
|
||||
:input-exponent="3"
|
||||
:mode="mode"
|
||||
:required="validateRequired"
|
||||
:label="t('harvester.fields.size')"
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div
|
||||
data-testid="input-hev-bus"
|
||||
class="col span-3"
|
||||
>
|
||||
<InputOrDisplay :name="t('harvester.virtualMachine.volume.bus')" :value="value.bus" :mode="mode">
|
||||
<LabeledSelect
|
||||
v-model="value.bus"
|
||||
:label="t('harvester.virtualMachine.volume.bus')"
|
||||
:mode="mode"
|
||||
:options="InterfaceOption"
|
||||
required
|
||||
@input="update"
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,836 @@
|
||||
<script>
|
||||
import { isEqual } from 'lodash';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
import Labels from '@shell/components/form/Labels';
|
||||
|
||||
import Reserved from './VirtualMachineReserved';
|
||||
import SSHKey from './VirtualMachineSSHKey';
|
||||
import Volume from './VirtualMachineVolume';
|
||||
import Network from './VirtualMachineNetwork';
|
||||
import CpuMemory from './VirtualMachineCpuMemory';
|
||||
import CloudConfig from './VirtualMachineCloudConfig';
|
||||
import NodeScheduling from '@shell/components/form/NodeScheduling';
|
||||
import PodAffinity from '@shell/components/form/PodAffinity';
|
||||
import AccessCredentials from './VirtualMachineAccessCredentials';
|
||||
import PciDevices from './VirtualMachinePciDevices/index';
|
||||
import VirtualMachineVGpuDevices from './VirtualMachineVGpuDevices/index';
|
||||
import RestartVMDialog from '../../dialog/RestartVMDialog';
|
||||
import KeyValue from '@shell/components/form/KeyValue';
|
||||
|
||||
import { clear } from '@shell/utils/array';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { HCI } from '../../types';
|
||||
import { RunStrategys } from '../../config/harvester-map';
|
||||
import { saferDump } from '@shell/utils/create-yaml';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { BEFORE_SAVE_HOOKS, AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
|
||||
|
||||
import VM_MIXIN from '../../mixins/harvester-vm';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
import { parseVolumeClaimTemplates } from '@pkg/utils/vm';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditVM',
|
||||
|
||||
components: {
|
||||
Tab,
|
||||
Tabbed,
|
||||
Checkbox,
|
||||
RadioGroup,
|
||||
CruResource,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
NameNsDescription,
|
||||
Volume,
|
||||
SSHKey,
|
||||
Network,
|
||||
CpuMemory,
|
||||
CloudConfig,
|
||||
NodeScheduling,
|
||||
PodAffinity,
|
||||
AccessCredentials,
|
||||
Reserved,
|
||||
Labels,
|
||||
PciDevices,
|
||||
RestartVMDialog,
|
||||
UnitInput,
|
||||
VirtualMachineVGpuDevices,
|
||||
KeyValue,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView, VM_MIXIN],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
const cloneVM = clone(this.value);
|
||||
const isRestartImmediately = this.value.actualState === 'Running';
|
||||
|
||||
const hostname = this.value.spec.template.spec.hostname || '';
|
||||
|
||||
return {
|
||||
cloneVM,
|
||||
count: 2,
|
||||
templateId: '',
|
||||
templateVersionId: '',
|
||||
namePrefix: '',
|
||||
isSingle: true,
|
||||
useTemplate: false,
|
||||
isOpen: false,
|
||||
hostname,
|
||||
isRestartImmediately,
|
||||
RunStrategys,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
machineTypeOptions() {
|
||||
return [{
|
||||
label: 'None',
|
||||
value: ''
|
||||
}, {
|
||||
label: 'q35',
|
||||
value: 'q35'
|
||||
}];
|
||||
},
|
||||
|
||||
templateOptions() {
|
||||
return this.templates.map( (T) => {
|
||||
const isReady = this.hasAvailableVersion(T.id);
|
||||
|
||||
return {
|
||||
label: T.id,
|
||||
value: T.id,
|
||||
disabled: !isReady
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
versionOptions() {
|
||||
const defaultVersion = this.curTemplateResource?.defaultVersion;
|
||||
|
||||
return this.versions.filter( (O) => O.templateId === this.templateId).map( (T) => {
|
||||
const version = T.version;
|
||||
|
||||
const label = defaultVersion === version ? `${ version } (${ this.t('generic.default') })` : version;
|
||||
const value = T.id;
|
||||
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
disabled: !T.isReady
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
curTemplateResource() {
|
||||
return this.templates.find( (O) => O.id === this.templateId);
|
||||
},
|
||||
|
||||
nameLabel() {
|
||||
return this.isSingle ? 'harvester.virtualMachine.instance.single.nameLabel' : 'harvester.virtualMachine.instance.multiple.nameLabel';
|
||||
},
|
||||
|
||||
hostnameLabel() {
|
||||
return this.isSingle ? 'harvester.virtualMachine.instance.single.host.label' : 'harvester.virtualMachine.instance.multiple.host.label';
|
||||
},
|
||||
|
||||
hostPlaceholder() {
|
||||
return this.isSingle ? this.t('harvester.virtualMachine.instance.single.host.placeholder') : this.t('harvester.virtualMachine.instance.multiple.host.placeholder');
|
||||
},
|
||||
|
||||
secretNamePrefix() {
|
||||
return this.value?.metadata?.name;
|
||||
},
|
||||
|
||||
isQemuInstalled() {
|
||||
return this.value.isQemuInstalled;
|
||||
},
|
||||
|
||||
hasRestartAction() {
|
||||
return this.value.hasAction('restart');
|
||||
},
|
||||
|
||||
hasStartAction() {
|
||||
return this.value.hasAction('start');
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
templateId: {
|
||||
async handler(id, old) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (id !== old && !this.templateVersionId) {
|
||||
const templates = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_TEMPLATE });
|
||||
|
||||
this.templateVersionId = templates.find( (O) => O.id === id)?.spec?.defaultVersionId;
|
||||
}
|
||||
},
|
||||
immediate: false
|
||||
},
|
||||
|
||||
templateVersionId: {
|
||||
async handler(id) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const versions = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_VERSION });
|
||||
const curVersion = versions.find( (V) => V.id === id);
|
||||
const cloneVersionVM = clone(curVersion.spec.vm);
|
||||
|
||||
delete cloneVersionVM.spec?.template?.spec?.accessCredentials;
|
||||
delete cloneVersionVM.spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_NAMES];
|
||||
delete cloneVersionVM.spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_USERS];
|
||||
|
||||
const claimTemplate = parseVolumeClaimTemplates(cloneVersionVM);
|
||||
|
||||
const deleteDataSource = claimTemplate.map((volume) => {
|
||||
if (volume?.spec?.dataSource) {
|
||||
delete volume.spec.dataSource;
|
||||
}
|
||||
|
||||
return volume;
|
||||
});
|
||||
|
||||
cloneVersionVM.metadata.annotations[HCI_ANNOTATIONS.VOLUME_CLAIM_TEMPLATE] = JSON.stringify(deleteDataSource);
|
||||
|
||||
this.getInitConfig({
|
||||
value: cloneVersionVM, existUserData: true, fromTemplate: true
|
||||
});
|
||||
this.$set(this, 'hasCreateVolumes', []); // When using the template, all volume names need to be newly created
|
||||
}
|
||||
},
|
||||
|
||||
useTemplate(neu) {
|
||||
if (neu === false) {
|
||||
this.templateId = '';
|
||||
this.templateVersionId = '';
|
||||
this.value.applyDefaults();
|
||||
this.getInitConfig({ value: this.value, init: this.isCreate });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.registerAfterHook(async() => {
|
||||
await this.restartVM();
|
||||
const id = `${ this.value.metadata.namespace }/${ this.value.metadata.name }`;
|
||||
|
||||
const res = this.$store.getters['harvester/byId'](HCI.VM, id);
|
||||
|
||||
try {
|
||||
await this.saveSecret(res);
|
||||
await this.saveAccessCredentials(res);
|
||||
} catch (e) {
|
||||
this.errors.push(...exceptionToErrorsArray(e));
|
||||
}
|
||||
|
||||
if (!this.errors.length && this.isSingle) {
|
||||
this.done();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.imageId = this.$route.query?.image || this.imageId;
|
||||
|
||||
const diskRows = this.getDiskRows(this.value);
|
||||
|
||||
this.$set(this, 'diskRows', diskRows);
|
||||
const templateId = this.$route.query.templateId;
|
||||
const templateVersionId = this.$route.query.versionId;
|
||||
|
||||
if (templateId && templateVersionId) {
|
||||
this.templateId = templateId;
|
||||
this.templateVersionId = templateVersionId;
|
||||
this.useTemplate = true;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
saveVM(buttonCb) {
|
||||
clear(this.errors);
|
||||
|
||||
if (this.isSingle) {
|
||||
this.saveSingle(buttonCb);
|
||||
} else {
|
||||
this.saveMultiple(buttonCb);
|
||||
}
|
||||
},
|
||||
|
||||
async saveSingle(buttonCb) {
|
||||
this.parseVM();
|
||||
this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name;
|
||||
await this._save(this.value, buttonCb);
|
||||
if (!this.errors.length) {
|
||||
buttonCb(true);
|
||||
} else {
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
async saveMultiple(buttonCb) {
|
||||
this.namePrefix = this.value.metadata.name || '';
|
||||
const join = this.namePrefix.endsWith('-') ? '' : '-';
|
||||
const baseHostname = this.hostname ? this.hostname : this.namePrefix;
|
||||
|
||||
if (this.count < 1) {
|
||||
this.errors = [this.t('harvester.virtualMachine.instance.multiple.countTip')];
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.value.metadata.name) {
|
||||
this.errors.push(this.t('validation.required', { key: this.t('generic.name') }, true));
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cloneValue = clone(this.value);
|
||||
const cloneSpec = clone(this.spec);
|
||||
|
||||
for (let i = 1; i <= this.count; i++) {
|
||||
this.$set(this.value, 'spec', cloneValue.spec);
|
||||
this.$set(this, 'spec', cloneSpec);
|
||||
const suffix = i < 10 ? `0${ i }` : i;
|
||||
|
||||
this.value.cleanForNew();
|
||||
this.value.metadata.name = `${ this.namePrefix }${ join }${ suffix }`;
|
||||
this.$set(this.value.spec.template.spec, 'hostname', `${ baseHostname }${ join }${ suffix }`);
|
||||
this.secretName = '';
|
||||
await this.parseVM();
|
||||
const basicValue = await this.$store.dispatch('harvester/clone', { resource: this.value });
|
||||
|
||||
await this._save(basicValue, buttonCb);
|
||||
|
||||
if (i === this.count && !this.errors.length) {
|
||||
buttonCb(true);
|
||||
this.done();
|
||||
} else if (i === this.count) {
|
||||
this.value.metadata.name = this.namePrefix;
|
||||
buttonCb(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async _save(value, buttonCb) {
|
||||
try {
|
||||
await this.applyHooks(BEFORE_SAVE_HOOKS);
|
||||
await value.save();
|
||||
await this.applyHooks(AFTER_SAVE_HOOKS);
|
||||
} catch (e) {
|
||||
this.errors.push(...exceptionToErrorsArray(e));
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
restartVM() {
|
||||
if (this.mode !== 'edit') {
|
||||
return;
|
||||
}
|
||||
if (!this.value.isRunning) {
|
||||
return;
|
||||
}
|
||||
const cloneDeepNewVM = clone(this.value);
|
||||
|
||||
delete cloneDeepNewVM?.metadata;
|
||||
delete this.cloneVM?.metadata;
|
||||
delete this.cloneVM?.__clone;
|
||||
|
||||
const oldVM = JSON.parse(JSON.stringify(this.cloneVM));
|
||||
const newVM = JSON.parse(JSON.stringify(cloneDeepNewVM));
|
||||
|
||||
if (isEqual(oldVM, newVM)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.isOpen = true;
|
||||
this.$refs.restartDialog.resolve = resolve;
|
||||
});
|
||||
},
|
||||
|
||||
updateBeforeSave() {
|
||||
if (this.isSingle) {
|
||||
if (!this.value.spec.template.spec.hostname) {
|
||||
this.$set(this.value.spec.template.spec, 'hostname', this.value.metadata.name);
|
||||
}
|
||||
}
|
||||
|
||||
const errors = this.getAccessCredentialsValidation();
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Promise.reject(errors);
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
|
||||
validateCount(count) {
|
||||
if (count > 10) {
|
||||
this.$set(this, 'count', 10);
|
||||
}
|
||||
},
|
||||
|
||||
updateTemplateId() {
|
||||
this.templateVersionId = '';
|
||||
},
|
||||
|
||||
onTabChanged({ tab }) {
|
||||
if (tab.name === 'advanced') {
|
||||
this.$refs.yamlEditor?.refresh();
|
||||
}
|
||||
},
|
||||
|
||||
hasAvailableVersion(templateId) {
|
||||
let hasAvailableVersion = false;
|
||||
|
||||
this.versions.filter( (O) => O.templateId === templateId).find( (T) => {
|
||||
if (T.isReady) {
|
||||
hasAvailableVersion = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasAvailableVersion;
|
||||
},
|
||||
|
||||
generateYaml() {
|
||||
this.parseVM();
|
||||
const out = saferDump(this.value);
|
||||
|
||||
return out;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
v-if="spec"
|
||||
id="vm"
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:can-yaml="isSingle ? true : false"
|
||||
:errors="errors"
|
||||
:generate-yaml="generateYaml"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="saveVM"
|
||||
>
|
||||
<RadioGroup
|
||||
v-if="isCreate"
|
||||
v-model="isSingle"
|
||||
class="mb-20 vm-radio-group"
|
||||
name="createInstanceMode"
|
||||
:options="[true,false]"
|
||||
:labels="[t('harvester.virtualMachine.instance.single.label'), t('harvester.virtualMachine.instance.multiple.label')]"
|
||||
/>
|
||||
|
||||
<NameNsDescription
|
||||
v-model="value"
|
||||
:mode="mode"
|
||||
:has-extra="!isSingle"
|
||||
:name-label="nameLabel"
|
||||
:namespaced="true"
|
||||
:name-placeholder="isSingle ? 'nameNsDescription.name.placeholder' : 'harvester.virtualMachine.instance.multiple.nameNsDescription'"
|
||||
:extra-columns="isSingle ? [] :['type']"
|
||||
>
|
||||
<template v-slot:type>
|
||||
<LabeledInput
|
||||
v-if="!isSingle"
|
||||
v-model.number="count"
|
||||
v-int-number
|
||||
:min="2"
|
||||
type="number"
|
||||
:label="t('harvester.virtualMachine.instance.multiple.count')"
|
||||
required
|
||||
@input="validateCount"
|
||||
/>
|
||||
</template>
|
||||
</NameNsDescription>
|
||||
|
||||
<Checkbox
|
||||
v-if="isCreate"
|
||||
v-model="useTemplate"
|
||||
class="check mb-20"
|
||||
type="checkbox"
|
||||
label-key="harvester.virtualMachine.useTemplate.label"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="useTemplate"
|
||||
class="row mb-20"
|
||||
>
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="templateId"
|
||||
label-key="harvester.virtualMachine.useTemplate.template.label"
|
||||
:options="templateOptions"
|
||||
@input="updateTemplateId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="templateVersionId"
|
||||
label-key="harvester.virtualMachine.useTemplate.version.label"
|
||||
:options="versionOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabbed
|
||||
:side-tabs="true"
|
||||
@changed="onTabChanged"
|
||||
>
|
||||
<Tab
|
||||
name="basics"
|
||||
:label="t('harvester.virtualMachine.detail.tabs.basics')"
|
||||
>
|
||||
<CpuMemory
|
||||
:cpu="cpu"
|
||||
:memory="memory"
|
||||
:mode="mode"
|
||||
@updateCpuMemory="updateCpuMemory"
|
||||
/>
|
||||
|
||||
<SSHKey
|
||||
v-model="sshKey"
|
||||
class="mb-20"
|
||||
:create-namespace="true"
|
||||
:namespace="value.metadata.namespace"
|
||||
:mode="mode"
|
||||
:disabled="isWindows"
|
||||
@update:sshKey="updateSSHKey"
|
||||
@register-after-hook="registerAfterHook"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="Volume"
|
||||
:label="t('harvester.tab.volume')"
|
||||
:weight="-1"
|
||||
>
|
||||
<Volume
|
||||
v-model="diskRows"
|
||||
:mode="mode"
|
||||
:custom-volume-mode="customVolumeMode"
|
||||
:namespace="value.metadata.namespace"
|
||||
:resource-type="value.type"
|
||||
:vm="value"
|
||||
:validate-required="true"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="Network"
|
||||
:label="t('harvester.tab.network')"
|
||||
:weight="-2"
|
||||
>
|
||||
<Network
|
||||
v-model="networkRows"
|
||||
:mode="mode"
|
||||
:is-single="isSingle"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="nodeScheduling"
|
||||
:label="t('workload.container.titles.nodeScheduling')"
|
||||
:weight="-3"
|
||||
>
|
||||
<template #default="{active}">
|
||||
<NodeScheduling
|
||||
:key="active"
|
||||
:mode="mode"
|
||||
:value="spec.template.spec"
|
||||
:nodes="nodesIdOptions"
|
||||
/>
|
||||
</template>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
:label="t('harvester.tab.vmScheduling')"
|
||||
name="vmScheduling"
|
||||
:weight="-4"
|
||||
>
|
||||
<template #default="{active}">
|
||||
<PodAffinity
|
||||
:key="active"
|
||||
:mode="mode"
|
||||
:value="spec.template.spec"
|
||||
:nodes="nodes"
|
||||
:all-namespaces-option-available="true"
|
||||
:namespaces="filteredNamespaces"
|
||||
:overwrite-labels="affinityLabels"
|
||||
/>
|
||||
</template>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
v-if="enabledPCI"
|
||||
:label="t('harvester.tab.pciDevices')"
|
||||
name="pciDevices"
|
||||
:weight="-5"
|
||||
>
|
||||
<PciDevices
|
||||
:mode="mode"
|
||||
:value="spec.template.spec"
|
||||
:vm="value"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
v-if="enabledSriovgpu"
|
||||
:label="t('harvester.tab.vGpuDevices')"
|
||||
name="vGpuDevices"
|
||||
:weight="-6"
|
||||
>
|
||||
<VirtualMachineVGpuDevices
|
||||
:mode="mode"
|
||||
:value="spec.template.spec"
|
||||
:vm="value"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
v-if="isEdit"
|
||||
:label="t('harvester.tab.accessCredentials')"
|
||||
name="accessCredentials"
|
||||
:weight="-7"
|
||||
>
|
||||
<AccessCredentials
|
||||
v-model="accessCredentials"
|
||||
:mode="mode"
|
||||
:resource="value"
|
||||
:is-qemu-installed="isQemuInstalled"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="advanced"
|
||||
:label="t('harvester.tab.advanced')"
|
||||
:weight="-8"
|
||||
>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="runStrategy"
|
||||
label-key="harvester.virtualMachine.runStrategy"
|
||||
:options="RunStrategys"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="osType"
|
||||
label-key="harvester.virtualMachine.osType"
|
||||
:options="OS"
|
||||
:disabled="!isCreate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<a
|
||||
v-if="showAdvanced"
|
||||
v-t="'harvester.generic.showMore'"
|
||||
role="button"
|
||||
@click="toggleAdvanced"
|
||||
/>
|
||||
<a
|
||||
v-else
|
||||
v-t="'harvester.generic.showMore'"
|
||||
role="button"
|
||||
@click="toggleAdvanced"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showAdvanced"
|
||||
class="mb-20"
|
||||
>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model="hostname"
|
||||
:label-key="hostnameLabel"
|
||||
:placeholder="hostPlaceholder"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="machineType"
|
||||
label-key="harvester.virtualMachine.input.MachineType"
|
||||
:options="machineTypeOptions"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<Reserved
|
||||
:reserved-memory="reservedMemory"
|
||||
:mode="mode"
|
||||
@updateReserved="updateReserved"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<UnitInput
|
||||
v-model="terminationGracePeriodSeconds"
|
||||
:suffix="terminationGracePeriodSeconds == 1 ? 'Second' : 'Seconds'"
|
||||
:label="t('harvester.virtualMachine.terminationGracePeriodSeconds.label')"
|
||||
:mode="mode"
|
||||
positive
|
||||
@input="updateTerminationGracePeriodSeconds"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CloudConfig
|
||||
ref="yamlEditor"
|
||||
:user-script="userScript"
|
||||
:mode="mode"
|
||||
:view-code="isWindows"
|
||||
:namespace="value.metadata.namespace"
|
||||
:network-script="networkScript"
|
||||
@updateUserData="updateUserData"
|
||||
@updateNetworkData="updateNetworkData"
|
||||
@updateDataTemplateId="updateDataTemplateId"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model="installUSBTablet"
|
||||
class="check mt-20"
|
||||
type="checkbox"
|
||||
tooltip-key="harvester.virtualMachine.usbTip"
|
||||
label-key="harvester.virtualMachine.enableUsb"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model="installAgent"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
:disabled="isWindows"
|
||||
label-key="harvester.virtualMachine.installAgent"
|
||||
:mode="mode"
|
||||
@input="updateAgent"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model="tpmEnabled"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
label-key="harvester.virtualMachine.advancedOptions.tpm"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model="efiEnabled"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
:label="t('harvester.virtualMachine.efiEnabled')"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-if="efiEnabled"
|
||||
v-model="secureBoot"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
:label="t('harvester.virtualMachine.secureBoot')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="instanceLabel"
|
||||
:label="t('harvester.tab.instanceLabel')"
|
||||
:weight="-99"
|
||||
>
|
||||
<Labels
|
||||
:default-container-class="'labels-and-annotations-container'"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:display-side-by-side="false"
|
||||
:show-annotations="false"
|
||||
:show-label-title="false"
|
||||
>
|
||||
<template #labels="{toggler}">
|
||||
<KeyValue
|
||||
key="labels"
|
||||
:value="value.instanceLabels"
|
||||
:protected-keys="value.systemLabels || []"
|
||||
:toggle-filter="toggler"
|
||||
:add-label="t('labels.addLabel')"
|
||||
:mode="mode"
|
||||
:read-allowed="false"
|
||||
:value-can-be-empty="true"
|
||||
@input="value.setInstanceLabels($event)"
|
||||
/>
|
||||
</template>
|
||||
</Labels>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
|
||||
<RestartVMDialog
|
||||
v-if="isOpen"
|
||||
:vm="value"
|
||||
/>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#vm {
|
||||
::v-deep .vm-radio-group .radio-group {
|
||||
display: flex;
|
||||
.radio-container {
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.restart {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.banner-right {
|
||||
width: auto;
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script>
|
||||
import debounce from 'lodash/debounce';
|
||||
import { _EDIT, _VIEW } from '@shell/config/query-params';
|
||||
import { removeAt } from '@shell/utils/array';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { mapGetters } from 'vuex';
|
||||
import PriorityRow from './PriorityRow';
|
||||
|
||||
export default {
|
||||
components: { PriorityRow },
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: _EDIT,
|
||||
},
|
||||
autoAddIfEmpty: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const rows = clone(this.value || []);
|
||||
|
||||
return { rows };
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['isRancherInHarvester', 'isStandaloneHarvester']),
|
||||
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
|
||||
showAdd() {
|
||||
return !this.isView;
|
||||
},
|
||||
|
||||
showRemove() {
|
||||
return !this.isView;
|
||||
},
|
||||
|
||||
showProjectAndCluster() {
|
||||
return !this.isStandaloneHarvester;
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.queueUpdate = debounce(this.update, 500);
|
||||
},
|
||||
|
||||
methods: {
|
||||
add() {
|
||||
const defaultRow = { namespace: '*' };
|
||||
|
||||
if (!this.showProjectAndCluster) {
|
||||
this.rows.push(defaultRow);
|
||||
} else {
|
||||
this.rows.push({
|
||||
...defaultRow,
|
||||
project: '*',
|
||||
guestCluster: '*',
|
||||
});
|
||||
}
|
||||
|
||||
this.queueUpdate();
|
||||
},
|
||||
|
||||
remove(idx) {
|
||||
removeAt(this.rows, idx);
|
||||
this.queueUpdate();
|
||||
},
|
||||
|
||||
update() {
|
||||
if ( this.isView ) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('input', this.rows);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3>
|
||||
{{ t('harvester.ipPool.scope.label') }}
|
||||
</h3>
|
||||
<div v-if="rows.length">
|
||||
<div
|
||||
class="pool-headers"
|
||||
:class="{
|
||||
'show-project-and-cluster': showProjectAndCluster,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="showProjectAndCluster"
|
||||
class="pool-project"
|
||||
>
|
||||
<t k="harvester.ipPool.project.label" />
|
||||
</span>
|
||||
<span class="pool-namespace">
|
||||
<t k="harvester.ipPool.namespace.label" />
|
||||
</span>
|
||||
<span
|
||||
v-if="showProjectAndCluster"
|
||||
class="pool-guestCluster"
|
||||
>
|
||||
<t k="harvester.ipPool.guestCluster.label" />
|
||||
</span>
|
||||
<span
|
||||
v-if="showRemove"
|
||||
class="remove"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, idx) in rows"
|
||||
:key="idx"
|
||||
>
|
||||
<PriorityRow
|
||||
:row="row"
|
||||
:mode="mode"
|
||||
:rows="rows"
|
||||
:idx="idx"
|
||||
@input="queueUpdate"
|
||||
@remove="remove(idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showAdd" class="footer">
|
||||
<button type="button" class="btn role-tertiary add" @click="add()">
|
||||
<t k="harvester.ipPool.scope.addLabel" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pool-headers {
|
||||
display: grid;
|
||||
grid-column-gap: $column-gutter;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
|
||||
grid-template-columns: 40% 40% 15%;
|
||||
|
||||
&.show-project-and-cluster {
|
||||
grid-template-columns: 25% 25% 25% 15%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script>
|
||||
import { _EDIT, _VIEW } from '@shell/config/query-params';
|
||||
import Select from '@shell/components/form/Select';
|
||||
import { MANAGEMENT, CAPI } from '@shell/config/types';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { HCI } from '@pkg/harvester/types';
|
||||
|
||||
export default {
|
||||
components: { Select },
|
||||
|
||||
props: {
|
||||
row: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: _EDIT,
|
||||
},
|
||||
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
idx: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return { value: '' };
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['allNamespaces', 'currentCluster', 'isRancherInHarvester', 'isStandaloneHarvester']),
|
||||
|
||||
showProjectAndCluster() {
|
||||
return !this.isStandaloneHarvester;
|
||||
},
|
||||
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
|
||||
showRemove() {
|
||||
return !this.isView;
|
||||
},
|
||||
|
||||
filteredNamespaces() {
|
||||
const namespaces = this.allNamespaces || [];
|
||||
|
||||
return namespaces.filter((namespace) => {
|
||||
if (this.row.project === '*') {
|
||||
return true;
|
||||
} else if (this.row.project) {
|
||||
return namespace.project?.id === this.row.project;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
namespaceOptions() {
|
||||
const out = (this.filteredNamespaces || []).map((namespace) => {
|
||||
return {
|
||||
label: namespace.metadata.name,
|
||||
value: namespace.id,
|
||||
};
|
||||
});
|
||||
|
||||
return [{
|
||||
label: this.t('generic.all'),
|
||||
value: '*',
|
||||
}, ...out];
|
||||
},
|
||||
|
||||
guestClusterOptions() {
|
||||
const clusters = this.$store.getters['management/all'](CAPI.RANCHER_CLUSTER);
|
||||
const configs = this.$store.getters['management/all'](HCI.HARVESTER_CONFIG);
|
||||
const selectedClusters = this.rows.map(row => row?.guestCluster);
|
||||
const filteredNamespaces = this.filteredNamespaces.map(n => n.id);
|
||||
|
||||
const out = clusters.filter((c) => {
|
||||
const machinePools = c.spec?.rkeConfig?.machinePools || [];
|
||||
const machineConfigName = machinePools[0]?.machineConfigRef?.name;
|
||||
const config = configs.find(c => c.id === `fleet-default/${ machineConfigName }`);
|
||||
|
||||
if (config) {
|
||||
const vmNamespace = config?.vmNamespace;
|
||||
|
||||
if (this.row.namespace === '*' && filteredNamespaces.includes(vmNamespace)) {
|
||||
return true;
|
||||
} else {
|
||||
return vmNamespace === this.row.namespace && !selectedClusters.includes(c.id);
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}).map((cluster) => {
|
||||
return {
|
||||
label: cluster.nameDisplay,
|
||||
value: cluster.metadata.name,
|
||||
};
|
||||
});
|
||||
|
||||
return [{
|
||||
label: this.t('generic.none'),
|
||||
value: '',
|
||||
}, {
|
||||
label: this.t('generic.all'),
|
||||
value: '*',
|
||||
}, ...out];
|
||||
},
|
||||
|
||||
projectOptions() {
|
||||
const projects = this.$store.getters['management/all'](MANAGEMENT.PROJECT);
|
||||
|
||||
const out = projects.filter(p => p.metadata.namespace === this.currentCluster.id).map((project) => {
|
||||
return {
|
||||
label: project.nameDisplay,
|
||||
value: project.id,
|
||||
};
|
||||
});
|
||||
|
||||
return [{
|
||||
label: this.t('generic.none'),
|
||||
value: '',
|
||||
}, {
|
||||
label: this.t('generic.all'),
|
||||
value: '*',
|
||||
}, ...out];
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
const { namespace, project, guestCluster } = this.row;
|
||||
|
||||
this.$emit('input', {
|
||||
namespace,
|
||||
project,
|
||||
guestCluster,
|
||||
});
|
||||
},
|
||||
|
||||
remove() {
|
||||
this.$emit('remove');
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'row.project'() {
|
||||
if (this.row.namespace !== '*') {
|
||||
this.row.namespace = '';
|
||||
}
|
||||
|
||||
if (this.row.guestCluster !== '*' || this.row.guestCluster !== '') {
|
||||
this.row.guestCluster = '';
|
||||
}
|
||||
},
|
||||
|
||||
'row.namespace'() {
|
||||
if (this.row.guestCluster !== '*') {
|
||||
this.row.guestCluster = '';
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pool-row"
|
||||
:class="{
|
||||
'show-project-and-cluster': showProjectAndCluster,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="showProjectAndCluster"
|
||||
class="pool-project"
|
||||
>
|
||||
<span v-if="isView">
|
||||
{{ row.project }}
|
||||
</span>
|
||||
<Select
|
||||
v-else
|
||||
v-model="row.project"
|
||||
:options="projectOptions"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
<div class="pool-namespace">
|
||||
<span v-if="isView">
|
||||
{{ row.namespace }}
|
||||
</span>
|
||||
<Select
|
||||
v-else
|
||||
v-model="row.namespace"
|
||||
:options="namespaceOptions"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showProjectAndCluster"
|
||||
class="pool-guestCluster"
|
||||
>
|
||||
<span v-if="isView">
|
||||
{{ row.guestCluster }}
|
||||
</span>
|
||||
<Select
|
||||
v-else
|
||||
v-model="row.guestCluster"
|
||||
:options="guestClusterOptions"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showRemove" class="remove">
|
||||
<button type="button" class="btn role-link" @click="remove(idx)">
|
||||
<t k="generic.remove" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pool-row {
|
||||
display: grid;
|
||||
grid-column-gap: $column-gutter;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
|
||||
grid-template-columns: 40% 40% 15%;
|
||||
|
||||
&.show-project-and-cluster {
|
||||
grid-template-columns: 25% 25% 25% 15%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script>
|
||||
import debounce from 'lodash/debounce';
|
||||
import { _EDIT, _VIEW } from '@shell/config/query-params';
|
||||
import { removeAt } from '@shell/utils/array';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: _EDIT,
|
||||
},
|
||||
specType: {
|
||||
type: String,
|
||||
default: 'ClusterIP',
|
||||
},
|
||||
autoAddIfEmpty: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const rows = (this.value || []).map((row) => {
|
||||
let type = 'cidr';
|
||||
|
||||
if (row.rangeStart && row.rangeEnd) {
|
||||
type = 'range';
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
type,
|
||||
};
|
||||
});
|
||||
|
||||
return { rows };
|
||||
},
|
||||
|
||||
computed: {
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
|
||||
showAdd() {
|
||||
return !this.isView;
|
||||
},
|
||||
|
||||
showRemove() {
|
||||
return !this.isView;
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.queueUpdate = debounce(this.update, 500);
|
||||
},
|
||||
|
||||
methods: {
|
||||
addCIDR() {
|
||||
this.rows.push({
|
||||
subnet: '',
|
||||
gateway: '',
|
||||
type: 'cidr',
|
||||
});
|
||||
|
||||
this.queueUpdate();
|
||||
},
|
||||
|
||||
addRange() {
|
||||
this.rows.push({
|
||||
subnet: '',
|
||||
gateway: '',
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
type: 'range',
|
||||
});
|
||||
|
||||
this.queueUpdate();
|
||||
},
|
||||
|
||||
remove(idx) {
|
||||
removeAt(this.rows, idx);
|
||||
this.queueUpdate();
|
||||
},
|
||||
|
||||
update() {
|
||||
if ( this.isView ) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('input', this.rows);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="rows.length">
|
||||
<div
|
||||
v-for="(row, idx) in rows"
|
||||
:key="idx"
|
||||
>
|
||||
<div
|
||||
class="pool-headers"
|
||||
:class="{
|
||||
[row.type]: true,
|
||||
}"
|
||||
>
|
||||
<span class="pool-subnet">
|
||||
<t k="harvester.ipPool.subnet.label" />
|
||||
<span class="text-error">
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="pool-gateway"
|
||||
>
|
||||
<t k="harvester.ipPool.gateway.label" />
|
||||
</span>
|
||||
<span
|
||||
v-if="row.type === 'range'"
|
||||
class="pool-startIP"
|
||||
>
|
||||
<t k="harvester.ipPool.startIP.label" />
|
||||
</span>
|
||||
<span
|
||||
v-if="row.type === 'range'"
|
||||
class="pool-endIP"
|
||||
>
|
||||
<t k="harvester.ipPool.endIP.label" />
|
||||
</span>
|
||||
<span
|
||||
v-if="showRemove"
|
||||
class="remove"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="pool-row"
|
||||
:class="{
|
||||
[row.type]: true,
|
||||
}"
|
||||
>
|
||||
<div class="pool-subnet">
|
||||
<span v-if="isView">
|
||||
{{ row.subnet }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
v-model="row.subnet"
|
||||
type="text"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="pool-gateway"
|
||||
>
|
||||
<span v-if="isView">
|
||||
{{ row.gateway }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
v-model="row.gateway"
|
||||
type="text"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="row.type === 'range'"
|
||||
class="pool-startIP"
|
||||
>
|
||||
<span v-if="isView">
|
||||
{{ row.rangeStart }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
v-model="row.rangeStart"
|
||||
type="text"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="row.type === 'range'"
|
||||
class="pool-endIP"
|
||||
>
|
||||
<span v-if="isView">
|
||||
{{ row.rangeEnd }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
v-model="row.rangeEnd"
|
||||
type="text"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showRemove" class="remove">
|
||||
<button type="button" class="btn role-link" @click="remove(idx)">
|
||||
<t k="generic.remove" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showAdd" class="footer">
|
||||
<button type="button" class="btn role-tertiary add" @click="addCIDR()">
|
||||
<t k="harvester.ipPool.cidr.addLabel" />
|
||||
</button>
|
||||
<button type="button" class="btn role-tertiary add" @click="addRange()">
|
||||
<t k="harvester.ipPool.range.addLabel" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pool-headers, .pool-row {
|
||||
display: grid;
|
||||
grid-column-gap: $column-gutter;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
|
||||
&.cidr {
|
||||
grid-template-columns: 40%+$column-gutter 40%+$column-gutter 15%;
|
||||
}
|
||||
|
||||
&.range {
|
||||
grid-template-columns: 20% 20% 20% 20% 15%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script>
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import Priority from './Priority';
|
||||
|
||||
import { NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LabeledSelect,
|
||||
LabeledInput,
|
||||
Priority,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: _EDIT,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
const selector = this.value || {
|
||||
network: '',
|
||||
priority: 0,
|
||||
scope: [],
|
||||
};
|
||||
|
||||
return { selector };
|
||||
},
|
||||
|
||||
computed: {
|
||||
networkOptions() {
|
||||
const networks = this.$store.getters['harvester/all'](NETWORK_ATTACHMENT) || [];
|
||||
|
||||
return [{
|
||||
label: this.t('generic.none'),
|
||||
value: '',
|
||||
}, ...networks.map(n => ({
|
||||
label: n.id,
|
||||
value: n.id,
|
||||
}))];
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.queueUpdate = debounce(this.update, 500);
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
if ( this.isView ) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('input', this.selector);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="selector.network"
|
||||
:label="t('harvester.ipPool.network.label')"
|
||||
:options="networkOptions"
|
||||
:mode="mode"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="selector.priority"
|
||||
:label="t('harvester.ipPool.priority.label')"
|
||||
:mode="mode"
|
||||
type="number"
|
||||
min="0"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Priority
|
||||
v-model="selector.scope"
|
||||
class="col span-12"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script>
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { NETWORK_ATTACHMENT, CAPI } from '@shell/config/types';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import Range from './Range';
|
||||
import Selector from './Selector';
|
||||
import { HCI } from '@pkg/harvester/types';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterIPPool',
|
||||
|
||||
components: {
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
Tab,
|
||||
CruResource,
|
||||
Range,
|
||||
Selector,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const hash = {
|
||||
networks: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
|
||||
settings: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SETTING }),
|
||||
};
|
||||
|
||||
if (this.$store.getters['management/schemaFor'](HCI.HARVESTER_CONFIG)) {
|
||||
hash.harvesterConfigs = this.$store.dispatch(`management/findAll`, { type: HCI.HARVESTER_CONFIG });
|
||||
}
|
||||
|
||||
if (this.$store.getters['management/schemaFor'](CAPI.RANCHER_CLUSTER)) {
|
||||
hash.rancherClusters = this.$store.dispatch(`management/findAll`, { type: CAPI.RANCHER_CLUSTER });
|
||||
}
|
||||
|
||||
await allHash(hash);
|
||||
},
|
||||
|
||||
data() {
|
||||
return { errors: [] };
|
||||
},
|
||||
|
||||
computed: {
|
||||
yamlModifiers() {
|
||||
const activelyRemove = [
|
||||
'metadata.managedFields',
|
||||
'metadata.relationships',
|
||||
'metadata.state',
|
||||
'links',
|
||||
'type',
|
||||
'id'
|
||||
];
|
||||
|
||||
if (this.isCreate) {
|
||||
activelyRemove.push('status');
|
||||
}
|
||||
|
||||
return { activelyRemove };
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
:yaml-modifiers="yamlModifiers"
|
||||
@finish="save"
|
||||
>
|
||||
<NameNsDescription
|
||||
:value="value"
|
||||
:namespaced="false"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<ResourceTabs
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab
|
||||
name="range"
|
||||
:label="t('harvester.ipPool.tabs.range')"
|
||||
:weight="98"
|
||||
class="bordered-table"
|
||||
>
|
||||
<Range
|
||||
v-model="value.spec.ranges"
|
||||
class="col span-12"
|
||||
:mode="mode"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
name="selector"
|
||||
:label="t('harvester.ipPool.tabs.selector')"
|
||||
:weight="97"
|
||||
class="bordered-table"
|
||||
>
|
||||
<Selector
|
||||
v-model="value.spec.selector"
|
||||
:mode="mode"
|
||||
/>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script>
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import RadioGroup from '@components/Form/Radio/RadioGroup';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
RadioGroup,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
model: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return { healthCheckEnabled: !!this.value.port };
|
||||
},
|
||||
|
||||
computed: {
|
||||
portOptions() {
|
||||
const ports = this.model?.spec?.listeners || [];
|
||||
|
||||
return ports.filter(p => p.port && p.protocol === 'TCP').map(p => p.backendPort) || [];
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onToggle(value) {
|
||||
this.$emit('enabled', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model="healthCheckEnabled"
|
||||
:mode="mode"
|
||||
name="healthCheckEnabled"
|
||||
:labels="[t('generic.disabled'),t('generic.enabled')]"
|
||||
:options="[false, true]"
|
||||
:disabled="disabled"
|
||||
@input="onToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="healthCheckEnabled">
|
||||
<div class="row mt-10">
|
||||
<div v-if="healthCheckEnabled" class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="value.port"
|
||||
:mode="mode"
|
||||
:options="portOptions"
|
||||
required
|
||||
:label="t('harvester.service.healthCheckPort.label')"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="value.successThreshold"
|
||||
:mode="mode"
|
||||
type="number"
|
||||
:label="t('harvester.service.healthCheckSuccessThreshold.label')"
|
||||
:tooltip="t('harvester.service.healthCheckSuccessThreshold.description')"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="value.failureThreshold"
|
||||
:mode="mode"
|
||||
type="number"
|
||||
:label="t('harvester.service.healthCheckFailureThreshold.label')"
|
||||
:tooltip="t('harvester.service.healthCheckFailureThreshold.description')"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="value.periodSeconds"
|
||||
:mode="mode"
|
||||
type="number"
|
||||
:label="t('harvester.service.healthCheckPeriod.label')"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="value.timeoutSeconds"
|
||||
:mode="mode"
|
||||
type="number"
|
||||
:label="t('harvester.service.healthCheckTimeout.label')"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script>
|
||||
import debounce from 'lodash/debounce';
|
||||
import { _EDIT, _VIEW } from '@shell/config/query-params';
|
||||
import { removeAt } from '@shell/utils/array';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import Select from '@shell/components/form/Select';
|
||||
|
||||
export default {
|
||||
components: { Select },
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: _EDIT,
|
||||
},
|
||||
autoAddIfEmpty: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const rows = clone(this.value || []);
|
||||
|
||||
return { rows };
|
||||
},
|
||||
|
||||
computed: {
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
|
||||
showAdd() {
|
||||
return !this.isView;
|
||||
},
|
||||
|
||||
showRemove() {
|
||||
return !this.isView;
|
||||
},
|
||||
|
||||
protocolOptions() {
|
||||
return ['TCP', 'UDP'];
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.queueUpdate = debounce(this.update, 500);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if ( this.isView ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.autoAddIfEmpty && this.mode !== _EDIT && this?.rows.length < 1) {
|
||||
// don't focus on mount because we'll pull focus from name/namespace input
|
||||
this.add(false);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
add(focus = true) {
|
||||
this.rows.push({
|
||||
name: '',
|
||||
port: null,
|
||||
protocol: 'TCP',
|
||||
backendPort: null,
|
||||
});
|
||||
|
||||
this.queueUpdate();
|
||||
|
||||
if (this.rows.length > 0 && focus) {
|
||||
this.$nextTick(() => {
|
||||
const inputs = this.$refs['port-name'];
|
||||
|
||||
inputs[inputs.length - 1].focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
remove(idx) {
|
||||
removeAt(this.rows, idx);
|
||||
this.queueUpdate();
|
||||
},
|
||||
|
||||
update() {
|
||||
if ( this.isView ) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('input', this.rows);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="rows.length">
|
||||
<div
|
||||
class="listener-headers"
|
||||
>
|
||||
<span class="listener-name">
|
||||
<t k="harvester.loadBalancer.listeners.name.label" />
|
||||
</span>
|
||||
<span class="listener-protocol">
|
||||
<t k="harvester.loadBalancer.listeners.protocol.label" />
|
||||
</span>
|
||||
<span class="listener-port">
|
||||
<t k="harvester.loadBalancer.listeners.port.label" />
|
||||
<span class="text-error">
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<span class="listener-backendPort">
|
||||
<t k="harvester.loadBalancer.listeners.backendPort.label" />
|
||||
<span class="text-error">
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="showRemove"
|
||||
class="remove"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, idx) in rows"
|
||||
:key="idx"
|
||||
class="listener-row"
|
||||
>
|
||||
<div class="port-name">
|
||||
<span v-if="isView">
|
||||
{{ row.name }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
ref="port-name"
|
||||
v-model.number="row.name"
|
||||
type="text"
|
||||
:placeholder="t('servicePorts.rules.name.placeholder')"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="port-protocol">
|
||||
<span v-if="isView">
|
||||
{{ row.protocol }}
|
||||
</span>
|
||||
<Select
|
||||
v-else
|
||||
v-model="row.protocol"
|
||||
:options="protocolOptions"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="port">
|
||||
<span v-if="isView">
|
||||
{{ row.port }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
ref="port"
|
||||
v-model.number="row.port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
:placeholder="t('servicePorts.rules.listening.placeholder')"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="target-port">
|
||||
<span v-if="isView">{{ row.backendPort }}</span>
|
||||
<input
|
||||
v-else
|
||||
v-model.number="row.backendPort"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
:placeholder="t('harvester.loadBalancer.listeners.backendPort.placeholder')"
|
||||
@input="queueUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showRemove" class="remove">
|
||||
<button type="button" class="btn role-link" @click="remove(idx)">
|
||||
<t k="generic.remove" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showAdd" class="footer">
|
||||
<button type="button" class="btn role-tertiary add" @click="add()">
|
||||
<t k="generic.add" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.listener-headers, .listener-row{
|
||||
display: grid;
|
||||
grid-column-gap: $column-gutter;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
grid-template-columns: 35% 15% 15% 15% 15%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,399 @@
|
||||
<script>
|
||||
import { throttle, isEmpty } from 'lodash';
|
||||
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import KeyValue from '@shell/components/form/KeyValue';
|
||||
import Banner from '@components/Banner/Banner';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
import { MANAGEMENT, NAMESPACE } from '@shell/config/types';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { matching } from '@shell/utils/selector';
|
||||
import { HCI } from '@pkg/harvester/types';
|
||||
|
||||
import Listeners from './Listeners';
|
||||
import HealthCheck from './HealthCheck';
|
||||
|
||||
const NAMESPACE_SELECTOR = 'loadbalancer.harvesterhci.io/namespace';
|
||||
const PROJECT_SELECTOR = 'loadbalancer.harvesterhci.io/project';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterLoadBalancer',
|
||||
|
||||
components: {
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
LabeledSelect,
|
||||
Tab,
|
||||
Listeners,
|
||||
HealthCheck,
|
||||
CruResource,
|
||||
KeyValue,
|
||||
Banner,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const hash = {
|
||||
ipPools: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IP_POOL }),
|
||||
namespaces: this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }),
|
||||
vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM }),
|
||||
};
|
||||
|
||||
if (this.$store.getters['management/schemaFor'](MANAGEMENT.PROJECT)) {
|
||||
hash.projects = this.$store.dispatch('management/findAll', { type: MANAGEMENT.PROJECT });
|
||||
}
|
||||
|
||||
await allHash(hash);
|
||||
|
||||
this.updateMatchingVMs();
|
||||
},
|
||||
|
||||
data() {
|
||||
const annotations = this.value.metadata.annotations || [];
|
||||
|
||||
if (!this.value.spec.healthCheck) {
|
||||
this.value.spec.healthCheck = {};
|
||||
}
|
||||
|
||||
const matchingVMs = {
|
||||
matched: 0,
|
||||
matches: [],
|
||||
none: true,
|
||||
sample: null,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
ipPool: this.value.spec.ipPool,
|
||||
projectSelector: annotations[PROJECT_SELECTOR] || '',
|
||||
namespaceSelector: annotations[NAMESPACE_SELECTOR] || '',
|
||||
matchingVMs,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
ipamOption() {
|
||||
return [{
|
||||
label: this.t('harvester.loadBalancer.ipam.options.dhcp'),
|
||||
value: 'dhcp',
|
||||
}, {
|
||||
label: this.t('harvester.loadBalancer.ipam.options.pool'),
|
||||
value: 'pool',
|
||||
}];
|
||||
},
|
||||
|
||||
ipPoolOptions() {
|
||||
const ipPools = this.$store.getters['harvester/all'](HCI.IP_POOL);
|
||||
|
||||
const out = ipPools.map(ipPool => ipPool.id);
|
||||
|
||||
return [{
|
||||
label: this.t('harvester.loadBalancer.ipPool.options.none'),
|
||||
value: '',
|
||||
}, ...out];
|
||||
},
|
||||
|
||||
projectOptions() {
|
||||
const projects = this.$store.getters['harvester/all'](MANAGEMENT.PROJECT);
|
||||
|
||||
return projects.map(project => project.id);
|
||||
},
|
||||
|
||||
namespaceOptions() {
|
||||
const namespaces = this.$store.getters['harvester/all'](NAMESPACE);
|
||||
|
||||
return namespaces.map(n => n.id);
|
||||
},
|
||||
|
||||
backendServerSelector: {
|
||||
get() {
|
||||
const out = {};
|
||||
|
||||
Object.keys(this.value.spec?.backendServerSelector || {}).map((key) => {
|
||||
out[key] = (this.value.spec.backendServerSelector[key] || []).join(',') || '';
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
set(value) {
|
||||
const backendServerSelector = {};
|
||||
|
||||
Object.keys(value).map((key) => {
|
||||
backendServerSelector[key] = (value[key] || '').split(',');
|
||||
});
|
||||
|
||||
this.$set(this.value.spec, 'backendServerSelector', backendServerSelector);
|
||||
},
|
||||
},
|
||||
|
||||
yamlModifiers() {
|
||||
const activelyRemove = [
|
||||
'metadata.managedFields',
|
||||
'metadata.relationships',
|
||||
'metadata.state',
|
||||
'links',
|
||||
'type',
|
||||
'id'
|
||||
];
|
||||
|
||||
if (this.isCreate) {
|
||||
activelyRemove.push('status');
|
||||
}
|
||||
|
||||
return { activelyRemove };
|
||||
},
|
||||
|
||||
healthCheckPortInUseWarning() {
|
||||
const healthCheckPort = this.value?.spec?.healthCheck?.port;
|
||||
const portInUse = this.value?.spec?.listeners?.find(l => l.backendPort === healthCheckPort);
|
||||
|
||||
if (healthCheckPort && portInUse) {
|
||||
return this.t('harvester.loadBalancer.healthCheck.warning.portInUse', { port: portInUse.backendPort }, true);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
const { projectSelector, namespaceSelector } = this;
|
||||
|
||||
if (projectSelector) {
|
||||
this.value.metadata.annotations[PROJECT_SELECTOR] = projectSelector;
|
||||
}
|
||||
|
||||
if (namespaceSelector) {
|
||||
this.value.metadata.annotations[NAMESPACE_SELECTOR] = namespaceSelector;
|
||||
}
|
||||
},
|
||||
|
||||
updateMatchingVMs: throttle(function() {
|
||||
const backendServerSelector = this.value.spec.backendServerSelector;
|
||||
const allVMs = this.$store.getters['harvester/all'](HCI.VM).filter(vm => vm.metadata.namespace === this.value.metadata.namespace);
|
||||
|
||||
if (isEmpty(backendServerSelector)) {
|
||||
this.matchingVMs = {
|
||||
matched: 0,
|
||||
total: allVMs.length,
|
||||
none: true,
|
||||
sample: null,
|
||||
};
|
||||
} else {
|
||||
const match = matching(allVMs, backendServerSelector, 'spec.template.metadata.labels');
|
||||
|
||||
this.matchingVMs = {
|
||||
matched: match.length,
|
||||
total: allVMs.length,
|
||||
none: match.length === 0,
|
||||
sample: match[0] ? match[0].nameDisplay : null,
|
||||
};
|
||||
}
|
||||
}, 250, { leading: true }),
|
||||
|
||||
healthCheckEnabled(v) {
|
||||
if (!v) {
|
||||
this.value.spec.healthCheck = {};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
backendServerSelector: 'updateMatchingVMs',
|
||||
'value.metadata.namespace': 'updateMatchingVMs',
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
:yaml-modifiers="yamlModifiers"
|
||||
@finish="save"
|
||||
>
|
||||
<NameNsDescription
|
||||
:value="value"
|
||||
:namespaced="true"
|
||||
:mode="mode"
|
||||
/>
|
||||
|
||||
<ResourceTabs
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('harvester.loadBalancer.tabs.basic')"
|
||||
:weight="99"
|
||||
class="bordered-table"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="value.spec.ipam"
|
||||
:label="t('harvester.loadBalancer.ipam.label')"
|
||||
:options="ipamOption"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="value.spec.ipam === 'pool'"
|
||||
class="col span-6"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model="value.spec.ipPool"
|
||||
:label="t('harvester.loadBalancer.ipPool.label')"
|
||||
:options="ipPoolOptions"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab
|
||||
v-if="value.spec.workloadType === 'vm'"
|
||||
name="listeners"
|
||||
:label="t('harvester.loadBalancer.tabs.listeners')"
|
||||
:weight="98"
|
||||
class="bordered-table"
|
||||
>
|
||||
<Banner
|
||||
v-if="healthCheckPortInUseWarning"
|
||||
color="warning"
|
||||
>
|
||||
<span v-clean-html="healthCheckPortInUseWarning" />
|
||||
</Banner>
|
||||
<Listeners
|
||||
v-model="value.spec.listeners"
|
||||
class="col span-12"
|
||||
:mode="mode"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
v-if="value.spec.workloadType === 'vm'"
|
||||
name="backendServer"
|
||||
:label="t('harvester.loadBalancer.tabs.backendServer')"
|
||||
:weight="97"
|
||||
class="bordered-table"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<Banner :color="(matchingVMs.none ? 'warning' : 'success')">
|
||||
<span v-clean-html="t('harvester.loadBalancer.backendServerSelector.matchingVMs.matchesSome', matchingVMs)" />
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
<KeyValue
|
||||
v-model="backendServerSelector"
|
||||
:mode="mode"
|
||||
:read-allowed="false"
|
||||
:initial-empty-row="true"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
v-if="value.spec.workloadType === 'vm'"
|
||||
name="healthCheck"
|
||||
:label="t('harvester.loadBalancer.tabs.healthCheck')"
|
||||
:weight="96"
|
||||
class="bordered-table"
|
||||
>
|
||||
<HealthCheck
|
||||
v-model="value.spec.healthCheck"
|
||||
:mode="mode"
|
||||
:model="value"
|
||||
@enabled="healthCheckEnabled"
|
||||
/>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$remove: 75;
|
||||
$checkbox: 75;
|
||||
|
||||
.title {
|
||||
margin-bottom: 10px;
|
||||
|
||||
.read-from-file {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.ports-headers, .ports-row{
|
||||
display: grid;
|
||||
grid-column-gap: $column-gutter;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
|
||||
&.show-protocol{
|
||||
grid-template-columns: 23% 23% 10% 15% 15% 10%;
|
||||
&:not(.show-node-port){
|
||||
grid-template-columns: 31% 31% 10% 15% 10%;
|
||||
}
|
||||
}
|
||||
&.show-node-port:not(.show-protocol){
|
||||
grid-template-columns: 28% 28% 15% 15% 10%;
|
||||
}
|
||||
}
|
||||
|
||||
.ports-headers {
|
||||
color: var(--input-label);
|
||||
}
|
||||
|
||||
.toggle-host-ports {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.remove BUTTON {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.ports-row {
|
||||
> div {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.port-protocol ::v-deep {
|
||||
.unlabeled-select {
|
||||
.v-select.inline {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 10px;
|
||||
margin-left: 5px;
|
||||
|
||||
.protip {
|
||||
float: right;
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ContainerResourceLimit from '@shell/components/ContainerResourceLimit';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import FormValidation from '@shell/mixins/form-validation';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import Labels from '@shell/components/form/Labels';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import ResourceQuota from '@shell/components/form/ResourceQuota/Project';
|
||||
import { HARVESTER_TYPES, RANCHER_TYPES } from '@shell/components/form/ResourceQuota/shared';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import { MANAGEMENT } from '@shell/config/types';
|
||||
import { NAME } from '@shell/config/product/explorer';
|
||||
import { PROJECT_ID, _VIEW, _CREATE, _EDIT } from '@shell/config/query-params';
|
||||
import ProjectMembershipEditor from '@shell/components/form/Members/ProjectMembershipEditor';
|
||||
import { canViewProjectMembershipEditor } from '@shell/components/form/Members/ProjectMembershipEditor.vue';
|
||||
import { HARVESTER_NAME as HARVESTER } from '@shell/config/features';
|
||||
import { Banner } from '@components/Banner';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContainerResourceLimit, CruResource, Labels, LabeledSelect, NameNsDescription, ProjectMembershipEditor, ResourceQuota, Tabbed, Tab, Banner
|
||||
},
|
||||
|
||||
mixins: [CreateEditView, FormValidation],
|
||||
async fetch() {
|
||||
if ( this.$store.getters['management/canList'](MANAGEMENT.POD_SECURITY_POLICY_TEMPLATE) ) {
|
||||
this.allPSPs = await this.$store.dispatch('management/findAll', { type: MANAGEMENT.POD_SECURITY_POLICY_TEMPLATE });
|
||||
}
|
||||
|
||||
// User can only change the PSP if the user has permissions to see the binding schema for PSP Templates
|
||||
const pspBindingSchema = this.$store.getters['management/schemaFor'](MANAGEMENT.PSP_TEMPLATE_BINDING);
|
||||
|
||||
this.canEditPSPBindings = !!pspBindingSchema;
|
||||
},
|
||||
data() {
|
||||
this.$set(this.value, 'spec', this.value.spec || {});
|
||||
this.$set(this.value.spec, 'podSecurityPolicyTemplateId', this.value.status?.podSecurityPolicyTemplateId || '');
|
||||
|
||||
return {
|
||||
allPSPs: [],
|
||||
projectRoleTemplateBindingSchema: this.$store.getters[`management/schemaFor`](MANAGEMENT.PROJECT_ROLE_TEMPLATE_BINDING),
|
||||
createLocation: {
|
||||
name: 'c-cluster-product-resource-create',
|
||||
params: {
|
||||
product: NAME,
|
||||
resource: MANAGEMENT.PROJECT_ROLE_TEMPLATE_BINDING,
|
||||
},
|
||||
query: { [PROJECT_ID]: this.project?.id?.replace('/', ':') }
|
||||
},
|
||||
resource: MANAGEMENT.PROJECT_ROLE_TEMPLATE_BINDING,
|
||||
saveBindings: null,
|
||||
membershipHasOwner: false,
|
||||
membershipUpdate: {},
|
||||
HARVESTER_TYPES,
|
||||
RANCHER_TYPES,
|
||||
fvFormRuleSets: [{ path: 'spec.displayName', rules: ['required'] }],
|
||||
canEditPSPBindings: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['currentCluster', 'isStandaloneHarvester']),
|
||||
|
||||
canViewMembers() {
|
||||
return canViewProjectMembershipEditor(this.$store);
|
||||
},
|
||||
|
||||
canEditProject() {
|
||||
return this.value?.links?.update;
|
||||
},
|
||||
|
||||
isDescriptionDisabled() {
|
||||
return (this.mode === _EDIT && !this.canEditProject) || false;
|
||||
},
|
||||
|
||||
canEditTabElements() {
|
||||
if (this.mode === _EDIT && !this.canEditProject) {
|
||||
return _VIEW;
|
||||
}
|
||||
|
||||
return this.mode;
|
||||
},
|
||||
|
||||
showBannerForOnlyManagingMembers() {
|
||||
return this.mode === _EDIT && !this.canEditProject;
|
||||
},
|
||||
|
||||
isK3s() {
|
||||
return (this.currentCluster?.spec?.kubernetesVersion || '').includes('k3s');
|
||||
},
|
||||
|
||||
pspOptions() {
|
||||
if ( this.isK3s || !this.currentCluster.spec.defaultPodSecurityPolicyTemplateName ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const out = [{ label: this.t('project.psp.default'), value: '' }];
|
||||
|
||||
if ( this.allPSPs ) {
|
||||
for ( const pspt of this.allPSPs ) {
|
||||
out.push({
|
||||
label: pspt.nameDisplay,
|
||||
value: pspt.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cur = this.value.status?.podSecurityPolicyTemplateId;
|
||||
|
||||
if ( cur && !out.find(x => x.value === cur) ) {
|
||||
out.unshift({ label: this.t('project.psp.current', { value: cur }), value: cur });
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
isHarvester() {
|
||||
return this.$store.getters['currentProduct'].inStore === HARVESTER;
|
||||
},
|
||||
|
||||
resourceQuotaLabel() {
|
||||
if (this.isHarvester) {
|
||||
return this.t('project.vmDefaultResourceLimit');
|
||||
}
|
||||
|
||||
return this.t('project.containerDefaultResourceLimit');
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
hasOwner() {
|
||||
this.errors = this.hasOwner ? [] : [this.t('project.haveOneOwner')];
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.$set(this.value.metadata, 'namespace', this.$store.getters['currentCluster'].id);
|
||||
this.$set(this.value, 'spec', this.value.spec || {});
|
||||
this.$set(this.value.spec, 'containerDefaultResourceLimit', this.value.spec.containerDefaultResourceLimit || {});
|
||||
},
|
||||
methods: {
|
||||
async save(saveCb) {
|
||||
try {
|
||||
// clear up of the unused resourceQuotas will now be done on the model side
|
||||
|
||||
if (this.mode === _CREATE) {
|
||||
const savedProject = await this.value.save();
|
||||
|
||||
if (this.membershipUpdate.save) {
|
||||
await this.membershipUpdate.save(savedProject.id);
|
||||
}
|
||||
} else if (this.mode === _EDIT) {
|
||||
if (this.canEditProject) {
|
||||
await this.value.save(true);
|
||||
|
||||
// We updated the Norman resource - re-fetch the Steve resource so we know it is definitely updated in the store
|
||||
await this.$store.dispatch('management/find', {
|
||||
type: MANAGEMENT.PROJECT,
|
||||
id: this.value.id,
|
||||
opt: { force: true }
|
||||
});
|
||||
}
|
||||
|
||||
// // we allow users with permissions for projectroletemplatebindings to be able to manage members on projects
|
||||
if (this.membershipUpdate.save) {
|
||||
const norman = await this.value.norman;
|
||||
|
||||
await this.membershipUpdate.save(norman.id);
|
||||
}
|
||||
}
|
||||
|
||||
saveCb(true);
|
||||
this.$router.replace(this.value.listLocation);
|
||||
} catch (ex) {
|
||||
this.errors.push(ex);
|
||||
saveCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
onHasOwnerChanged(hasOwner) {
|
||||
this.$set(this, 'membershipHasOwner', hasOwner);
|
||||
},
|
||||
|
||||
onMembershipUpdate(update) {
|
||||
this.$set(this, 'membershipUpdate', update);
|
||||
},
|
||||
|
||||
removeQuota(key) {
|
||||
['resourceQuota', 'namespaceDefaultResourceQuota'].forEach((specProp) => {
|
||||
if (this.value?.spec[specProp]?.limit && this.value?.spec[specProp]?.limit[key]) {
|
||||
delete this.value?.spec[specProp]?.limit[key];
|
||||
}
|
||||
if (this.value?.spec[specProp]?.usedLimit && this.value?.spec[specProp]?.usedLimit[key]) {
|
||||
delete this.value?.spec[specProp]?.usedLimit[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<CruResource
|
||||
class="project"
|
||||
:done-route="value.listLocation"
|
||||
:errors="fvUnreportedValidationErrors"
|
||||
:mode="mode"
|
||||
:resource="value"
|
||||
:subtypes="[]"
|
||||
:can-yaml="false"
|
||||
:validation-passed="fvFormIsValid"
|
||||
@error="e=>errors = e"
|
||||
@finish="save"
|
||||
@cancel="done"
|
||||
>
|
||||
<NameNsDescription
|
||||
v-model="value"
|
||||
:name-editable="true"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
description-key="spec.description"
|
||||
:description-disabled="isDescriptionDisabled"
|
||||
name-key="spec.displayName"
|
||||
:normalize-name="false"
|
||||
:rules="{ name: fvGetAndReportPathRules('spec.displayName'), namespace: [], description: [] }"
|
||||
/>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-3">
|
||||
<LabeledSelect
|
||||
v-if="pspOptions"
|
||||
v-model="value.spec.podSecurityPolicyTemplateId"
|
||||
class="psp"
|
||||
:mode="mode"
|
||||
:options="pspOptions"
|
||||
:disabled="!canEditPSPBindings"
|
||||
:label="t('project.psp.label')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Tabbed :side-tabs="true">
|
||||
<Tab
|
||||
v-if="canViewMembers"
|
||||
name="members"
|
||||
:label="t('project.members.label')"
|
||||
:weight="10"
|
||||
>
|
||||
<Banner
|
||||
v-if="showBannerForOnlyManagingMembers"
|
||||
color="info"
|
||||
:label="t('project.membersEditOnly')"
|
||||
/>
|
||||
<ProjectMembershipEditor
|
||||
:mode="mode"
|
||||
:parent-id="value.id"
|
||||
@has-owner-changed="onHasOwnerChanged"
|
||||
@membership-update="onMembershipUpdate"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
name="resource-quotas"
|
||||
:label="t('project.resourceQuotas')"
|
||||
:weight="9"
|
||||
>
|
||||
<ResourceQuota
|
||||
v-model="value"
|
||||
:mode="canEditTabElements"
|
||||
:types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES"
|
||||
@remove="removeQuota"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
name="container-default-resource-limit"
|
||||
:label="resourceQuotaLabel"
|
||||
:weight="8"
|
||||
>
|
||||
<ContainerResourceLimit
|
||||
v-model="value.spec.containerDefaultResourceLimit"
|
||||
:mode="canEditTabElements"
|
||||
:show-tip="false"
|
||||
:register-before-hook="registerBeforeHook"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
name="labels-and-annotations"
|
||||
label-key="generic.labelsAndAnnotations"
|
||||
:weight="7"
|
||||
>
|
||||
<Labels
|
||||
default-container-class="labels-and-annotations-container"
|
||||
:value="value"
|
||||
:mode="canEditTabElements"
|
||||
:display-side-by-side="false"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs/index';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import Labels from '@shell/components/form/Labels';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
export default {
|
||||
name: 'EditClusterNetwork',
|
||||
|
||||
components: {
|
||||
CruResource,
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
Tab,
|
||||
Labels,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
computed: {
|
||||
doneLocationOverride() {
|
||||
return this.value.doneOverride;
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.validate);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
validate() {
|
||||
const errors = [];
|
||||
|
||||
const name = this.value?.metadata?.name;
|
||||
|
||||
if (!name) {
|
||||
errors.push(this.t('validation.required', { key: this.t('generic.name') }, true));
|
||||
} else if (name.length > 12) {
|
||||
errors.push(this.t('validation.stringLength.max', {
|
||||
key: this.t('generic.name'),
|
||||
count: 12,
|
||||
}, true));
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Promise.reject(errors);
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
@finish="save"
|
||||
>
|
||||
<NameNsDescription
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
/>
|
||||
<ResourceTabs
|
||||
v-model="value"
|
||||
:mode="mode"
|
||||
:side-tabs="true"
|
||||
>
|
||||
<Tab
|
||||
name="labels-and-annotations"
|
||||
label-key="generic.labelsAndAnnotations"
|
||||
:weight="-1"
|
||||
>
|
||||
<Labels
|
||||
default-container-class="labels-and-annotations-container"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:display-side-by-side="false"
|
||||
/>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import KeyValue from '@shell/components/form/KeyValue';
|
||||
|
||||
import { HOSTNAME } from '@shell/config/labels-annotations';
|
||||
import { findBy } from '@shell/utils/array';
|
||||
import { HCI } from '../../types';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
RadioGroup,
|
||||
LabeledSelect,
|
||||
KeyValue,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
nodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const { nodeSelector = {} } = this.value;
|
||||
|
||||
let selectNode = 'all';
|
||||
let nodeName = '';
|
||||
|
||||
if (nodeSelector[HOSTNAME] && Object.keys(nodeSelector).length === 1) {
|
||||
selectNode = 'nodeSelector';
|
||||
nodeName = nodeSelector[HOSTNAME];
|
||||
} else if (Object.keys(nodeSelector).length > 0) {
|
||||
selectNode = 'custom';
|
||||
}
|
||||
|
||||
return {
|
||||
selectNode,
|
||||
nodeName,
|
||||
nodeSelector,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
selectNodeOptions() {
|
||||
const out = [{
|
||||
label: this.t(`harvester.vlanConfig.scheduling.affinity.allNodes`),
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
label: this.t(`harvester.vlanConfig.scheduling.affinity.specificNode`),
|
||||
value: 'nodeSelector'
|
||||
},
|
||||
{
|
||||
label: this.t(`harvester.vlanConfig.scheduling.affinity.schedulingRules`),
|
||||
value: 'custom'
|
||||
}];
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
nics() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
|
||||
const linkStatus = linkMonitor?.status?.linkStatus || {};
|
||||
|
||||
const out = [];
|
||||
|
||||
Object.keys(linkStatus).map((nodeName) => {
|
||||
const nics = linkStatus[nodeName] || [];
|
||||
|
||||
nics.map((nic) => {
|
||||
if (!findBy(out, 'name', nic.name) && !nic.masterIndex) {
|
||||
out.push({
|
||||
...nic,
|
||||
nodeName,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
const { nodeName, nodeSelector } = this;
|
||||
|
||||
switch (this.selectNode) {
|
||||
case 'all':
|
||||
delete this.value?.nodeSelector;
|
||||
|
||||
break;
|
||||
case 'nodeSelector':
|
||||
delete this.value?.nodeSelector;
|
||||
|
||||
Object.assign(this.value, { nodeSelector: { [HOSTNAME]: nodeName } });
|
||||
break;
|
||||
case 'custom':
|
||||
delete this.value.nodeName;
|
||||
delete this.value.nodeSelector;
|
||||
|
||||
Object.assign(this.value, { nodeSelector });
|
||||
break;
|
||||
default:
|
||||
delete this.value.nodeSelector;
|
||||
}
|
||||
|
||||
this.$emit('updateMatchingNICs');
|
||||
this.$emit('updateMatchingNodes');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row">
|
||||
<RadioGroup
|
||||
v-model="selectNode"
|
||||
name="selectNode"
|
||||
:options="selectNodeOptions"
|
||||
:mode="mode"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="selectNode === 'nodeSelector'">
|
||||
<div class="row mb-10">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="nodeName"
|
||||
:label="t('workload.scheduling.affinity.nodeName')"
|
||||
:options="nodes || []"
|
||||
:mode="mode"
|
||||
:multiple="false"
|
||||
@input="update"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="selectNode === 'custom'">
|
||||
<KeyValue
|
||||
v-model="nodeSelector"
|
||||
:key-label="t('generic.key')"
|
||||
:mode="mode"
|
||||
:add-label="t('workload.scheduling.affinity.addNodeSelector')"
|
||||
:read-allowed="false"
|
||||
:value-label="t('generic.value')"
|
||||
:initial-empty-row="true"
|
||||
@input="update"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,517 @@
|
||||
<script>
|
||||
import { isEmpty, throttle } from 'lodash';
|
||||
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import Tabbed from '@shell/components/Tabbed';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import ArrayListSelect from '@shell/components/form/ArrayListSelect';
|
||||
import LabelValue from '@shell/components/LabelValue';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import { Banner } from '@components/Banner';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
import { NODE } from '@shell/config/types';
|
||||
import { set, clone } from '@shell/utils/object';
|
||||
import { uniq, findBy } from '@shell/utils/array';
|
||||
import { HCI } from '../../types';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HOSTNAME } from '@shell/config/labels-annotations';
|
||||
import { matching } from '@shell/utils/selector';
|
||||
|
||||
import NodeSelector from './NodeSelector';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CruResource,
|
||||
NameNsDescription,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
Tabbed,
|
||||
Tab,
|
||||
NodeSelector,
|
||||
ArrayListSelect,
|
||||
LabelValue,
|
||||
Loading,
|
||||
Banner,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
data() {
|
||||
const originNics = clone(this.value?.spec?.uplink?.nics || []);
|
||||
|
||||
const matchingNodes = {
|
||||
matched: 0,
|
||||
matches: [],
|
||||
none: true,
|
||||
sample: null,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'vlan',
|
||||
matchNICs: [],
|
||||
originNics,
|
||||
matchingNodes,
|
||||
nicErrors: [],
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.validate);
|
||||
}
|
||||
|
||||
const clusterNetwork = this.$route.query.clusterNetwork;
|
||||
|
||||
if (clusterNetwork) {
|
||||
set(this.value, 'spec.clusterNetwork', clusterNetwork);
|
||||
}
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const hash = {
|
||||
linkMonitors: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.LINK_MONITOR }),
|
||||
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
|
||||
};
|
||||
|
||||
await allHash(hash);
|
||||
|
||||
this.updateMatchingNICs();
|
||||
this.updateMatchingNodes();
|
||||
},
|
||||
|
||||
computed: {
|
||||
nodeOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
|
||||
|
||||
return nodes.filter(n => n.isEtcd !== 'true').map((node) => {
|
||||
return {
|
||||
label: node.nameDisplay,
|
||||
value: node.id
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
mtu: {
|
||||
get() {
|
||||
return this.value?.spec?.uplink?.linkAttributes?.mtu;
|
||||
},
|
||||
|
||||
set(value) {
|
||||
set(this.value, 'spec.uplink.linkAttributes.mtu', value);
|
||||
}
|
||||
},
|
||||
|
||||
bondOptionMode: {
|
||||
get() {
|
||||
return this.value?.spec?.uplink?.bondOptions?.mode;
|
||||
},
|
||||
|
||||
set(value) {
|
||||
set(this.value, 'spec.uplink.bondOptions.mode', value);
|
||||
},
|
||||
},
|
||||
|
||||
miimon: {
|
||||
get() {
|
||||
return this.value?.spec?.uplink?.bondOptions?.miimon;
|
||||
},
|
||||
|
||||
set(value) {
|
||||
set(this.value, 'spec.uplink.bondOptions.miimon', value);
|
||||
},
|
||||
},
|
||||
|
||||
bondOptions() {
|
||||
return [
|
||||
'balance-rr',
|
||||
'active-backup',
|
||||
'balance-xor',
|
||||
'broadcast',
|
||||
'802.3ad',
|
||||
'balance-tlb',
|
||||
'balance-alb',
|
||||
];
|
||||
},
|
||||
|
||||
doneLocationOverride() {
|
||||
return this.value.doneOverride;
|
||||
},
|
||||
|
||||
nics() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
|
||||
const linkStatus = linkMonitor?.status?.linkStatus || {};
|
||||
const nodes = this.nodes.map(n => n.id);
|
||||
|
||||
const out = [];
|
||||
|
||||
// The node name in the Link monitor is not deleted after the nodes is deleted
|
||||
// So the UI needs to filter it first.
|
||||
Object.keys(linkStatus).map((nodeName) => {
|
||||
if (nodes.includes(nodeName)) {
|
||||
const nics = linkStatus[nodeName] || [];
|
||||
|
||||
nics.map((nic) => {
|
||||
out.push({
|
||||
...nic,
|
||||
nodeName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
nicOptions() {
|
||||
const out = [];
|
||||
const map = {};
|
||||
|
||||
(this.matchNICs || []).map((nic) => {
|
||||
if (nic.masterIndex && !this.originNics.includes(nic.name)) {
|
||||
set(map, `${ nic.name }.masterIndex`, true);
|
||||
} else if (!findBy(out, 'name', nic.name)) {
|
||||
out.push(nic);
|
||||
|
||||
set(map, `${ nic.name }.total`, 1);
|
||||
set(map, `${ nic.name }.down`, nic.state === 'down' ? 1 : 0);
|
||||
} else if (findBy(out, 'name', nic.name)) {
|
||||
set(map, `${ nic.name }.total`, map[nic.name].total + 1);
|
||||
set(map, `${ nic.name }.down`, nic.state === 'down' ? map[nic.name].down + 1 : map[nic.name].down);
|
||||
}
|
||||
});
|
||||
|
||||
return out.filter(o => !map[o.name].masterIndex).map((o) => {
|
||||
let label = '';
|
||||
|
||||
if (map[o.name].down === 0) {
|
||||
label = `${ o.name } (Up)`;
|
||||
} else if (map[o.name].total === 1) {
|
||||
label = `${ o.name } (Down)`;
|
||||
} else {
|
||||
label = `${ o.name } (${ map[o.name].down }/${ map[o.name].total } Down)`;
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
value: o.name,
|
||||
disabled: map[o.name].down > 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
nodes() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
|
||||
|
||||
return nodes.filter(n => n.isEtcd !== 'true');
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
validate() {
|
||||
const errors = [];
|
||||
|
||||
const nics = this.value.spec?.uplink?.nics || [];
|
||||
const nicRequired = this.t('validation.arrayCountRequired', { key: this.t('harvester.vlanConfig.uplink.nics.label'), count: 1 }, true);
|
||||
|
||||
if (nics.length === 0) {
|
||||
errors.push(nicRequired);
|
||||
} else {
|
||||
nics.map((n) => {
|
||||
if (!n) {
|
||||
errors.push(nicRequired);
|
||||
}
|
||||
|
||||
const option = this.nicOptions.find(option => option.value === n);
|
||||
|
||||
if (option && option?.disabled) {
|
||||
errors.push(this.t('harvester.vlanConfig.uplink.nics.validate.available', { nic: n }, true));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.value?.metadata?.name) {
|
||||
errors.push(this.t('validation.required', { key: this.t('generic.name') }, true));
|
||||
}
|
||||
|
||||
if (!this.value?.spec?.clusterNetwork) {
|
||||
errors.push(this.t('validation.required', { key: this.t('harvester.network.clusterNetwork.label') }, true));
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Promise.reject(uniq(errors));
|
||||
} else {
|
||||
const miimon = this.value?.spec?.uplink?.bondOptions?.miimon;
|
||||
|
||||
if (!miimon && miimon !== 0) {
|
||||
delete this.value?.spec?.uplink?.bondOptions?.miimon;
|
||||
}
|
||||
|
||||
const mtu = this.value?.spec?.uplink?.linkAttributes?.mtu;
|
||||
|
||||
if (!mtu && mtu !== 0 ) {
|
||||
delete this.value?.spec?.uplink?.linkAttributes?.mtu;
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
|
||||
updateMatchingNICs: throttle(function() {
|
||||
const nodeSelector = this.value?.spec?.nodeSelector || {};
|
||||
|
||||
const allNICs = this.nics || [];
|
||||
let matchNICs = [];
|
||||
let commonNodes = [];
|
||||
|
||||
if (isEmpty(nodeSelector)) {
|
||||
matchNICs = clone(allNICs);
|
||||
commonNodes = (this.nodes || []).map(n => n.id);
|
||||
} else if (nodeSelector[HOSTNAME] && Object.keys(nodeSelector).length === 1) {
|
||||
matchNICs = allNICs.filter(n => n.nodeName === nodeSelector[HOSTNAME]);
|
||||
commonNodes = [nodeSelector[HOSTNAME]];
|
||||
} else {
|
||||
const matchNodes = matching(this.nodes || [], nodeSelector).map(n => n.id);
|
||||
|
||||
matchNICs = allNICs.filter(n => matchNodes.includes(n.nodeName));
|
||||
commonNodes = matchNodes.map(n => n.id);
|
||||
}
|
||||
|
||||
this.matchNICs = this.intersection(matchNICs, commonNodes) || [];
|
||||
}, 250, { leading: true }),
|
||||
|
||||
intersection(nics = [], commonNodes = []) {
|
||||
const map = {};
|
||||
|
||||
nics.map((n) => {
|
||||
map[n.name] = (map[n.name] || 0) + 1;
|
||||
});
|
||||
|
||||
return nics.filter(n => map[n.name] === commonNodes.length);
|
||||
},
|
||||
|
||||
updateMatchingNodes: throttle(function() {
|
||||
const selector = this.value?.spec?.nodeSelector || {};
|
||||
const allNodes = this.nodes || [];
|
||||
|
||||
if (isEmpty(selector)) {
|
||||
this.matchingNodes = {
|
||||
matched: allNodes.length,
|
||||
total: allNodes.length,
|
||||
none: false,
|
||||
sample: allNodes[0] ? allNodes[0].nameDisplay : null,
|
||||
};
|
||||
} else if (selector[HOSTNAME] && Object.keys(selector).length === 1) {
|
||||
const matchNode = allNodes.find(n => n.id === selector[HOSTNAME]);
|
||||
|
||||
this.matchingNodes = {
|
||||
matched: 1,
|
||||
total: allNodes.length,
|
||||
none: false,
|
||||
sample: matchNode ? matchNode.nameDisplay : selector[HOSTNAME],
|
||||
};
|
||||
} else {
|
||||
const match = matching(allNodes, selector);
|
||||
|
||||
this.matchingNodes = {
|
||||
matched: match.length,
|
||||
total: allNodes.length,
|
||||
none: match.length === 0,
|
||||
sample: match[0] ? match[0].nameDisplay : null,
|
||||
};
|
||||
}
|
||||
}, 250, { leading: true }),
|
||||
},
|
||||
|
||||
watch: {
|
||||
nicOptions(options) {
|
||||
const nics = this.value.spec?.uplink?.nics || [];
|
||||
const nicErrors = [];
|
||||
|
||||
nics.map((n) => {
|
||||
const option = options.find(option => option.value === n);
|
||||
|
||||
if ((option && option?.disabled) || !option) {
|
||||
nicErrors.push(this.t('harvester.vlanConfig.uplink.nics.validate.available', { nic: n }, true));
|
||||
}
|
||||
});
|
||||
|
||||
this.nicErrors = uniq(nicErrors);
|
||||
},
|
||||
|
||||
'value.spec.uplink.nics'(nics = []) {
|
||||
const nicErrors = [];
|
||||
const options = this.nicOptions || [];
|
||||
|
||||
nics.map((n) => {
|
||||
const option = options.find(option => option.value === n);
|
||||
|
||||
if ((option && option?.disabled) || !option) {
|
||||
nicErrors.push(this.t('harvester.vlanConfig.uplink.nics.validate.available', { nic: n }, true));
|
||||
}
|
||||
});
|
||||
|
||||
this.nicErrors = uniq(nicErrors);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="$fetchState.pending" />
|
||||
<CruResource
|
||||
v-else
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
@finish="save"
|
||||
>
|
||||
<NameNsDescription
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
/>
|
||||
|
||||
<Tabbed
|
||||
:side-tabs="true"
|
||||
>
|
||||
<Tab
|
||||
name="nodeSelector"
|
||||
:label="t('harvester.vlanConfig.titles.nodeSelector')"
|
||||
:weight="99"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<Banner :color="(matchingNodes.none ? 'warning' : 'success')">
|
||||
<span v-clean-html="t('harvester.vlanConfig.nodeSelector.matchingNodes.matchesSome', matchingNodes)" />
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<NodeSelector
|
||||
:mode="mode"
|
||||
:value="value.spec"
|
||||
:nodes="nodeOptions"
|
||||
@updateMatchingNICs="updateMatchingNICs"
|
||||
@updateMatchingNodes="updateMatchingNodes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="upLink"
|
||||
:label="t('harvester.vlanConfig.titles.uplink')"
|
||||
:weight="99"
|
||||
:show-header="false"
|
||||
>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabelValue
|
||||
:name="t('harvester.network.clusterNetwork.label')"
|
||||
:value="value.spec.clusterNetwork"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabelValue
|
||||
name="Type"
|
||||
value="VLAN"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-20">
|
||||
<div class="col span-12">
|
||||
<Banner
|
||||
v-for="(err, i) in nicErrors"
|
||||
:key="i"
|
||||
color="warning"
|
||||
:label="err"
|
||||
/>
|
||||
<ArrayListSelect
|
||||
v-model="value.spec.uplink.nics"
|
||||
:mode="mode"
|
||||
:options="nicOptions"
|
||||
:array-list-props="{
|
||||
addLabel: t('harvester.vlanConfig.uplink.nics.addLabel'),
|
||||
initialEmptyRow: true,
|
||||
title: t('harvester.vlanConfig.uplink.nics.label'),
|
||||
required: true,
|
||||
protip: false,
|
||||
}"
|
||||
:select-props="{
|
||||
placeholder: t('harvester.vlanConfig.uplink.nics.placeholder'),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-20">
|
||||
{{ t('harvester.vlanConfig.titles.bondOptions') }}
|
||||
</h3>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model="bondOptionMode"
|
||||
:label="t('harvester.vlanConfig.uplink.bondOptions.mode.label')"
|
||||
:mode="mode"
|
||||
:options="bondOptions"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="miimon"
|
||||
:label="t('harvester.vlanConfig.uplink.bondOptions.miimon.label')"
|
||||
:mode="mode"
|
||||
type="number"
|
||||
:tooltip="t('harvester.vlanConfig.uplink.bondOptions.miimon.tooltip', null, true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-20">
|
||||
{{ t('harvester.vlanConfig.titles.attributes') }}
|
||||
</h3>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model.number="mtu"
|
||||
:label="t('harvester.vlanConfig.uplink.linkAttributes.mtu.label')"
|
||||
:mode="mode"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
button {
|
||||
all: unset;
|
||||
height: 0;
|
||||
position: relative;
|
||||
top: -35px;
|
||||
float: right;
|
||||
margin-right: 7px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
.align-value {
|
||||
padding-top: 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user