Add pkg/harvester components + shell portings - 1

Signed-off-by: Francesco Torchia <francesco.torchia@suse.com>
This commit is contained in:
Francesco Torchia
2024-10-23 17:00:46 +02:00
parent f8408469f7
commit 4f2688f6ab
265 changed files with 42316 additions and 82 deletions
@@ -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>