mirror of
https://github.com/didi/KnowStreaming.git
synced 2026-01-07 15:12:14 +08:00
feat: 多集群列表支持编辑 & 代码结构优化
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import React, { useEffect, useMemo, useRef, useState, useReducer } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Slider, Input, Select, Checkbox, Button, Utils, Spin, IconFont, AppContainer } from 'knowdesign';
|
||||
import API from '../../api';
|
||||
import API from '@src/api';
|
||||
import TourGuide, { MultiPageSteps } from '@src/components/TourGuide';
|
||||
import './index.less';
|
||||
import { healthSorceList, linesMetric, pointsMetric, sortFieldList, sortTypes, statusFilters } from './config';
|
||||
import { oneDayMillims } from '../../constants/common';
|
||||
import ListScroll from './List';
|
||||
import { healthSorceList, sortFieldList, sortTypes, statusFilters } from './config';
|
||||
import ClusterList from './List';
|
||||
import AccessClusters from './AccessCluster';
|
||||
import CustomCheckGroup from './CustomCheckGroup';
|
||||
import { ClustersPermissionMap } from '../CommonConfig';
|
||||
@@ -13,98 +12,85 @@ import { ClustersPermissionMap } from '../CommonConfig';
|
||||
const CheckboxGroup = Checkbox.Group;
|
||||
const { Option } = Select;
|
||||
|
||||
interface ClustersState {
|
||||
liveCount: number;
|
||||
downCount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
healthScoreRange?: [number, number];
|
||||
checkedKafkaVersions?: string[];
|
||||
sortInfo?: {
|
||||
sortField: string;
|
||||
sortType: string;
|
||||
};
|
||||
keywords?: string;
|
||||
clusterStatus?: number[];
|
||||
isReloadAll?: boolean;
|
||||
}
|
||||
|
||||
// 未接入集群默认页
|
||||
const DefaultPage = (props: { setVisible: (visible: boolean) => void }) => {
|
||||
return (
|
||||
<div className="empty-page">
|
||||
<div className="title">Kafka 多集群管理</div>
|
||||
<div className="img">
|
||||
<div className="img-card-1" />
|
||||
<div className="img-card-2" />
|
||||
<div className="img-card-3" />
|
||||
</div>
|
||||
<div>
|
||||
<Button className="header-filter-top-button" type="primary" onClick={() => props.setVisible(true)}>
|
||||
<span>
|
||||
<IconFont type="icon-jiahao" />
|
||||
<span className="text">接入集群</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
const LoadingState = () => {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Spin spinning={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MultiClusterPage = () => {
|
||||
const [run, setRun] = useState<boolean>(false);
|
||||
const [global] = AppContainer.useGlobalValue();
|
||||
const [statusList, setStatusList] = React.useState([1, 0]);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [accessClusterVisible, setAccessClusterVisible] = React.useState(false);
|
||||
const [curClusterInfo, setCurClusterInfo] = useState<any>({});
|
||||
const [kafkaVersions, setKafkaVersions] = React.useState<string[]>([]);
|
||||
const [existKafkaVersion, setExistKafkaVersion] = React.useState<string[]>([]);
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [list, setList] = useState<[]>([]);
|
||||
const [healthScoreRange, setHealthScoreRange] = React.useState([0, 100]);
|
||||
const [checkedKafkaVersions, setCheckedKafkaVersions] = React.useState<string[]>([]);
|
||||
const [sortInfo, setSortInfo] = React.useState({
|
||||
sortField: 'HealthScore',
|
||||
sortType: 'asc',
|
||||
});
|
||||
const [clusterLoading, setClusterLoading] = useState(true);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [isReload, setIsReload] = useState(false);
|
||||
const [versionLoading, setVersionLoading] = useState(true);
|
||||
const [searchKeywords, setSearchKeywords] = useState('');
|
||||
const [stateInfo, setStateInfo] = React.useState({
|
||||
const [stateInfo, setStateInfo] = React.useState<ClustersState>({
|
||||
downCount: 0,
|
||||
liveCount: 0,
|
||||
total: 0,
|
||||
});
|
||||
const [pagination, setPagination] = useState({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
// TODO: 首次进入因 searchParams 状态变化导致获取两次列表数据的问题
|
||||
const [searchParams, setSearchParams] = React.useState<SearchParams>({
|
||||
keywords: '',
|
||||
checkedKafkaVersions: [],
|
||||
healthScoreRange: [0, 100],
|
||||
sortInfo: {
|
||||
sortField: 'HealthScore',
|
||||
sortType: 'asc',
|
||||
},
|
||||
clusterStatus: [0, 1],
|
||||
// 是否拉取当前所有数据
|
||||
isReloadAll: false,
|
||||
});
|
||||
|
||||
const searchKeyword = useRef('');
|
||||
const isReload = useRef(false);
|
||||
|
||||
const getPhyClustersDashbord = (pageNo: number, pageSize: number) => {
|
||||
const endTime = new Date().getTime();
|
||||
const startTime = endTime - oneDayMillims;
|
||||
const params = {
|
||||
metricLines: {
|
||||
endTime,
|
||||
metricsNames: linesMetric,
|
||||
startTime,
|
||||
},
|
||||
latestMetricNames: pointsMetric,
|
||||
pageNo: pageNo || 1,
|
||||
pageSize: pageSize || 10,
|
||||
preciseFilterDTOList: [
|
||||
{
|
||||
fieldName: 'kafkaVersion',
|
||||
fieldValueList: checkedKafkaVersions as (string | number)[],
|
||||
},
|
||||
],
|
||||
rangeFilterDTOList: [
|
||||
{
|
||||
fieldMaxValue: healthScoreRange[1],
|
||||
fieldMinValue: healthScoreRange[0],
|
||||
fieldName: 'HealthScore',
|
||||
},
|
||||
],
|
||||
searchKeywords,
|
||||
...sortInfo,
|
||||
};
|
||||
|
||||
if (statusList.length === 1) {
|
||||
params.preciseFilterDTOList.push({
|
||||
fieldName: 'Alive',
|
||||
fieldValueList: statusList,
|
||||
});
|
||||
}
|
||||
return Utils.post(API.phyClustersDashbord, params);
|
||||
};
|
||||
|
||||
const getSupportKafkaVersion = () => {
|
||||
Utils.request(API.supportKafkaVersion).then((res) => {
|
||||
setKafkaVersions(Object.keys(res || {}));
|
||||
});
|
||||
};
|
||||
|
||||
const getExistKafkaVersion = () => {
|
||||
setVersionLoading(true);
|
||||
Utils.request(API.getClustersVersion)
|
||||
.then((versions: string[]) => {
|
||||
if (!Array.isArray(versions)) {
|
||||
versions = [];
|
||||
}
|
||||
setExistKafkaVersion(versions.sort().reverse() || []);
|
||||
setVersionLoading(false);
|
||||
setCheckedKafkaVersions(versions || []);
|
||||
})
|
||||
.catch((err) => {
|
||||
setVersionLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 获取集群状态
|
||||
const getPhyClusterState = () => {
|
||||
Utils.request(API.phyClusterState)
|
||||
.then((res: any) => {
|
||||
@@ -115,213 +101,224 @@ const MultiClusterPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 获取 kafka 全部版本
|
||||
const getSupportKafkaVersion = () => {
|
||||
Utils.request(API.supportKafkaVersion).then((res) => {
|
||||
setKafkaVersions(Object.keys(res || {}));
|
||||
});
|
||||
};
|
||||
|
||||
const updateSearchParams = (params: SearchParams) => {
|
||||
setSearchParams((curParams) => ({ ...curParams, isReloadAll: false, ...params }));
|
||||
};
|
||||
|
||||
const searchParamsChangeFunc = {
|
||||
// 健康分改变
|
||||
onSilderChange: (value: [number, number]) =>
|
||||
updateSearchParams({
|
||||
healthScoreRange: value,
|
||||
}),
|
||||
// 排序信息改变
|
||||
onSortInfoChange: (type: string, value: string) =>
|
||||
updateSearchParams({
|
||||
sortInfo: {
|
||||
...searchParams.sortInfo,
|
||||
[type]: value,
|
||||
},
|
||||
}),
|
||||
// Live / Down 筛选
|
||||
onClusterStatusChange: (list: number[]) =>
|
||||
updateSearchParams({
|
||||
clusterStatus: list,
|
||||
}),
|
||||
// 集群名称搜索项改变
|
||||
onInputChange: () =>
|
||||
updateSearchParams({
|
||||
keywords: searchKeyword.current,
|
||||
}),
|
||||
// 集群版本筛选
|
||||
onChangeCheckGroup: (list: string[]) => {
|
||||
updateSearchParams({
|
||||
checkedKafkaVersions: list,
|
||||
isReloadAll: isReload.current,
|
||||
});
|
||||
isReload.current = false;
|
||||
},
|
||||
};
|
||||
|
||||
// 获取当前接入集群的 kafka 版本
|
||||
const getExistKafkaVersion = (isReloadAll = false) => {
|
||||
isReload.current = isReloadAll;
|
||||
Utils.request(API.getClustersVersion).then((versions: string[]) => {
|
||||
if (!Array.isArray(versions)) {
|
||||
versions = [];
|
||||
}
|
||||
setExistKafkaVersion(versions.sort().reverse() || []);
|
||||
});
|
||||
};
|
||||
|
||||
// 接入/编辑集群
|
||||
const showAccessCluster = (clusterInfo: any = {}) => {
|
||||
setCurClusterInfo(clusterInfo);
|
||||
setAccessClusterVisible(true);
|
||||
};
|
||||
// 接入/编辑集群回调
|
||||
const afterAccessCluster = () => {
|
||||
getPhyClusterState();
|
||||
getExistKafkaVersion(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getPhyClusterState();
|
||||
getSupportKafkaVersion();
|
||||
getExistKafkaVersion();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageLoading && stateInfo.total) {
|
||||
setRun(true);
|
||||
}
|
||||
}, [pageLoading, stateInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (versionLoading) return;
|
||||
setClusterLoading(true);
|
||||
getPhyClustersDashbord(pagination.pageNo, pagination.pageSize)
|
||||
.then((res: any) => {
|
||||
setPagination(res.pagination);
|
||||
setList(res?.bizData || []);
|
||||
return res;
|
||||
})
|
||||
.finally(() => {
|
||||
setClusterLoading(false);
|
||||
});
|
||||
}, [sortInfo, checkedKafkaVersions, healthScoreRange, statusList, searchKeywords, isReload]);
|
||||
|
||||
const onSilderChange = (value: number[]) => {
|
||||
setHealthScoreRange(value);
|
||||
};
|
||||
|
||||
const onSelectChange = (type: string, value: string) => {
|
||||
setSortInfo({
|
||||
...sortInfo,
|
||||
[type]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const onStatusChange = (list: []) => {
|
||||
setStatusList(list);
|
||||
};
|
||||
|
||||
const onInputChange = (e: any) => {
|
||||
const { value } = e.target;
|
||||
setSearchKeywords(value.trim());
|
||||
};
|
||||
|
||||
const onChangeCheckGroup = (list: []) => {
|
||||
setCheckedKafkaVersions(list);
|
||||
};
|
||||
|
||||
const afterSubmitSuccessAccessClusters = () => {
|
||||
getPhyClusterState();
|
||||
setIsReload(!isReload);
|
||||
};
|
||||
|
||||
const renderEmpty = () => {
|
||||
return (
|
||||
<div className="empty-page">
|
||||
<div className="title">Kafka 多集群管理</div>
|
||||
<div className="img">
|
||||
<div className="img-card-1" />
|
||||
<div className="img-card-2" />
|
||||
<div className="img-card-3" />
|
||||
</div>
|
||||
<div>
|
||||
<Button className="header-filter-top-button" type="primary" onClick={() => setVisible(true)}>
|
||||
<span>
|
||||
<IconFont type="icon-jiahao" />
|
||||
<span className="text">接入集群</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoading = () => {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Spin spinning={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<div className="multi-cluster-page" id="scrollableDiv">
|
||||
<div className="multi-cluster-page-fixed">
|
||||
<div className="content-container">
|
||||
<div className="multi-cluster-header">
|
||||
<div className="cluster-header-card">
|
||||
<div className="cluster-header-card-bg-left"></div>
|
||||
<div className="cluster-header-card-bg-right"></div>
|
||||
<h5 className="header-card-title">
|
||||
Clusters<span className="chinese-text"> 总数</span>
|
||||
</h5>
|
||||
<div className="header-card-total">{stateInfo.total}</div>
|
||||
<div className="header-card-info">
|
||||
<div className="card-info-item card-info-item-live">
|
||||
<div>
|
||||
live
|
||||
<span className="info-item-value">
|
||||
<em>{stateInfo.liveCount}</em>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-info-item card-info-item-down">
|
||||
<div>
|
||||
down
|
||||
<span className="info-item-value">
|
||||
<em>{stateInfo.downCount}</em>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cluster-header-filter">
|
||||
<div className="header-filter-top">
|
||||
<div className="header-filter-top-input">
|
||||
<Input
|
||||
onPressEnter={onInputChange}
|
||||
onChange={(e) => (searchKeyword.current = e.target.value)}
|
||||
allowClear
|
||||
bordered={false}
|
||||
placeholder="请输入ClusterName进行搜索"
|
||||
suffix={<IconFont className="icon" type="icon-fangdajing" onClick={() => setSearchKeywords(searchKeyword.current)} />}
|
||||
/>
|
||||
</div>
|
||||
{global.hasPermission && global.hasPermission(ClustersPermissionMap.CLUSTER_ADD) ? (
|
||||
<>
|
||||
<div className="header-filter-top-divider"></div>
|
||||
<Button className="header-filter-top-button" type="primary" onClick={() => setVisible(true)}>
|
||||
<IconFont type="icon-jiahao" />
|
||||
<span className="text">接入集群</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="header-filter-bottom">
|
||||
<div className="header-filter-bottom-item header-filter-bottom-item-checkbox">
|
||||
<h3 className="header-filter-bottom-item-title">版本分布</h3>
|
||||
<div className="header-filter-bottom-item-content flex">
|
||||
{existKafkaVersion.length ? (
|
||||
<CustomCheckGroup kafkaVersions={existKafkaVersion} onChangeCheckGroup={onChangeCheckGroup} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-filter-bottom-item header-filter-bottom-item-slider">
|
||||
<h3 className="header-filter-bottom-item-title title-right">健康分</h3>
|
||||
<div className="header-filter-bottom-item-content">
|
||||
<Slider range step={20} defaultValue={[0, 100]} marks={healthSorceList} onAfterChange={onSilderChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="multi-cluster-filter">
|
||||
<div className="multi-cluster-filter-select">
|
||||
<Select
|
||||
onChange={(value) => onSelectChange('sortField', value)}
|
||||
defaultValue="HealthScore"
|
||||
style={{ width: 170, marginRight: 12 }}
|
||||
>
|
||||
{sortFieldList.map((item) => (
|
||||
<Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select onChange={(value) => onSelectChange('sortType', value)} defaultValue="asc" style={{ width: 170 }}>
|
||||
{sortTypes.map((item) => (
|
||||
<Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="multi-cluster-filter-checkbox">
|
||||
<CheckboxGroup options={statusFilters} value={statusList} onChange={onStatusChange} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="test-modal-23"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="multi-cluster-page-dashboard">
|
||||
<Spin spinning={clusterLoading}>{renderList}</Spin>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderList = useMemo(() => {
|
||||
return <ListScroll list={list} pagination={pagination} loadMoreData={getPhyClustersDashbord} getPhyClusterState={getPhyClusterState} />;
|
||||
}, [list, pagination]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TourGuide guide={MultiPageSteps} run={run} />
|
||||
{pageLoading ? renderLoading() : stateInfo.total ? renderContent() : renderEmpty()}
|
||||
{pageLoading ? (
|
||||
<LoadingState />
|
||||
) : !stateInfo?.total ? (
|
||||
<DefaultPage setVisible={setAccessClusterVisible} />
|
||||
) : (
|
||||
<>
|
||||
<div className="multi-cluster-page" id="scrollableDiv">
|
||||
<div className="multi-cluster-page-fixed">
|
||||
<div className="content-container">
|
||||
<div className="multi-cluster-header">
|
||||
<div className="cluster-header-card">
|
||||
<div className="cluster-header-card-bg-left"></div>
|
||||
<div className="cluster-header-card-bg-right"></div>
|
||||
<h5 className="header-card-title">
|
||||
Clusters<span className="chinese-text"> 总数</span>
|
||||
</h5>
|
||||
<div className="header-card-total">{stateInfo.total}</div>
|
||||
<div className="header-card-info">
|
||||
<div className="card-info-item card-info-item-live">
|
||||
<div>
|
||||
live
|
||||
<span className="info-item-value">
|
||||
<em>{stateInfo.liveCount}</em>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-info-item card-info-item-down">
|
||||
<div>
|
||||
down
|
||||
<span className="info-item-value">
|
||||
<em>{stateInfo.downCount}</em>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cluster-header-filter">
|
||||
<div className="header-filter-top">
|
||||
<div className="header-filter-top-input">
|
||||
<Input
|
||||
onPressEnter={searchParamsChangeFunc.onInputChange}
|
||||
onChange={(e) => (searchKeyword.current = e.target.value)}
|
||||
allowClear
|
||||
bordered={false}
|
||||
placeholder="请输入ClusterName进行搜索"
|
||||
suffix={<IconFont className="icon" type="icon-fangdajing" onClick={searchParamsChangeFunc.onInputChange} />}
|
||||
/>
|
||||
</div>
|
||||
{global.hasPermission && global.hasPermission(ClustersPermissionMap.CLUSTER_ADD) ? (
|
||||
<>
|
||||
<div className="header-filter-top-divider"></div>
|
||||
<Button className="header-filter-top-button" type="primary" onClick={() => showAccessCluster()}>
|
||||
<IconFont type="icon-jiahao" />
|
||||
<span className="text">接入集群</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="header-filter-bottom">
|
||||
<div className="header-filter-bottom-item header-filter-bottom-item-checkbox">
|
||||
<h3 className="header-filter-bottom-item-title">版本分布</h3>
|
||||
<div className="header-filter-bottom-item-content flex">
|
||||
{existKafkaVersion.length ? (
|
||||
<CustomCheckGroup
|
||||
kafkaVersions={existKafkaVersion}
|
||||
onChangeCheckGroup={searchParamsChangeFunc.onChangeCheckGroup}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-filter-bottom-item header-filter-bottom-item-slider">
|
||||
<h3 className="header-filter-bottom-item-title title-right">健康分</h3>
|
||||
<div className="header-filter-bottom-item-content">
|
||||
<Slider
|
||||
range
|
||||
step={20}
|
||||
defaultValue={[0, 100]}
|
||||
marks={healthSorceList}
|
||||
onAfterChange={searchParamsChangeFunc.onSilderChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="multi-cluster-filter">
|
||||
<div className="multi-cluster-filter-select">
|
||||
<Select
|
||||
onChange={(value) => searchParamsChangeFunc.onSortInfoChange('sortField', value)}
|
||||
defaultValue="HealthScore"
|
||||
style={{ width: 170, marginRight: 12 }}
|
||||
>
|
||||
{sortFieldList.map((item) => (
|
||||
<Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
onChange={(value) => searchParamsChangeFunc.onSortInfoChange('sortType', value)}
|
||||
defaultValue="asc"
|
||||
style={{ width: 170 }}
|
||||
>
|
||||
{sortTypes.map((item) => (
|
||||
<Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="multi-cluster-filter-checkbox">
|
||||
<CheckboxGroup
|
||||
options={statusFilters}
|
||||
value={searchParams.clusterStatus}
|
||||
onChange={searchParamsChangeFunc.onClusterStatusChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="multi-cluster-page-dashboard">
|
||||
<ClusterList
|
||||
searchParams={searchParams}
|
||||
showAccessCluster={showAccessCluster}
|
||||
getPhyClusterState={getPhyClusterState}
|
||||
getExistKafkaVersion={getExistKafkaVersion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 引导页 */}
|
||||
<TourGuide guide={MultiPageSteps} run={true} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<AccessClusters
|
||||
visible={visible}
|
||||
setVisible={setVisible}
|
||||
clusterInfo={curClusterInfo}
|
||||
kafkaVersion={kafkaVersions}
|
||||
afterSubmitSuccess={afterSubmitSuccessAccessClusters}
|
||||
visible={accessClusterVisible}
|
||||
setVisible={setAccessClusterVisible}
|
||||
afterSubmitSuccess={afterAccessCluster}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user