forked from cerc-io/cosmos-explorer
commit
add8a3466b
@ -8,6 +8,7 @@ import { ref } from 'vue';
|
||||
|
||||
defineProps({
|
||||
proposals: { type: Object as PropType<PaginatedProposals> },
|
||||
votable: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const format = useFormatter();
|
||||
@ -30,12 +31,12 @@ const proposalInfo = ref();
|
||||
</script>
|
||||
<template>
|
||||
<div class="bg-white dark:bg-[#28334e] rounded text-sm">
|
||||
<table class="table-compact w-full table-fixed hidden lg:table">
|
||||
<table class="table-compact w-full table-fixed lg:table">
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in proposals?.proposals" :key="index">
|
||||
<td class="px-4 w-20">
|
||||
<label
|
||||
for="proposal-detail-modal"
|
||||
for=""
|
||||
class="text-main text-base hover:text-indigo-400 cursor-pointer"
|
||||
@click="proposalInfo = item"
|
||||
>
|
||||
@ -97,7 +98,7 @@ const proposalInfo = ref();
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<td v-if="votable">
|
||||
<div>
|
||||
<button class="btn btn-xs btn-primary rounded-sm">Vote</button>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@ const baseStore = useBaseStore();
|
||||
chainStore.initial();
|
||||
chainStore.$subscribe((m, s) => {
|
||||
if (!Array.isArray(m.events) && m.events.key === 'endpoint') {
|
||||
chainStore.initial();
|
||||
// chainStore.initial();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
@ -98,7 +98,7 @@ export class CosmosRestClient extends BaseRestClient<RequestRegistry> {
|
||||
async getGovParamsTally() {
|
||||
return this.request(this.registry.gov_params_tally, {});
|
||||
}
|
||||
async getGovProposals(status: string, limit = 50) {
|
||||
async getGovProposals(status: string, limit = 20) {
|
||||
const query =
|
||||
'?proposal_status={status}&pagination.limit={limit}&pagination.reverse=true&pagination.key=';
|
||||
return this.request(this.registry.gov_proposals, { status, limit }, query);
|
||||
|
@ -41,7 +41,7 @@ const changeTab = (val: '2' | '3' | '4') => {
|
||||
>Rejected</a
|
||||
>
|
||||
</div>
|
||||
<ProposalListItem :proposals="store?.proposals[tab]" />
|
||||
<ProposalListItem :proposals="store?.proposals[tab]" :votable="tab === '2'" />
|
||||
</div>
|
||||
</template>
|
||||
<route>
|
||||
|
@ -16,23 +16,14 @@ import type { SigningInfo } from '@/types/slashing';
|
||||
const props = defineProps(['chain']);
|
||||
|
||||
const stakingStore = useStakingStore();
|
||||
const format = useFormatter();
|
||||
const baseStore = useBaseStore();
|
||||
const chainStore = useBlockchain();
|
||||
const latest = ref({} as Block);
|
||||
const latest = ref(0);
|
||||
const commits = ref([] as Commit[]);
|
||||
const keyword = ref('');
|
||||
const live = ref(true);
|
||||
|
||||
// storage local favorite validator ids
|
||||
const local = ref(
|
||||
JSON.parse(localStorage.getItem('uptime-validators') || '{}') as Record<
|
||||
string,
|
||||
string[]
|
||||
>
|
||||
);
|
||||
const currentPined = local.value[chainStore.chainName]
|
||||
const selected = ref(currentPined || []); // favorite validators on selected blockchain
|
||||
|
||||
const signingInfo = ref({} as Record<string, SigningInfo>);
|
||||
|
||||
// filter validators by keywords
|
||||
@ -44,10 +35,18 @@ const validators = computed(() => {
|
||||
return stakingStore.validators;
|
||||
});
|
||||
|
||||
const list = computed(() => {
|
||||
return validators.value.map(v => ({
|
||||
v,
|
||||
signing: signingInfo.value[consensusPubkeyToHexAddress(v.consensus_pubkey)],
|
||||
hex: toBase64(fromHex(consensusPubkeyToHexAddress(v.consensus_pubkey)))
|
||||
}))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
live.value = true;
|
||||
baseStore.fetchLatest().then(b => {
|
||||
latest.value = b;
|
||||
latest.value = Number(b.block.header.height);
|
||||
commits.value.unshift(b.block.last_commit);
|
||||
const height = Number(b.block.header?.height || 0);
|
||||
if (height === 0) {
|
||||
@ -82,77 +81,102 @@ onMounted(() => {
|
||||
const commits2 = computed(() => {
|
||||
const la = baseStore.recents.map(b => b.block.last_commit)
|
||||
const all = [...commits.value, ...la]
|
||||
return all.length > 50 ? all.slice(all.length - 50): all
|
||||
return all.length > 50 ? all.slice(all.length - 50) : all
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
live.value = false;
|
||||
});
|
||||
const tab = ref("3")
|
||||
function changeTab(v: string) {
|
||||
tab.value = v
|
||||
}
|
||||
|
||||
|
||||
watchEffect(() => {
|
||||
local.value[chainStore.chainName] = selected.value;
|
||||
localStorage.setItem('uptime-validators', JSON.stringify(local.value));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-base-100 px-5 pt-5">
|
||||
<div class="flex items-center gap-x-4">
|
||||
<input
|
||||
type="text"
|
||||
v-model="keyword"
|
||||
placeholder="Keywords to filter validators"
|
||||
class="input input-sm w-full flex-1"
|
||||
/>
|
||||
<button class="btn btn-primary btn-sm">
|
||||
<Icon icon="mdi-star" class="mr-2 text-lg" />
|
||||
<span class="">Favorite</span>
|
||||
</button>
|
||||
<div>
|
||||
<div class="tabs tabs-boxed bg-transparent mb-4">
|
||||
<a
|
||||
class="tab text-gray-400 capitalize"
|
||||
:class="{ 'tab-active': tab === '3' }"
|
||||
@click="changeTab('3')"
|
||||
>Overall</a
|
||||
>
|
||||
<a
|
||||
class="tab text-gray-400 capitalize"
|
||||
:class="{ 'tab-active': tab === '2' }"
|
||||
@click="changeTab('2')"
|
||||
>Blocks</a
|
||||
>
|
||||
<RouterLink :to="`/${chain}/uptime/overview`">
|
||||
<a
|
||||
class="tab text-gray-400 capitalize"
|
||||
>Customize</a
|
||||
></RouterLink>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6 gap-x-4 mt-4"
|
||||
>
|
||||
<div v-for="(v, i) in validators" :key="i" >
|
||||
<div class="flex items-center justify-between py-0">
|
||||
<label class="text-truncate text-sm">
|
||||
<input type="checkbox"
|
||||
v-model="selected"
|
||||
:value="v.operator_address"
|
||||
/>
|
||||
<span class="ml-1 text-black dark:text-white">{{ i + 1 }}.{{ v.description.moniker }}</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="
|
||||
Number(
|
||||
signingInfo[consensusPubkeyToHexAddress(v.consensus_pubkey)]
|
||||
?.missed_blocks_counter || 0
|
||||
) > 0
|
||||
"
|
||||
class="badge badge-error badge-sm text-white"
|
||||
>
|
||||
{{
|
||||
signingInfo[consensusPubkeyToHexAddress(v.consensus_pubkey)]
|
||||
?.missed_blocks_counter
|
||||
}}
|
||||
</div>
|
||||
<div v-else class="mt-1 badge badge-sm text-white bg-yes border-0">
|
||||
{{
|
||||
signingInfo[consensusPubkeyToHexAddress(v.consensus_pubkey)]
|
||||
?.missed_blocks_counter
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<UptimeBar
|
||||
:blocks="commits2"
|
||||
:validator="
|
||||
toBase64(fromHex(consensusPubkeyToHexAddress(v.consensus_pubkey)))
|
||||
"
|
||||
/>
|
||||
<div class="bg-base-100 px-5 pt-5">
|
||||
<div class="flex items-center gap-x-4">
|
||||
<input type="text" v-model="keyword" placeholder="Keywords to filter validators"
|
||||
class="input input-sm w-full flex-1" />
|
||||
<RouterLink class="btn btn-primary btn-sm" :to="`/${chain}/uptime/overview`">
|
||||
<Icon icon="mdi-star" class="mr-2 text-lg" />
|
||||
<span class="">Favorite</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6 gap-x-4 mt-4" :class="tab === '2'?'':'hidden'">
|
||||
<div v-for="({v, signing, hex}, i) in list" :key="i">
|
||||
<div class="flex items-center justify-between py-0">
|
||||
<label class="text-truncate text-sm">
|
||||
<span class="ml-1 text-black dark:text-white">{{ i + 1 }}.{{ v.description.moniker }}</span>
|
||||
</label>
|
||||
<div v-if="Number(signing?.missed_blocks_counter || 0) > 10" class="badge badge-error badge-sm text-white">
|
||||
{{ signing?.missed_blocks_counter }}
|
||||
</div>
|
||||
<div v-else class="mt-1 badge badge-sm text-white bg-yes border-0">
|
||||
{{ signing?.missed_blocks_counter }}
|
||||
</div>
|
||||
</div>
|
||||
<UptimeBar :blocks="commits2" :validator="hex" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-6"></div>
|
||||
<div :class="tab === '3'?'':'hidden'">
|
||||
<table class="table table-compact w-full mt-5">
|
||||
<thead>
|
||||
<tr>
|
||||
<td rowspan="2">Validator</td>
|
||||
<td rowspan="2">Start Height</td>
|
||||
<td rowspan="2">Signed Blocks</td>
|
||||
<td colspan="2">Missing blocks</td>
|
||||
<td rowspan="2">Last Jailed Time</td>
|
||||
<td rowspan="2">Tombstoned</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>In Window</td>
|
||||
<td>Over All</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr v-for="({v, signing}, i) in list">
|
||||
<td>{{ i+1 }}. {{ v.description.moniker }}</td>
|
||||
<td>{{ signing?.start_height }}</td>
|
||||
<td>{{ signing?.index_offset }}</td>
|
||||
<td>
|
||||
<span class="badge badge-sm text-white" :class="Number(signing?.missed_blocks_counter) < 10?'badge-success':'badge-error'">{{ signing?.missed_blocks_counter }}</span>
|
||||
</td>
|
||||
<td><span v-if="signing && signing.jailed_until.startsWith('1970')">{{ format.percent(Number(signing.index_offset)/(latest-Number(signing.start_height))) }}</span></td>
|
||||
<td><span v-if="signing && !signing.jailed_until.startsWith('1970')">
|
||||
<div class="tooltip" :data-tip="format.toDay(signing?.jailed_until, 'long')">
|
||||
<span>{{ format.toDay(signing?.jailed_until, "from") }}</span>
|
||||
</div>
|
||||
</span></td>
|
||||
<td class=" capitalize">{{ signing?.tombstoned }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="h-6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<route>
|
||||
|
211
src/modules/[chain]/uptime/overview.vue
Normal file
211
src/modules/[chain]/uptime/overview.vue
Normal file
@ -0,0 +1,211 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, computed, watchEffect } from 'vue';
|
||||
import { fromHex, toBase64 } from '@cosmjs/encoding';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import {
|
||||
useFormatter,
|
||||
useStakingStore,
|
||||
useBaseStore,
|
||||
useBlockchain,
|
||||
useDashboard,
|
||||
} from '@/stores';
|
||||
import UptimeBar from '@/components/UptimeBar.vue';
|
||||
import type { Block, Commit } from '@/types';
|
||||
import { consensusPubkeyToHexAddress, valconsToBase64 } from '@/libs';
|
||||
import type { SigningInfo } from '@/types/slashing';
|
||||
import { CosmosRestClient } from '@/libs/client';
|
||||
|
||||
const props = defineProps(['chain']);
|
||||
|
||||
const stakingStore = useStakingStore();
|
||||
const format = useFormatter();
|
||||
const chainStore = useBlockchain();
|
||||
const dashboard = useDashboard()
|
||||
// storage local favorite validator ids
|
||||
const local = ref(JSON.parse(localStorage.getItem('uptime-validators') || '{}') as Record<string, {name: string, address: string}[]>)
|
||||
const signingInfo = ref({} as Record<string, SigningInfo[]>);
|
||||
const selected = ref([] as string[])
|
||||
const selectChain = ref(chainStore.chainName)
|
||||
const validators = ref(stakingStore.validators)
|
||||
const keyword = ref("")
|
||||
|
||||
if(local.value) Object.keys(local.value).map(chainName => {
|
||||
const chain = dashboard.chains[chainName]
|
||||
if(chain && chain.endpoints.rest) {
|
||||
const client = CosmosRestClient.newDefault(chain.endpoints.rest[0].address)
|
||||
client.getSlashingSigningInfos().then( resp => {
|
||||
signingInfo.value[chainName] = resp.info
|
||||
})
|
||||
}
|
||||
if(chainName === selectChain.value) {
|
||||
const vals = local.value[chainName]
|
||||
if(vals) {
|
||||
selected.value = vals.map(x => x.address)
|
||||
}
|
||||
}
|
||||
|
||||
return chain
|
||||
})
|
||||
|
||||
const filterValidators = computed(() => {
|
||||
if(keyword.value) {
|
||||
return validators.value.filter(x => x.description.moniker.indexOf(keyword.value) > -1)
|
||||
}
|
||||
return validators.value
|
||||
})
|
||||
|
||||
const list = computed(() => {
|
||||
const list = [] as any[]
|
||||
if(local.value) Object.keys(local.value).map( chainName => {
|
||||
const vals = local.value[chainName]
|
||||
const info = signingInfo.value[chainName]
|
||||
if(vals && info) {
|
||||
vals.forEach(v => {
|
||||
const sigingInfo = info.find(x => valconsToBase64(x.address) === v.address)
|
||||
list.push( {
|
||||
chainName,
|
||||
v,
|
||||
sigingInfo,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
return list
|
||||
})
|
||||
|
||||
function add() {
|
||||
const newList = [] as { name: string; address: string; }[]
|
||||
selected.value.forEach(x => {
|
||||
const validator = validators.value.find(v => (consensusPubkeyToHexAddress(v.consensus_pubkey) === x))
|
||||
if(validator) newList.push({
|
||||
name: validator.description.moniker || x,
|
||||
address: x
|
||||
})
|
||||
})
|
||||
if(!local.value) local.value = {}
|
||||
local.value[selectChain.value] = newList
|
||||
localStorage.setItem("uptime-validators", JSON.stringify(local.value))
|
||||
}
|
||||
|
||||
function changeChain() {
|
||||
validators.value = []
|
||||
const endpoint = dashboard.chains[selectChain.value].endpoints.rest?.at(0)?.address
|
||||
if(!endpoint) return
|
||||
|
||||
const client = CosmosRestClient.newDefault(endpoint)
|
||||
client.getStakingValidators("BOND_STATUS_BONDED").then(x => {
|
||||
validators.value = x.validators
|
||||
})
|
||||
|
||||
const vals = local.value[selectChain.value]
|
||||
if(vals) {
|
||||
selected.value = vals.map(x => x.address)
|
||||
} else {
|
||||
selected.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function color(v: string) {
|
||||
if(v) {
|
||||
const n = Number(v)
|
||||
if(n < 10) return " badge-success"
|
||||
if(n > 1000) return " badge-error"
|
||||
if(n > 0) return " badge-warning"
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="overflow-x-auto w-full card ">
|
||||
<div class="lg:flex lg:items-center lg:justify-between bg-base-100 p-5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 sm:truncate sm:text-3xl sm:tracking-tight">My Validators</h2>
|
||||
<div class="mt-1 flex flex-col sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-6">
|
||||
<div class="mt-2 flex items-center text-sm text-gray-500">
|
||||
<svg class="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M6 3.75A2.75 2.75 0 018.75 1h2.5A2.75 2.75 0 0114 3.75v.443c.572.055 1.14.122 1.706.2C17.053 4.582 18 5.75 18 7.07v3.469c0 1.126-.694 2.191-1.83 2.54-1.952.599-4.024.921-6.17.921s-4.219-.322-6.17-.921C2.694 12.73 2 11.665 2 10.539V7.07c0-1.321.947-2.489 2.294-2.676A41.047 41.047 0 016 4.193V3.75zm6.5 0v.325a41.622 41.622 0 00-5 0V3.75c0-.69.56-1.25 1.25-1.25h2.5c.69 0 1.25.56 1.25 1.25zM10 10a1 1 0 00-1 1v.01a1 1 0 001 1h.01a1 1 0 001-1V11a1 1 0 00-1-1H10z"
|
||||
clip-rule="evenodd" />
|
||||
<path
|
||||
d="M3 15.055v-.684c.126.053.255.1.39.142 2.092.642 4.313.987 6.61.987 2.297 0 4.518-.345 6.61-.987.135-.041.264-.089.39-.142v.684c0 1.347-.985 2.53-2.363 2.686a41.454 41.454 0 01-9.274 0C3.985 17.585 3 16.402 3 15.055z" />
|
||||
</svg>
|
||||
Add validators you want to monitor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex lg:ml-4 lg:mt-0">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Blockchain</th>
|
||||
<th>Validator</th>
|
||||
<th>Missing Blocks</th>
|
||||
<th>Signed Blocks</th>
|
||||
<th>Last Jailed Time</th>
|
||||
<th>Tombstoned</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="v in list">
|
||||
<td class=" capitalize">{{ v.chainName }}</td>
|
||||
<td>{{ v.v.name }}</td>
|
||||
<td><span class="badge " :class="color( v.sigingInfo?.missed_blocks_counter)">{{ v.sigingInfo?.missed_blocks_counter }}</span></td>
|
||||
<td><span v-if="v.sigingInfo">{{ Number(v.sigingInfo.index_offset) - Number(v.sigingInfo.start_height) }}</span></td>
|
||||
<td>
|
||||
<div v-if="v.sigingInfo && !v.sigingInfo?.jailed_until.startsWith('1970')" class="text-xs flex flex-col">
|
||||
<div class="badge">{{ format.toDay(v.sigingInfo.jailed_until, 'from') }}</div>
|
||||
<div>{{v.sigingInfo?.jailed_until }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class=" capitalize">{{ v.sigingInfo?.tombstoned }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<label for="add-validator" class="btn btn-primary mt-5">Add Validators</label>
|
||||
|
||||
<!-- Put this part before </body> tag -->
|
||||
<input type="checkbox" id="add-validator" class="modal-toggle" />
|
||||
<div class="modal">
|
||||
<div class="modal-box relative">
|
||||
<label for="add-validator" class="btn btn-sm btn-circle absolute right-2 top-2">✕</label>
|
||||
<h3 class="text-lg font-bold">Add Validators</h3>
|
||||
<div class="py-4 max-h-60 overflow-y-auto">
|
||||
<div class="form-control my-5 border-2">
|
||||
<div class="input-group input-group-md">
|
||||
<select v-model="selectChain" class="select select-bordered capitalize" @change="changeChain">
|
||||
<option v-for="v in dashboard.chains" :value="v.chainName">
|
||||
{{ v.chainName }}
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="keyword" type="text" class="input w-full" placeholder="keywords to filter validator">
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-compact w-full hover">
|
||||
<thead>
|
||||
<tr><th>Validator</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="v in filterValidators">
|
||||
<td><label :for="v.operator_address"><div class=" w-full">{{ v.description.moniker }}</div></label></td>
|
||||
<td><input :id="v.operator_address" v-model="selected" class="checkbox" type="checkbox" :value="consensusPubkeyToHexAddress(v.consensus_pubkey)"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<label for="add-validator" class="btn" @click="add">add</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-6"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -13,7 +13,9 @@ router.beforeEach((to) => {
|
||||
const { chain } = to.params
|
||||
if(chain){
|
||||
const blockchain = useBlockchain()
|
||||
blockchain.setCurrent(chain.toString())
|
||||
if(chain !== blockchain.chainName) {
|
||||
blockchain.setCurrent(chain.toString())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -122,11 +122,16 @@ export const useBlockchain = defineStore('blockchain', {
|
||||
},
|
||||
|
||||
async randomSetupEndpoint() {
|
||||
const all = this.current?.endpoints?.rest;
|
||||
if (all) {
|
||||
const rn = Math.random();
|
||||
const endpoint = all[Math.floor(rn * all.length)];
|
||||
await this.setRestEndpoint(endpoint);
|
||||
const end = localStorage.getItem(`endpoint-${this.chainName}`)
|
||||
if(end) {
|
||||
this.setRestEndpoint(JSON.parse(end))
|
||||
} else {
|
||||
const all = this.current?.endpoints?.rest;
|
||||
if (all) {
|
||||
const rn = Math.random();
|
||||
const endpoint = all[Math.floor(rn * all.length)];
|
||||
await this.setRestEndpoint(endpoint);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -134,9 +139,12 @@ export const useBlockchain = defineStore('blockchain', {
|
||||
this.connErr = '';
|
||||
this.endpoint = endpoint;
|
||||
this.rpc = new CosmosRestClient(endpoint.address, DEFAULT);
|
||||
localStorage.setItem(`endpoint-${this.chainName}`, JSON.stringify(endpoint))
|
||||
},
|
||||
setCurrent(name: string) {
|
||||
this.chainName = name;
|
||||
if(name !== this.chainName) {
|
||||
this.chainName = name;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user