mirror of
https://github.com/nasa/openmct.git
synced 2025-05-22 18:23:59 +00:00
Merge pull request #2800 from nasa/any-all-telemetry
Any all telemetry option for conditions.
This commit is contained in:
commit
e4c9f156a7
@ -25,6 +25,7 @@ import uuid from 'uuid';
|
|||||||
import TelemetryCriterion from "./criterion/TelemetryCriterion";
|
import TelemetryCriterion from "./criterion/TelemetryCriterion";
|
||||||
import { TRIGGER } from "./utils/constants";
|
import { TRIGGER } from "./utils/constants";
|
||||||
import {computeCondition, computeConditionByLimit} from "./utils/evaluator";
|
import {computeCondition, computeConditionByLimit} from "./utils/evaluator";
|
||||||
|
import AllTelemetryCriterion from "./criterion/AllTelemetryCriterion";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* conditionConfiguration = {
|
* conditionConfiguration = {
|
||||||
@ -72,7 +73,11 @@ export default class ConditionClass extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.criteria.forEach(criterion => {
|
this.criteria.forEach(criterion => {
|
||||||
criterion.emit(`subscription:${datum.id}`, datum);
|
if (criterion.telemetry && (criterion.telemetry === 'all' || criterion.telemetry === 'any')) {
|
||||||
|
criterion.handleSubscription(datum, this.conditionManager.telemetryObjects);
|
||||||
|
} else {
|
||||||
|
criterion.emit(`subscription:${datum.id}`, datum);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,8 +125,13 @@ export default class ConditionClass extends EventEmitter {
|
|||||||
* adds criterion to the condition.
|
* adds criterion to the condition.
|
||||||
*/
|
*/
|
||||||
addCriterion(criterionConfiguration) {
|
addCriterion(criterionConfiguration) {
|
||||||
|
let criterion;
|
||||||
let criterionConfigurationWithId = this.generateCriterion(criterionConfiguration || null);
|
let criterionConfigurationWithId = this.generateCriterion(criterionConfiguration || null);
|
||||||
let criterion = new TelemetryCriterion(criterionConfigurationWithId, this.openmct);
|
if (criterionConfiguration.telemetry && (criterionConfiguration.telemetry === 'any' || criterionConfiguration.telemetry === 'all')) {
|
||||||
|
criterion = new AllTelemetryCriterion(criterionConfigurationWithId, this.openmct);
|
||||||
|
} else {
|
||||||
|
criterion = new TelemetryCriterion(criterionConfigurationWithId, this.openmct);
|
||||||
|
}
|
||||||
criterion.on('criterionUpdated', (obj) => this.handleCriterionUpdated(obj));
|
criterion.on('criterionUpdated', (obj) => this.handleCriterionUpdated(obj));
|
||||||
criterion.on('criterionResultUpdated', (obj) => this.handleCriterionResult(obj));
|
criterion.on('criterionResultUpdated', (obj) => this.handleCriterionResult(obj));
|
||||||
if (!this.criteria) {
|
if (!this.criteria) {
|
||||||
|
@ -9,6 +9,8 @@
|
|||||||
@change="updateMetadataOptions"
|
@change="updateMetadataOptions"
|
||||||
>
|
>
|
||||||
<option value="">- Select Telemetry -</option>
|
<option value="">- Select Telemetry -</option>
|
||||||
|
<option value="all">All Telemetry</option>
|
||||||
|
<option value="any">Any Telemetry</option>
|
||||||
<option v-for="telemetryOption in telemetry"
|
<option v-for="telemetryOption in telemetry"
|
||||||
:key="telemetryOption.identifier.key"
|
:key="telemetryOption.identifier.key"
|
||||||
:value="telemetryOption.identifier"
|
:value="telemetryOption.identifier"
|
||||||
@ -20,7 +22,8 @@
|
|||||||
<span v-if="criterion.telemetry"
|
<span v-if="criterion.telemetry"
|
||||||
class="c-cdef__control"
|
class="c-cdef__control"
|
||||||
>
|
>
|
||||||
<select v-model="criterion.metadata"
|
<select ref="metadataSelect"
|
||||||
|
v-model="criterion.metadata"
|
||||||
@change="updateOperations"
|
@change="updateOperations"
|
||||||
>
|
>
|
||||||
<option value="">- Select Field -</option>
|
<option value="">- Select Field -</option>
|
||||||
@ -36,7 +39,7 @@
|
|||||||
class="c-cdef__control"
|
class="c-cdef__control"
|
||||||
>
|
>
|
||||||
<select v-model="criterion.operation"
|
<select v-model="criterion.operation"
|
||||||
@change="updateOperationInputVisibility"
|
@change="updateInputVisibilityAndValues"
|
||||||
>
|
>
|
||||||
<option value="">- Select Comparison -</option>
|
<option value="">- Select Comparison -</option>
|
||||||
<option v-for="option in filteredOps"
|
<option v-for="option in filteredOps"
|
||||||
@ -107,8 +110,7 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
telemetryMetadata: {},
|
telemetryMetadataOptions: [],
|
||||||
telemetryMetadataOptions: {},
|
|
||||||
operations: OPERATIONS,
|
operations: OPERATIONS,
|
||||||
inputCount: 0,
|
inputCount: 0,
|
||||||
rowLabel: '',
|
rowLabel: '',
|
||||||
@ -123,13 +125,13 @@ export default {
|
|||||||
return (this.index !== 0 ? operator : '') + 'when';
|
return (this.index !== 0 ? operator : '') + 'when';
|
||||||
},
|
},
|
||||||
filteredOps: function () {
|
filteredOps: function () {
|
||||||
return [...this.operations.filter(op => op.appliesTo.indexOf(this.operationFormat) !== -1)];
|
return this.operations.filter(op => op.appliesTo.indexOf(this.operationFormat) !== -1);
|
||||||
},
|
},
|
||||||
setInputType: function () {
|
setInputType: function () {
|
||||||
let type = '';
|
let type = '';
|
||||||
for (let i = 0; i < this.filteredOps.length; i++) {
|
for (let i = 0; i < this.filteredOps.length; i++) {
|
||||||
if (this.criterion.operation === this.filteredOps[i].name) {
|
if (this.criterion.operation === this.filteredOps[i].name) {
|
||||||
if (this.filteredOps[i].appliesTo.length === 1) {
|
if (this.filteredOps[i].appliesTo.length) {
|
||||||
type = this.inputTypes[this.filteredOps[i].appliesTo[0]];
|
type = this.inputTypes[this.filteredOps[i].appliesTo[0]];
|
||||||
} else {
|
} else {
|
||||||
type = 'text'
|
type = 'text'
|
||||||
@ -153,79 +155,113 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
checkTelemetry() {
|
checkTelemetry() {
|
||||||
if(this.criterion.telemetry &&
|
if(this.criterion.telemetry) {
|
||||||
!this.telemetry.find((telemetryObj) => this.openmct.objects.areIdsEqual(this.criterion.telemetry, telemetryObj.identifier))) {
|
if (this.criterion.telemetry === 'any' || this.criterion.telemetry === 'all') {
|
||||||
//telemetry being used was removed. So reset this criterion.
|
this.updateMetadataOptions();
|
||||||
this.criterion.telemetry = '';
|
} else {
|
||||||
this.criterion.metadata = '';
|
if (!this.telemetry.find((telemetryObj) => this.openmct.objects.areIdsEqual(this.criterion.telemetry, telemetryObj.identifier))) {
|
||||||
this.criterion.input = [];
|
//telemetry being used was removed. So reset this criterion.
|
||||||
this.criterion.operation = '';
|
this.criterion.telemetry = '';
|
||||||
this.persist();
|
this.criterion.metadata = '';
|
||||||
}
|
this.criterion.input = [];
|
||||||
},
|
this.criterion.operation = '';
|
||||||
getOperationFormat() {
|
this.persist();
|
||||||
this.enumerations = [];
|
|
||||||
this.telemetryMetadata.valueMetadatas.forEach((value, index) => {
|
|
||||||
if (value.key === this.criterion.metadata) {
|
|
||||||
let valueMetadata = this.telemetryMetadataOptions[index];
|
|
||||||
if (valueMetadata.enumerations !== undefined) {
|
|
||||||
this.operationFormat = 'enum';
|
|
||||||
this.enumerations = valueMetadata.enumerations;
|
|
||||||
} else if (valueMetadata.hints.hasOwnProperty('range')) {
|
|
||||||
this.operationFormat = 'number';
|
|
||||||
} else if (valueMetadata.hints.hasOwnProperty('domain')) {
|
|
||||||
this.operationFormat = 'number';
|
|
||||||
} else if (valueMetadata.key === 'name') {
|
|
||||||
this.operationFormat = 'string';
|
|
||||||
} else {
|
|
||||||
this.operationFormat = 'string';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateOperationFormat() {
|
||||||
|
this.enumerations = [];
|
||||||
|
let foundMetadata = this.telemetryMetadataOptions.find((value) => {
|
||||||
|
return value.key === this.criterion.metadata;
|
||||||
});
|
});
|
||||||
|
if (foundMetadata) {
|
||||||
|
if (foundMetadata.enumerations !== undefined) {
|
||||||
|
this.operationFormat = 'enum';
|
||||||
|
this.enumerations = foundMetadata.enumerations;
|
||||||
|
} else if (foundMetadata.hints.hasOwnProperty('range')) {
|
||||||
|
this.operationFormat = 'number';
|
||||||
|
} else if (foundMetadata.hints.hasOwnProperty('domain')) {
|
||||||
|
this.operationFormat = 'number';
|
||||||
|
} else if (foundMetadata.key === 'name') {
|
||||||
|
this.operationFormat = 'string';
|
||||||
|
} else {
|
||||||
|
this.operationFormat = 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateInputVisibilityAndValues();
|
||||||
},
|
},
|
||||||
updateMetadataOptions(ev) {
|
updateMetadataOptions(ev) {
|
||||||
if (ev) {
|
|
||||||
this.clearDependentFields(ev.target)
|
|
||||||
}
|
|
||||||
if (this.criterion.telemetry) {
|
|
||||||
this.openmct.objects.get(this.criterion.telemetry).then((telemetryObject) => {
|
|
||||||
this.telemetryMetadata = this.openmct.telemetry.getMetadata(telemetryObject);
|
|
||||||
this.telemetryMetadataOptions = this.telemetryMetadata.values();
|
|
||||||
this.updateOperations(ev);
|
|
||||||
this.updateOperationInputVisibility();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.criterion.metadata = '';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateOperations(ev) {
|
|
||||||
if (ev) {
|
if (ev) {
|
||||||
this.clearDependentFields(ev.target);
|
this.clearDependentFields(ev.target);
|
||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
this.getOperationFormat();
|
if (this.criterion.telemetry) {
|
||||||
|
const telemetry = (this.criterion.telemetry === 'all' || this.criterion.telemetry === 'any') ? this.telemetry : [{
|
||||||
|
identifier: this.criterion.telemetry
|
||||||
|
}];
|
||||||
|
|
||||||
|
let telemetryPromises = telemetry.map((telemetryObject) => this.openmct.objects.get(telemetryObject.identifier));
|
||||||
|
Promise.all(telemetryPromises).then(telemetryObjects => {
|
||||||
|
this.telemetryMetadataOptions = [];
|
||||||
|
telemetryObjects.forEach(telemetryObject => {
|
||||||
|
let telemetryMetadata = this.openmct.telemetry.getMetadata(telemetryObject);
|
||||||
|
this.addMetaDataOptions(telemetryMetadata.values());
|
||||||
|
});
|
||||||
|
this.updateOperations();
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
updateOperationInputVisibility(ev) {
|
addMetaDataOptions(options) {
|
||||||
if (ev) {
|
if (!this.telemetryMetadataOptions) {
|
||||||
if (this.enumerations.length) {
|
this.telemetryMetadataOptions = options;
|
||||||
this.criterion.input = [this.enumerations[0].value.toString()];
|
}
|
||||||
|
options.forEach((option) => {
|
||||||
|
const found = this.telemetryMetadataOptions.find((metadataOption) => {
|
||||||
|
return (metadataOption.key && (metadataOption.key === option.key)) && (metadataOption.name && (metadataOption.name === option.name))
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
this.telemetryMetadataOptions.push(option);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateOperations(ev) {
|
||||||
|
this.updateOperationFormat();
|
||||||
|
if (ev) {
|
||||||
|
this.clearDependentFields(ev.target);
|
||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
updateInputVisibilityAndValues(ev) {
|
||||||
|
if (ev) {
|
||||||
|
this.clearDependentFields();
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < this.filteredOps.length; i++) {
|
for (let i = 0; i < this.filteredOps.length; i++) {
|
||||||
if (this.criterion.operation === this.filteredOps[i].name) {
|
if (this.criterion.operation === this.filteredOps[i].name) {
|
||||||
this.inputCount = this.filteredOps[i].inputCount;
|
this.inputCount = this.filteredOps[i].inputCount;
|
||||||
if (!this.inputCount) {this.criterion.input = []}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!this.inputCount) {
|
||||||
|
this.criterion.input = [];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearDependentFields(el) {
|
clearDependentFields(el) {
|
||||||
if (el === this.$refs.telemetrySelect) {
|
if (el === this.$refs.telemetrySelect) {
|
||||||
this.criterion.metadata = '';
|
this.criterion.metadata = '';
|
||||||
|
} else if (el === this.$refs.metadataSelect) {
|
||||||
|
if (!this.filteredOps.find(operation => operation.name === this.criterion.operation)) {
|
||||||
|
this.criterion.operation = '';
|
||||||
|
this.criterion.input = this.enumerations.length ? [this.enumerations[0].value.toString()] : [];
|
||||||
|
this.inputCount = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.enumerations.length && !this.criterion.input.length) {
|
||||||
|
this.criterion.input = [this.enumerations[0].value.toString()];
|
||||||
|
}
|
||||||
|
this.inputCount = 0;
|
||||||
}
|
}
|
||||||
this.criterion.operation = '';
|
|
||||||
this.criterion.input = [];
|
|
||||||
this.inputCount = 0;
|
|
||||||
},
|
},
|
||||||
persist() {
|
persist() {
|
||||||
this.$emit('persist', this.criterion);
|
this.$emit('persist', this.criterion);
|
||||||
|
172
src/plugins/condition/criterion/AllTelemetryCriterion.js
Normal file
172
src/plugins/condition/criterion/AllTelemetryCriterion.js
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
/*****************************************************************************
|
||||||
|
* Open MCT, Copyright (c) 2014-2020, United States Government
|
||||||
|
* as represented by the Administrator of the National Aeronautics and Space
|
||||||
|
* Administration. All rights reserved.
|
||||||
|
*
|
||||||
|
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*
|
||||||
|
* Open MCT includes source code licensed under additional open source
|
||||||
|
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||||
|
* this source code distribution or the Licensing information page available
|
||||||
|
* at runtime from the About dialog for additional information.
|
||||||
|
*****************************************************************************/
|
||||||
|
|
||||||
|
import EventEmitter from 'EventEmitter';
|
||||||
|
import {OPERATIONS} from '../utils/operations';
|
||||||
|
import {computeCondition} from "@/plugins/condition/utils/evaluator";
|
||||||
|
|
||||||
|
export default class TelemetryCriterion extends EventEmitter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes/Unsubscribes to telemetry and emits the result
|
||||||
|
* of operations performed on the telemetry data returned and a given input value.
|
||||||
|
* @constructor
|
||||||
|
* @param telemetryDomainObjectDefinition {id: uuid, operation: enum, input: Array, metadata: string, key: {domainObject.identifier} }
|
||||||
|
* @param openmct
|
||||||
|
*/
|
||||||
|
constructor(telemetryDomainObjectDefinition, openmct) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.openmct = openmct;
|
||||||
|
this.objectAPI = this.openmct.objects;
|
||||||
|
this.telemetryAPI = this.openmct.telemetry;
|
||||||
|
this.timeAPI = this.openmct.time;
|
||||||
|
this.id = telemetryDomainObjectDefinition.id;
|
||||||
|
this.telemetry = telemetryDomainObjectDefinition.telemetry;
|
||||||
|
this.operation = telemetryDomainObjectDefinition.operation;
|
||||||
|
this.telemetryObjects = Object.assign({}, telemetryDomainObjectDefinition.telemetryObjects);
|
||||||
|
this.input = telemetryDomainObjectDefinition.input;
|
||||||
|
this.metadata = telemetryDomainObjectDefinition.metadata;
|
||||||
|
this.telemetryDataCache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTelemetry(telemetryObjects) {
|
||||||
|
this.telemetryObjects = Object.assign({}, telemetryObjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatData(data, telemetryObjects) {
|
||||||
|
if (data) {
|
||||||
|
this.telemetryDataCache[data.id] = this.computeResult(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys = Object.keys(telemetryObjects);
|
||||||
|
keys.forEach((key) => {
|
||||||
|
let telemetryObject = telemetryObjects[key];
|
||||||
|
const id = this.openmct.objects.makeKeyString(telemetryObject.identifier);
|
||||||
|
if (this.telemetryDataCache[id] === undefined) {
|
||||||
|
this.telemetryDataCache[id] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const datum = {
|
||||||
|
result: computeCondition(this.telemetryDataCache, this.telemetry === 'all')
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
// TODO check back to see if we should format times here
|
||||||
|
this.timeAPI.getAllTimeSystems().forEach(timeSystem => {
|
||||||
|
datum[timeSystem.key] = data[timeSystem.key]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return datum;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubscription(data, telemetryObjects) {
|
||||||
|
if(this.isValid()) {
|
||||||
|
this.emitEvent('criterionResultUpdated', this.formatData(data, telemetryObjects));
|
||||||
|
} else {
|
||||||
|
this.emitEvent('criterionResultUpdated', this.formatData({}, telemetryObjects));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findOperation(operation) {
|
||||||
|
for (let i=0; i < OPERATIONS.length; i++) {
|
||||||
|
if (operation === OPERATIONS[i].name) {
|
||||||
|
return OPERATIONS[i].operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeResult(data) {
|
||||||
|
let result = false;
|
||||||
|
if (data) {
|
||||||
|
let comparator = this.findOperation(this.operation);
|
||||||
|
let params = [];
|
||||||
|
params.push(data[this.metadata]);
|
||||||
|
if (this.input instanceof Array && this.input.length) {
|
||||||
|
this.input.forEach(input => params.push(input));
|
||||||
|
}
|
||||||
|
if (typeof comparator === 'function') {
|
||||||
|
result = comparator(params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitEvent(eventName, data) {
|
||||||
|
this.emit(eventName, {
|
||||||
|
id: this.id,
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid() {
|
||||||
|
return (this.telemetry === 'any' || this.telemetry === 'all') && this.metadata && this.operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLAD(options) {
|
||||||
|
options = Object.assign({},
|
||||||
|
options,
|
||||||
|
{
|
||||||
|
strategy: 'latest',
|
||||||
|
size: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.isValid()) {
|
||||||
|
return this.formatData({}, options.telemetryObjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
const telemetryRequests = options.telemetryObjects
|
||||||
|
.map(telemetryObject => this.telemetryAPI.request(
|
||||||
|
telemetryObject,
|
||||||
|
options
|
||||||
|
));
|
||||||
|
|
||||||
|
return Promise.all(telemetryRequests)
|
||||||
|
.then(telemetryRequestsResults => {
|
||||||
|
telemetryRequestsResults.forEach((results, index) => {
|
||||||
|
const latestDatum = results.length ? results[results.length - 1] : {};
|
||||||
|
if (index === telemetryRequestsResults.length-1) {
|
||||||
|
//when the last result is computed, we return the result
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
data: this.formatData(latestDatum, options.telemetryObjects)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (latestDatum) {
|
||||||
|
this.telemetryDataCache[latestDatum.id] = this.computeResult(latestDatum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.emitEvent('criterionRemoved');
|
||||||
|
delete this.telemetryObjects;
|
||||||
|
delete this.telemetryDataCache;
|
||||||
|
delete this.telemetryObjectIdAsString;
|
||||||
|
delete this.telemetryObject;
|
||||||
|
}
|
||||||
|
}
|
@ -71,6 +71,8 @@ export default class TelemetryCriterion extends EventEmitter {
|
|||||||
handleSubscription(data) {
|
handleSubscription(data) {
|
||||||
if(this.isValid()) {
|
if(this.isValid()) {
|
||||||
this.emitEvent('criterionResultUpdated', this.formatData(data));
|
this.emitEvent('criterionResultUpdated', this.formatData(data));
|
||||||
|
} else {
|
||||||
|
this.emitEvent('criterionResultUpdated', this.formatData({}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user