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,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>
|
||||
Reference in New Issue
Block a user