初始化3.0.0版本

This commit is contained in:
zengqiao
2022-08-18 17:04:05 +08:00
parent 462303fca0
commit 51832385b1
2446 changed files with 93177 additions and 127211 deletions

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { timeFormat } from '../../constants/common';
import moment from 'moment';
import { getSizeAndUnit } from '../../constants/common';
export const getControllerChangeLogListColumns = (arg?: any) => {
const columns = [
{
title: 'Change Time',
dataIndex: 'timestamp',
key: 'timestamp',
render: (t: number) => (t ? moment(t).format(timeFormat) : '-'),
},
{
title: 'Broker ID',
dataIndex: 'brokerId',
key: 'brokerId',
// eslint-disable-next-line react/display-name
render: (t: number, r: any) => {
return t === -1 ? (
'-'
) : (
<a
onClick={() => {
window.location.hash = `brokerId=${t || ''}&host=${r.brokerHost || ''}`;
}}
>
{t}
</a>
);
},
},
{
title: 'Broker Host',
dataIndex: 'brokerHost',
key: 'brokerHost',
},
];
return columns;
};
export const defaultPagination = {
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
};

View File

@@ -0,0 +1,5 @@
.controllerList{
.d-table-box-header{
padding: 0 0 12px 0 ;
}
}

View File

@@ -0,0 +1,125 @@
import React, { useState, useEffect } from 'react';
import { useParams, useHistory, useLocation } from 'react-router-dom';
import { ProTable, Utils, AppContainer } from 'knowdesign';
import API from '../../api';
import { getControllerChangeLogListColumns, defaultPagination } from './config';
import BrokerDetail from '../BrokerDetail';
import BrokerHealthCheck from '@src/components/CardBar/BrokerHealthCheck';
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
import './index.less';
const { request } = Utils;
const ControllerChangeLogList: React.FC = (props: any) => {
const [global] = AppContainer.useGlobalValue();
const urlParams = useParams<any>(); // 获取地址栏参数
const history = useHistory();
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [searchKeywords, setSearchKeywords] = useState('');
const [filteredInfo, setFilteredInfo] = useState(null);
const [pagination, setPagination] = useState<any>(defaultPagination);
const [clusterName, setClusterName] = useState<any>(null);
// const [visible, setVisible] = useState(false);
// const [record, setRecord] = useState(null); // 获取当前点击行的数据;
// 默认排序
const defaultSorter = {
sortField: 'changeTime',
sortType: 'desc',
};
// 请求接口获取数据
const genData = ({ pageNo, pageSize, filters = null, sorter = null }: any) => {
if (urlParams?.clusterId === undefined) return;
filters = filters || filteredInfo;
setLoading(true);
// const params = dealTableRequestParams({ searchKeywords, pageNo, pageSize, sorter, filters });
const params = {
searchKeywords: searchKeywords.slice(0, 128),
pageNo,
pageSize,
};
request(API.getChangeLogList(urlParams?.clusterId), { params: { ...params, ...defaultSorter } })
.then((res: any) => {
setPagination({
current: res.pagination?.pageNo,
pageSize: res.pagination?.pageSize,
total: res.pagination?.total,
});
setData(res?.bizData || []);
setLoading(false);
})
.catch((err) => {
// mock
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
setFilteredInfo(filters);
genData({ pageNo: pagination.current, pageSize: pagination.pageSize, filters, sorter });
};
const getSearchKeywords = (value: string) => {
setSearchKeywords(value);
};
useEffect(() => {
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, [searchKeywords]);
return (
<div className="controllerList">
<div className="breadcrumb" style={{ marginBottom: '10px' }}>
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Broker', aHref: `/cluster/${urlParams?.clusterId}/broker` },
{ label: 'Controller', aHref: `` },
]}
/>
</div>
<div style={{ margin: '12px 0' }}>
<BrokerHealthCheck />
</div>
<div className="clustom-table-content">
<ProTable
showQueryForm={false}
tableProps={{
showHeader: true,
rowKey: 'path',
loading: loading,
columns: getControllerChangeLogListColumns(),
dataSource: data,
paginationProps: { ...pagination },
tableHeaderSearchInput: {
// 搜索配置
submit: getSearchKeywords,
searchInputType: 'search',
searchAttr: {
placeholder: '请输入Broker Host',
style: {
width: '248px',
},
},
},
attrs: {
onChange: onTableChange,
bordered: false,
scroll: { y: 'calc(100vh - 400px)' },
},
}}
/>
</div>
{<BrokerDetail />}
</div>
);
};
export default ControllerChangeLogList;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { MetricType } from '@src/api';
import BrokerHealthCheck from '@src/components/CardBar/BrokerHealthCheck';
import DashboardDragChart from '@src/components/DashboardDragChart';
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
import { AppContainer } from 'knowdesign';
const BrokerDashboard = (): JSX.Element => {
const [global] = AppContainer.useGlobalValue();
return (
<>
<div className="breadcrumb" style={{ marginBottom: '10px' }}>
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Broker', aHref: `` },
]}
/>
</div>
<BrokerHealthCheck />
<DashboardDragChart type={MetricType.Broker} />
</>
);
};
export default BrokerDashboard;

View File

@@ -0,0 +1,175 @@
import React, { useState, useEffect } from 'react';
import { AppContainer, Checkbox, ProTable, Utils } from 'knowdesign';
import { useParams } from 'react-router-dom';
import Api from '@src/api';
import { getConfigurationColmns } from './config';
import { ConfigurationEdit } from './ConfigurationEdit';
import { hashDataParse } from 'src/constants/common';
import { ClustersPermissionMap } from '../CommonConfig';
const { request } = Utils;
const BrokerConfiguration = (props: any) => {
const { hashData } = props;
const urlParams = useParams<any>(); // 获取地址栏参数
const [global] = AppContainer.useGlobalValue();
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
// const [filterType, setFilterType] = useState<number>(0); // 多选框的筛选结果 filterType
const [checkedBoxList, setCheckedBoxList] = useState<string[]>([]); // 多选框的选中的列表
const [pagination, setPagination] = useState<any>({
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
showTotal: (total: number) => `${total} 条目`,
});
const [editVisible, setEditVisible] = useState(false);
const [record, setRecord] = useState(null); // 获取当前点击行的数据;
const [readOnlyVisible, setReadOnlyVisible] = useState(null);
const [readOnlyRecord, setReadOnlyRecord] = useState(null);
// 请求接口获取数据
const genData = async ({ pageNo, pageSize }: any) => {
if (urlParams?.clusterId === undefined || hashData?.brokerId === undefined) return;
setLoading(true);
const params = {
searchKeywords: props.searchKeywords ? props.searchKeywords.slice(0, 128) : undefined,
pageNo,
pageSize,
preciseFilterDTOList:
checkedBoxList.length > 0
? checkedBoxList.map((item) => {
return {
fieldName: item,
fieldValueList: [item === 'readOnly' ? false : true],
include: true,
};
})
: undefined,
};
request(Api.getBrokerConfigs(hashData?.brokerId, urlParams?.clusterId), { data: params, method: 'POST' })
.then((res: any) => {
setPagination({
current: res.pagination?.pageNo,
pageSize: res.pagination?.pageSize,
total: res.pagination?.total,
});
setData(res?.bizData || []);
})
.finally(() => {
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
// setPagination(pagination);
// const asc = sorter?.order && sorter?.order === 'ascend' ? true : false;
// const sortColumn = sorter.field && toLine(sorter.field);
genData({ pageNo: pagination.current, pageSize: pagination.pageSize });
};
// 多选配置
const checkedBoxOptions = [
{ label: 'Hide read-only', value: 'readOnly' },
{ label: 'Show Overrides Only', value: 'override' },
];
const checkedBoxChange = (e: any) => {
// 通过checked转换filterType
// const newfilterType =
// e.includes('readOnly') && e.includes('override')
// ? 0
// : e.includes('readOnly') && !e.includes('override')
// ? 1
// : !e.includes('readOnly') && e.includes('override')
// ? 2
// : 3;
// setFilterType(newfilterType);
setCheckedBoxList(e);
// 调用接口
};
const setEditOp = (record: any) => {
setEditVisible(true);
setRecord(record);
};
useEffect(() => {
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, [props.searchKeywords, checkedBoxList, hashData?.brokerId]);
return (
<>
<div className={'detail-header-cases'} style={{ padding: '0 0 12px' }}>
<Checkbox.Group options={checkedBoxOptions} value={checkedBoxList} onChange={checkedBoxChange} />
<div className={'detail-header-cases-right'}>
<div>
<div className="icon normal"></div>
<div></div>
</div>
<div>
<div className="icon differ"></div>
<div></div>
</div>
<div>
<div className="icon unique"></div>
<div>broker独有</div>
</div>
</div>
</div>
<ProTable
showQueryForm={false}
tableProps={{
showHeader: false,
rowKey: 'path',
loading: loading,
columns: getConfigurationColmns({
setEditOp,
readOnlyRecord,
readOnlyVisible,
allowEdit: global.hasPermission && global.hasPermission(ClustersPermissionMap.BROKER_CHANGE_CONFIG),
}),
dataSource: data,
paginationProps: { ...pagination },
attrs: {
// className: 'frameless-table', // 纯无边框表格类名
// bordered: true, // 表格边框
onChange: onTableChange,
bordered: false,
onRow: (record: any) => {
if (!!record?.readOnly) {
return {
onMouseEnter: () => {
setReadOnlyVisible(true);
setReadOnlyRecord(record);
},
onMouseLeave: () => {
setReadOnlyVisible(false);
setReadOnlyRecord(null);
},
};
}
return {};
},
},
}}
/>
<ConfigurationEdit
record={record}
hashData={hashData}
visible={editVisible}
setVisible={setEditVisible}
genData={genData}
pagination={pagination}
/>
</>
);
};
export default BrokerConfiguration;

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Drawer, Form, Input, Space, Button, Checkbox, Utils, Row, Col, IconFont, Divider, message } from 'knowdesign';
import { useParams } from 'react-router-dom';
import Api from '@src/api';
export const ConfigurationEdit = (props: any) => {
const urlParams = useParams<any>();
const [form] = Form.useForm();
const onClose = () => {
props.setVisible(false);
};
const onOk = () => {
form.validateFields().then((res: any) => {
const data = {
applyAll: res.applyAll,
brokerId: Number(props.hashData?.brokerId),
changedProps: {
[props.record?.name]: res.newValue,
},
clusterId: Number(urlParams.clusterId),
};
Utils.put(Api.getBrokerEditConfig(), data)
.then((res: any) => {
message.success('编辑配置成功');
props.setVisible(false);
props.genData({ pageNo: props.pagination.current, pageSize: props.pagination.pageSize });
})
.catch((err: any) => {
console.log(err, 'err');
});
});
};
return (
<Drawer
title={
<Space size={0}>
<Button className="drawer-title-left-button" type="text" size="small" icon={<IconFont type="icon-fanhui1" />} onClick={onClose} />
<Divider type="vertical" />
<span style={{ paddingLeft: '5px' }}></span>
</Space>
}
width={580}
visible={props.visible}
onClose={() => props.setVisible(false)}
maskClosable={false}
extra={
<Space>
<Button size="small" onClick={onClose}>
</Button>
<Button size="small" type="primary" onClick={onOk}>
</Button>
<Divider type="vertical" />
</Space>
}
>
<Row gutter={[12, 12]} className="desc-row">
<Col span={3} className="label-col">
:
</Col>
<Col span={21} className="value-col">
{props.record?.name || '-'}
</Col>
<Col span={3} className="label-col">
:
</Col>
<Col span={21} className="value-col">
{props.record?.documentation || '-'}
</Col>
</Row>
<Form form={form} layout="vertical" initialValues={props.record}>
<Form.Item name="defaultValue" label="Kafka默认配置">
<Input disabled />
</Form.Item>
<Form.Item name="value" label="当前配置">
<Input disabled />
</Form.Item>
<Form.Item name="newValue" label="新配置" rules={[{ required: true, message: '请输入新的配置值!!!' }]}>
<Input />
</Form.Item>
<Form.Item name="applyAll" valuePropName="checked">
<Checkbox defaultChecked={false}>Broker</Checkbox>
</Form.Item>
</Form>
</Drawer>
);
};

View File

@@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { ProTable, Utils } from 'knowdesign';
import { useParams } from 'react-router-dom';
import Api from '@src/api';
import { getDataLogsColmns } from './config';
const { request } = Utils;
const BrokerDataLogs = (props: any) => {
const { hashData } = props;
const urlParams = useParams<any>(); // 获取地址栏参数
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [pagination, setPagination] = useState<any>({
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
showTotal: (total: number) => `${total} 条目`,
// locale: {
// items_per_page: '条',
// },
// selectComponentClass: CustomSelect,
});
// 请求接口获取数据
const genData = async ({ pageNo, pageSize }: any) => {
if (urlParams?.clusterId === undefined || hashData?.brokerId === undefined) return;
setData([]);
setLoading(true);
const params = {
searchKeywords: props.searchKeywords ? props.searchKeywords.slice(0, 128) : '',
pageNo,
pageSize,
};
request(Api.getBrokerDataLogs(hashData?.brokerId, urlParams?.clusterId), { params })
.then((res: any) => {
setPagination({
current: res.pagination?.pageNo,
pageSize: res.pagination?.pageSize,
total: res.pagination?.total,
});
setData(res?.bizData || []);
setLoading(false);
})
.catch((err) => {
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
// setPagination(pagination);
// const asc = sorter?.order && sorter?.order === 'ascend' ? true : false;
// const sortColumn = sorter.field && toLine(sorter.field);
genData({ pageNo: pagination.current, pageSize: pagination.pageSize });
};
useEffect(() => {
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, [props.searchKeywords, hashData?.brokerId]);
return (
<ProTable
showQueryForm={false}
tableProps={{
showHeader: false,
rowKey: 'path',
loading: loading,
columns: getDataLogsColmns(),
dataSource: data,
paginationProps: { ...pagination },
attrs: {
// className: 'frameless-table', // 纯无边框表格类名
// bordered: true, // 表格边框
onChange: onTableChange,
bordered: false,
},
}}
/>
);
};
export default BrokerDataLogs;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { Utils, IconFont, Tooltip } from 'knowdesign';
export const getConfigurationColmns = (arg: any) => {
const columns: any = [
{
title: '',
dataIndex: 'readOnly',
key: 'readOnly',
align: 'right',
// eslint-disable-next-line react/display-name
render: (t: string, r: any) => {
return t ? (
<Tooltip title="该配置无法修改" visible={r.name === arg?.readOnlyRecord?.name && arg?.readOnlyVisible}>
<IconFont style={{ color: '#556EE6', fontSize: '16px' }} type="icon-suoding" />
</Tooltip>
) : null;
},
width: 56,
className: 'table-suoding',
},
{
title: '配置名',
dataIndex: 'name',
key: 'name',
width: 250,
},
{
title: '描述',
dataIndex: 'documentation',
key: 'documentation',
width: 300,
lineClampTwo: true,
needTooltip: true,
},
{
title: '配置值',
dataIndex: 'value',
key: 'value',
width: 250,
lineClampTwo: true,
},
{
title: '状态',
dataIndex: 'state',
key: 'state',
// eslint-disable-next-line react/display-name
render: (t: string, r: any) => {
return r.differentiated ? (
<div className="differ"></div>
) : r.exclusive ? (
<div className="unique"></div>
) : (
<div className="normal"></div>
);
},
},
];
if (arg.allowEdit) {
columns.push({
title: '操作',
dataIndex: 'options',
key: 'options',
// eslint-disable-next-line react/display-name
render: (_t: any, r: any) => {
return !r.readOnly ? <a onClick={() => arg.setEditOp(r)}></a> : '-';
},
});
}
return columns;
};
export const getDataLogsColmns = () => {
const columns = [
{
title: 'Folder',
dataIndex: 'dir',
key: 'dir',
},
{
title: 'Topic',
dataIndex: 'topicName',
key: 'topicName',
},
{
title: 'Partition',
dataIndex: 'partitionId',
key: 'partitionId',
},
{
title: 'Offset Lag',
dataIndex: 'offsetLag',
key: 'offsetLag',
},
{
title: 'SizeMB',
dataIndex: 'logSizeUnitB',
key: 'logSizeUnitB',
render: (t: number, r: any) => {
return t || t === 0 ? Utils.transBToMB(t) : '-';
},
},
];
return columns;
};

View File

@@ -0,0 +1,87 @@
.broker-detail-drawer {
.card-bar-container {
background: rgba(86, 110, 230, 0.04) !important;
.card-bar-colunms {
background-color: rgba(86, 110, 230, 0);
}
}
.detail-header-cases {
display: flex;
align-items: center;
justify-content: space-between;
&-right {
display: flex;
&>div {
display: flex;
align-items: center;
font-size: 12px;
color: #74788D;
margin-right: 12px;
line-height: 18px;
.icon {
margin-right: 5px;
}
}
}
}
.normal {
width: 10px;
height: 10px;
background: #D5DCFF;
border: 0.5px solid #BBC3EB;
border-radius: 2.75px;
}
.differ {
width: 10px;
height: 10px;
background: #FEE4C7;
border: 0.5px solid #F9D1A5;
border-radius: 2.75px;
}
.unique {
width: 10px;
height: 10px;
background: #FEC7C7;
border: 0.5px solid #F9ADA5;
border-radius: 2.75px;
}
.table-suoding {
font-size: 18px !important;
color: #556EE6 !important;
padding: 0 !important;
}
}
.desc-row {
margin-bottom: 24px;
.label-col,
.value-col {
font-size: 13px;
line-height: 18px;
}
.label-col {
color: #74788d;
text-align: right;
}
.value-col {
color: #495057;
}
}
.init-drawer{
.dcloud-drawer-close{
position: inherit !important;
}
}

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import { useParams, useHistory, useLocation } from 'react-router-dom';
import { Tabs, Drawer, Tag, Utils, AppContainer, SearchInput, Empty } from 'knowdesign';
import Api from '@src/api';
import { hashDataParse } from '../../constants/common';
import DataLogs from './DataLogs';
import Configuration from './Configuration';
import BrokerDetailHealthCheck from '@src/components/CardBar/BrokerDetailHealthCheck';
import './index.less';
import { ControlStatusMap } from '../CommonRoute';
const { TabPane } = Tabs;
const OperationsSlot: any = {
// eslint-disable-next-line react/display-name
['Configuration']: (arg: any) => {
return (
<SearchInput
onSearch={arg.setSearchKeywords}
attrs={{
value: arg.searchValue,
onChange: arg.setSearchValue,
placeholder: '请输入配置名',
size: 'small',
style: { width: '210px', marginRight: '2px' },
maxLength: 128,
}}
/>
);
},
// eslint-disable-next-line react/display-name
['DataLogs']: (arg: any) => {
return (
<SearchInput
onSearch={arg.setSearchKeywords}
attrs={{
value: arg.searchValue,
onChange: arg.setSearchValue,
placeholder: '请输入TopicName',
size: 'small',
style: { width: '210px', marginRight: '2px' },
maxLength: 128,
}}
/>
);
},
};
const BrokerDetail = (props: any) => {
const [global] = AppContainer.useGlobalValue();
const urlParams = useParams<{ clusterId: string; brokerId: string }>();
const urlLocation = useLocation<any>();
const history = useHistory();
const [positionType, setPositionType] = useState<string>('Configuration');
const [searchKeywords, setSearchKeywords] = useState<string>('');
const [searchValue, setSearchValue] = useState<string>('');
const [visible, setVisible] = useState(false);
const [hashData, setHashData] = useState<any>({});
const callback = (key: any) => {
setSearchValue('');
setSearchKeywords('');
setPositionType(key);
};
const onClose = () => {
setVisible(false);
setSearchValue('');
setSearchKeywords('');
setPositionType('Configuration');
// clean hash
history.push(urlLocation.pathname);
};
useEffect(() => {
global?.clusterInfo?.id && hashDataParse(urlLocation.hash).brokerId
? Utils.request(Api.getBrokerMetadata(hashDataParse(urlLocation.hash).brokerId, global?.clusterInfo?.id), {
init: {
errorNoTips: true,
},
})
.then((brokerData: any) => {
if (brokerData?.exist && brokerData?.alive && hashDataParse(urlLocation.hash).host === brokerData.host) {
setHashData(brokerData);
setVisible(true);
} else {
history.replace(urlLocation.pathname);
setVisible(false);
}
})
.catch((err) => {
history.replace(urlLocation.pathname);
setVisible(false);
})
: setVisible(false);
}, [hashDataParse(urlLocation.hash).brokerId, global?.clusterInfo, urlLocation]);
return (
<Drawer
push={false}
title={
<span>
<span style={{ fontSize: '18px', fontFamily: 'PingFangSC-Semibold', color: '#495057' }}>Broker {hashData?.brokerId}</span>
{hashData?.host && (
<Tag
style={{
fontSize: '10px',
color: '#495057',
textAlign: 'center',
background: '#ECECF6',
borderRadius: '4px',
marginLeft: '10px',
padding: '1px 10px',
}}
>
{hashData?.host}
</Tag>
)}
{global?.clusterInfo?.name && (
<Tag
style={{
fontSize: '10px',
color: '#495057',
textAlign: 'center',
background: '#ECECF6',
borderRadius: '4px',
marginLeft: '10px',
padding: '1px 10px',
}}
>
{global?.clusterInfo?.name}
</Tag>
)}
</span>
}
width={1080}
placement="right"
onClose={onClose}
visible={visible}
className="broker-detail-drawer"
destroyOnClose
maskClosable={false}
>
<BrokerDetailHealthCheck record={{ brokerId: hashData?.brokerId }} />
{hashData && positionType && (
<Tabs
className={'custom_tabs_class'}
defaultActiveKey="Configuration"
// activeKey={positionType}
onChange={callback}
tabBarExtraContent={
OperationsSlot[positionType] &&
global.isShowControl &&
global.isShowControl(
positionType === 'Configuration' ? ControlStatusMap.BROKER_DETAIL_CONFIG : ControlStatusMap.BROKER_DETAIL_DATALOGS
) &&
OperationsSlot[positionType]({ setSearchKeywords, setSearchValue, searchValue, positionType })
}
destroyInactiveTabPane
>
<TabPane tab="Configuration" key="Configuration">
{global.isShowControl && global.isShowControl(ControlStatusMap.BROKER_DETAIL_CONFIG) ? (
<Configuration searchKeywords={searchKeywords} positionType={positionType} hashData={hashData} />
) : (
<Empty description="当前版本过低,不支持该功能!" />
)}
</TabPane>
<TabPane tab="DataLogs" key="DataLogs">
{global.isShowControl && global.isShowControl(ControlStatusMap.BROKER_DETAIL_DATALOGS) ? (
<DataLogs searchKeywords={searchKeywords} positionType={positionType} hashData={hashData} />
) : (
<Empty description="当前版本过低,不支持该功能!" />
)}
</TabPane>
</Tabs>
)}
</Drawer>
);
};
export default BrokerDetail;

View File

@@ -0,0 +1,158 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import React from 'react';
import { timeFormat, getSizeAndUnit } from '../../constants/common';
import moment from 'moment';
import { Tag, Tooltip } from 'knowdesign';
export const getBrokerListColumns = (arg?: any) => {
const columns = [
{
title: 'Broker ID',
dataIndex: 'brokerId',
key: 'brokerId',
sorter: true,
// eslint-disable-next-line react/display-name
render: (t: number, r: any) => {
return r?.alive ? (
<a
onClick={() => {
window.location.hash = `brokerId=${t || t === 0 ? t : ''}&host=${r.host || ''}`;
}}
>
{t}
</a>
) : (
<span>{t}</span>
);
},
fixed: 'left',
width: 120,
},
// {
// title: 'Rack',
// dataIndex: 'rack',
// key: 'rack',
// },
{
title: 'Broker Host',
dataIndex: 'host',
key: 'host',
width: 180,
render: (t: any, r: any) => {
return (
<span>
{t}
{r?.alive ? <Tag className="tag-success">Live</Tag> : <Tag className="tag-error">Down</Tag>}
</span>
);
},
},
{
title: ' JMX Port',
dataIndex: 'jmxPort',
key: 'jmxPort',
width: 100,
},
{
title: 'Partitions',
dataIndex: 'Partitions',
key: 'Partitions',
width: 100,
},
{
title: 'Partitions Skew',
dataIndex: 'PartitionsSkew',
key: 'PartitionsSkew',
width: 140,
render: (t: number) => {
return t || t === 0 ? (t * 100).toFixed(2) + '%' : '-';
},
},
{
title: 'Leaders Partition',
dataIndex: 'Leaders',
key: 'Leaders',
width: 140,
},
{
title: 'Leaders Skew',
dataIndex: 'LeadersSkew',
key: 'LeadersSkew',
width: 120,
render: (t: number) => {
return t || t === 0 ? (t * 100).toFixed(2) + '%' : '-';
},
},
{
title: 'Message Size',
dataIndex: 'LogSize',
key: 'LogSize',
sorter: true,
width: 150,
render: (t: number) => {
return getSizeAndUnit(t, 'B').valueWithUnit;
},
},
// {
// title: 'Status',
// dataIndex: 'alive',
// key: 'alive',
// width: 100,
// // eslint-disable-next-line react/display-name
// render: (t: boolean) => {
// return <span className={t ? 'success' : 'error'}>{t ? 'Live' : 'Down'}</span>;
// },
// },
{
title: 'Roles',
dataIndex: 'kafkaRoleList',
key: 'kafkaRoleList',
width: 100,
// eslint-disable-next-line react/display-name
render: (t: any[]) => {
if (t) {
const newContant = t
?.filter((item: string) => item.slice(0, 3) == '__c' || item.slice(0, 3) == '__t')
?.map((subItem: any) => {
if (subItem.slice(0, 3) == '__c') {
return 'GC';
} else if (subItem.slice(0, 3) == '__t') {
return 'TC';
}
return null;
});
const newTooltip = newContant.map((item: string) => {
if (item === 'GC') {
return 'GroupCoordinator';
} else if (item === 'TC') {
return 'TransactionCoordinator';
}
return null;
});
return (
<Tooltip title={newTooltip.join()}>
<span>{newContant.length > 0 ? newContant.join('、') : '-'}</span>
</Tooltip>
);
}
return '-';
},
},
{
title: 'Start Time',
dataIndex: 'startTimeUnitMs',
key: 'startTimeUnitMs',
width: 200,
render: (t: number) => (t ? moment(t).format(timeFormat) : '-'),
},
];
return columns;
};
export const defaultPagination = {
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
};

View File

@@ -0,0 +1,18 @@
.brokerList{
.d-table-box-header{
padding: 0 0 12px 0;
}
.tag-success,.tag-error{
padding: 2px 8px;
border-radius: 4px;
margin-left: 4px;
}
.tag-success{
background: #F1FAF2;
color: #73d380;
}
.tag-error{
background: #FFF0EF;
color: #ff7066;
}
}

View File

@@ -0,0 +1,138 @@
import React, { useState, useEffect, memo } from 'react';
import { useParams, useHistory, useLocation } from 'react-router-dom';
import { ProTable, Drawer, Utils, AppContainer } from 'knowdesign';
import API from '../../api';
import { getBrokerListColumns, defaultPagination } from './config';
import { dealTableRequestParams } from '../../constants/common';
import BrokerDetail from '../BrokerDetail';
import CardBar from '@src/components/CardBar';
import BrokerHealthCheck from '@src/components/CardBar/BrokerHealthCheck';
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
import './index.less';
const { request } = Utils;
const BrokerList: React.FC = (props: any) => {
const [global] = AppContainer.useGlobalValue();
const urlParams = useParams<any>(); // 获取地址栏参数
const history = useHistory();
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [searchKeywords, setSearchKeywords] = useState('');
// const [filteredInfo, setFilteredInfo] = useState(null);
const [pagination, setPagination] = useState<any>(defaultPagination);
const [clusterName, setClusterName] = useState<any>(null);
// 默认排序
const defaultSorter = {
sortField: 'brokerId',
sortType: 'asc',
};
// 请求接口获取数据
const genData = async ({ pageNo, pageSize, filters, sorter }: any) => {
if (urlParams?.clusterId === undefined) return;
// filters = filters || filteredInfo;
setLoading(true);
// const params = dealTableRequestParams({ searchKeywords, pageNo, pageSize });
const params = {
searchKeywords: searchKeywords.slice(0, 128),
pageNo,
pageSize,
latestMetricNames: ['PartitionsSkew', 'Leaders', 'LeadersSkew', 'LogSize'],
sortField: sorter?.field || 'brokerId',
sortType: sorter?.order ? sorter.order.substring(0, sorter.order.indexOf('end')) : 'asc',
};
// API.getBrokersList(urlParams?.clusterId)
request(API.getBrokersList(urlParams?.clusterId), { method: 'POST', data: params })
.then((res: any) => {
setPagination({
current: res.pagination?.pageNo,
pageSize: res.pagination?.pageSize,
total: res.pagination?.total,
});
const newData =
res?.bizData.map((item: any) => {
return {
...item,
...item?.latestMetrics?.metrics,
};
}) || [];
setData(newData);
setLoading(false);
})
.catch((err) => {
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
// setFilteredInfo(filters);
genData({ pageNo: pagination.current, pageSize: pagination.pageSize, filters, sorter });
};
const getSearchKeywords = (value: string) => {
setSearchKeywords(value);
};
useEffect(() => {
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, [searchKeywords]);
return (
<div key="brokerList" className="brokerList">
<div className="breadcrumb" style={{ marginBottom: '10px' }}>
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Broker', aHref: `/cluster/${urlParams?.clusterId}/broker` },
{ label: 'Brokers', aHref: `` },
]}
/>
</div>
<div style={{ margin: '12px 0' }}>
<BrokerHealthCheck />
</div>
<div className="clustom-table-content">
<ProTable
key="brokerTable"
showQueryForm={false}
tableProps={{
showHeader: true,
rowKey: 'broker_list',
loading: loading,
columns: getBrokerListColumns(),
dataSource: data,
paginationProps: { ...pagination },
tableHeaderSearchInput: {
// 搜索配置
submit: getSearchKeywords,
searchInputType: 'search',
searchAttr: {
placeholder: '请输入Broker Host',
maxLength: 128,
style: {
width: '248px',
borderRiadus: '8px',
},
},
},
attrs: {
onChange: onTableChange,
scroll: { x: 'max-content', y: 'calc(100vh - 400px)' },
bordered: false,
},
}}
/>
</div>
{<BrokerDetail />}
</div>
);
};
export default BrokerList;

View File

@@ -0,0 +1,119 @@
import React, { useLayoutEffect } from 'react';
import { Utils, AppContainer } from 'knowdesign';
import api, { MetricType } from '../api';
import { goLogin } from '@src/constants/axiosConfig';
// 权限对应表
export enum ClustersPermissionMap {
CLUSTERS_MANAGE = '多集群管理',
// Cluster
CLUSTER_ADD = '接入集群',
CLUSTER_DEL = '删除集群',
CLUSTER_CHANGE_HEALTHY = 'Cluster-修改健康规则',
CLUSTER_CHANGE_INFO = 'Cluster-修改集群信息',
// Broker
BROKER_CHANGE_CONFIG = 'Broker-修改Broker配置',
// Topic
TOPIC_CHANGE_CONFIG = 'Topic-修改Topic配置',
TOPIC_RESET_OFFSET = 'Topic-重置Offset',
TOPIC_DEL = 'Topic-删除Topic',
TOPIC_EXPOND = 'Topic-扩分区',
TOPIC_ADD = 'Topic-新增Topic',
// Consumers
CONSUMERS_RESET_OFFSET = 'Consumers-重置Offset',
// Test
TEST_CONSUMER = 'Test-Consumer',
TEST_PRODUCER = 'Test-Producer',
}
export interface PermissionNode {
id: number;
permissionName: ClustersPermissionMap | null;
parentId: number | null;
has: boolean;
leaf: boolean;
childList: PermissionNode[];
}
export interface MetricsDefine {
[metricName: string]: {
category: string;
type: number;
name: string;
desc: string;
unit: string;
support: boolean | null;
minVersion: number;
maxVersion: number;
};
}
const CommonConfig = () => {
const [global, setGlobal] = AppContainer.useGlobalValue();
const userInfo = localStorage.getItem('userInfo');
// 获取权限树
const getPermissions = (userId: number) => {
const getUserInfo = Utils.request(api.getUserInfo(userId), {
retryTimes: 2,
});
const getPermissionTree = Utils.request(api.getPermissionTree, {
retryTimes: 2,
});
Promise.all([getPermissionTree, getUserInfo]).then(([permissionTree, userDetail]: [PermissionNode, any]) => {
const allPermissions = permissionTree.childList;
// 获取用户在多集群管理拥有的权限
const userPermissionTree = userDetail.permissionTreeVO.childList;
const clustersPermissions = userPermissionTree.find(
(sys: PermissionNode) => sys.permissionName === ClustersPermissionMap.CLUSTERS_MANAGE
);
const userPermissions: ClustersPermissionMap[] = [];
clustersPermissions &&
clustersPermissions.childList.forEach((node: PermissionNode) => node.has && userPermissions.push(node.permissionName));
const hasPermission = (permissionName: ClustersPermissionMap) => permissionName && userPermissions.includes(permissionName);
setGlobal((curState: any) => ({ ...curState, permissions: allPermissions, userPermissions, hasPermission, userInfo }));
});
return true;
};
// 获取指标信息
const getMetricsDefine = () => {
Utils.request(api.getKafkaVersionItems(), {
retryTimes: 2,
}).then((metricsDefine: MetricsDefine) => {
const getMetricDefine = (type: MetricType, metricName: string) => metricsDefine[`${type}@${metricName}`] || null;
setGlobal((curState: any) => ({ ...curState, metricsDefine, getMetricDefine }));
});
};
useLayoutEffect(() => {
// 如果未登录,直接跳转到登录页
const userInfo = localStorage.getItem('userInfo');
let userId: number;
try {
userId = JSON.parse(userInfo).id;
if (!userId) throw 'err';
} catch (_) {
goLogin();
return;
}
// 仅获取一次全局权限
if (!global.permissions) {
getPermissions(userId);
}
if (!global.metricsDefine) {
getMetricsDefine();
}
}, []);
return <></>;
};
export default CommonConfig;

View File

@@ -0,0 +1,87 @@
import React, { useEffect } from 'react';
import { AppContainer, Utils } from 'knowdesign';
import { useParams, useHistory } from 'react-router-dom';
import API, { MetricType } from '../api';
import CommonConfig from './CommonConfig';
interface ControlInfo {
type: MetricType.Controls;
name: ControlStatusMap;
desc: string;
support: boolean;
unit: string | null;
minVersion: number;
maxVersion: number;
}
export enum ControlStatusMap {
// Broker
BROKER_DETAIL_CONFIG = 'FEBrokersSpecifiedBrokerConfigList',
BROKER_DETAIL_DATALOGS = 'FEBrokersSpecifiedBrokerDataLogsList',
// Testing
TESTING_PRODUCER_HEADER = 'FETestingProducerHeader',
TESTING_PRODUCER_COMPRESSION_TYPE_ZSTD = 'FETestingProducerCompressionTypeZSTD',
TESTING_CONSUMER_HEADER = 'FETestingConsumerHeader',
}
const CommonRoute: React.FC = (props: any) => {
const [global, setGlobal] = AppContainer.useGlobalValue();
const { clusterId } = useParams<{ clusterId: string }>();
const history = useHistory();
const { Consumer } = AppContainer.context;
useEffect(() => {
getPhyClusterInfo();
getControlsStatus();
}, []);
const getPhyClusterInfo = () => {
Utils.request(API.getPhyClusterBasic(+clusterId), {
init: {
// needCode: true,
errorNoTips: true,
},
retryTimes: 2,
// timeout: 200,
})
.then((res: any) => {
if (res) {
setGlobal((curState: any) => ({ ...curState, clusterInfo: res }));
} else {
history.replace('/');
}
})
.catch((err) => {
history.replace('/');
// notification.error({
// message: '错误',
// duration: 3,
// description: `${'集群不存在或集群ID有误'}`,
// });
});
};
// 获取控件状态
const getControlsStatus = () => {
Utils.request(API.getSupportKafkaVersions(clusterId, MetricType.Controls), {
retryTimes: 2,
init: {
errorNoTips: true,
},
}).then((controlsStatus: ControlInfo[]) => {
const isShowControl = (controlName: ControlStatusMap) => {
const controlInfo = controlName && controlsStatus.find(({ name }) => name === controlName);
return controlInfo ? controlInfo.support : false;
};
setGlobal((curState: any) => ({ ...curState, controlsStatus, isShowControl }));
});
};
return (
<>
<CommonConfig />
</>
);
};
export default CommonRoute;

View File

@@ -0,0 +1,532 @@
import React, { useState, useEffect } from 'react';
import { useParams, useHistory } from 'react-router-dom';
import CopyToClipboard from 'react-copy-to-clipboard';
import { AppContainer, Divider, Drawer, IconFont, ProTable, Select, SingleChart, Space, Tooltip, Utils } from 'knowdesign';
import { DRangeTime } from 'knowdesign';
import { getBasicChartConfig } from '@src/constants/chartConfig';
import Api from '@src/api/index';
import { hashDataParse } from '@src/constants/common';
import { ClustersPermissionMap } from '../CommonConfig';
import ResetOffsetDrawer from './ResetOffsetDrawer';
import { CheckCircleFilled } from '@ant-design/icons';
import SwitchTab from '@src/components/SwitchTab';
const { Option } = Select;
export interface MetricLine {
createTime?: number;
metricPoints: Array<{
aggType: string;
createTime: number;
timeStamp: number;
unit: string;
updateTime: number;
value: number;
}>;
name: string;
updateTime?: number;
}
export interface MetricData {
metricLines?: Array<MetricLine>;
metricLine?: MetricLine;
metricName: string;
}
export interface HashData {
groupName: string;
topicName: string;
}
const metricConsts = ['LogEndOffset', 'OffsetConsumed', 'Lag'];
const metricWithType = [
{ metricName: 'LogEndOffset', metricType: 104 },
{ metricName: 'OffsetConsumed', metricType: 102 },
{ metricName: 'Lag', metricType: 102 },
];
const ContentWithCopy = (props: { content: string }) => {
const { content } = props;
const [visible, setVisible] = useState(false);
return (
<CopyToClipboard text={content}>
<div className="content-with-copy">
<Tooltip title={content}>
<span className="content">{content}</span>
</Tooltip>
{content && (
<Tooltip
title={
<span>
<CheckCircleFilled style={{ color: '#00b365' }} />
</span>
}
visible={visible}
onVisibleChange={() => setVisible(false)}
>
<IconFont className="copy-icon" type="icon-fuzhi" onClick={() => setVisible(true)} />
</Tooltip>
)}
</div>
</CopyToClipboard>
);
};
export default (props: any) => {
const { scene } = props;
const params = useParams<{
clusterId: string;
}>();
const history = useHistory();
const [global] = AppContainer.useGlobalValue();
// const { record } = props;
const now = Date.now();
const [allGroupMetricsData, setAllGroupMetricsData] = useState<Array<MetricData>>([]);
const [groupMetricsData, setGroupMetricsData] = useState<Array<MetricData>>([]);
const [timeRange, setTimeRange] = useState([now - 24 * 60 * 60 * 1000, now]);
const [consumerList, setConsumerList] = useState([]);
const [partitionList, setPartitionList] = useState([]);
const [curPartition, setCurPartition] = useState<string>('');
const [showMode, setShowMode] = useState('table');
const [pageIndex, setPageIndex] = useState(1);
const [pageTotal, setPageTotal] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [consumerListLoading, setConsumerListLoading] = useState(false);
const [consumerChartLoading, setConsumerChartLoading] = useState(false);
const [hashData, setHashData] = useState<HashData>({ groupName: '', topicName: '' });
const [visible, setVisible] = useState(false);
const [sortObj, setSortObj] = useState<{
sortField: string;
sortType: 'desc' | 'asc' | '';
}>({ sortField: '', sortType: '' });
const clusterId = Number(params.clusterId);
const columns = [
{
title: 'Topic Partition',
dataIndex: 'partitionId',
key: 'partitionId',
render: (v: string, record: any) => {
return `${record.topicName}-${v}`;
},
},
{
title: 'Member ID',
dataIndex: 'memberId',
key: 'memberId',
width: 200,
render: (v: string) => {
return <ContentWithCopy content={v} />;
},
},
{
title: 'Current Offset',
dataIndex: 'OffsetConsumed',
key: 'OffsetConsumed',
render: (v: any, record: any) => {
return record?.latestMetrics?.metrics?.OffsetConsumed;
},
sorter: true,
// sorter: {
// compare: (a: any, b: any) => {
// let value1 = a?.metrics?.find((item: any) => item.metricName === 'OffsetConsumed' && item.metricType === 102)?.metricValue
// let value2 = b?.metrics?.find((item: any) => item.metricName === 'OffsetConsumed' && item.metricType === 102)?.metricValue
// return value1 - value2
// },
// multiple: 1
// }
},
{
title: 'Log End Offset',
dataIndex: 'LogEndOffset',
key: 'LogEndOffset',
render: (v: any, record: any) => {
return record?.latestMetrics?.metrics?.LogEndOffset;
},
sorter: true,
// sorter: {
// compare: (a: any, b: any) => {
// let value1 = a?.metrics?.find((item: any) => item.metricName === 'LogEndOffset' && item.metricType === 104)?.metricValue
// let value2 = b?.metrics?.find((item: any) => item.metricName === 'LogEndOffset' && item.metricType === 104)?.metricValue
// return value1 - value2
// },
// multiple: 2
// }
},
{
title: 'Lag',
dataIndex: 'Lag',
key: 'Lag',
render: (v: any, record: any) => {
return record?.latestMetrics?.metrics?.Lag;
},
sorter: true,
// sorter: {
// compare: (a: any, b: any) => {
// let value1 = a?.metrics?.find((item: any) => item.metricName === 'Lag' && item.metricType === 102)?.metricValue
// let value2 = b?.metrics?.find((item: any) => item.metricName === 'Lag' && item.metricType === 102)?.metricValue
// return value1 - value2
// },
// multiple: 3
// }
},
{
title: 'Host',
dataIndex: 'host',
key: 'host',
},
{
title: 'Client ID',
dataIndex: 'clientId',
key: 'clientId',
needTooltip: true,
lineClampOne: true,
width: 200,
},
];
const getTopicGroupMetric = (hashData: HashData) => {
setConsumerListLoading(true);
const params: any = {
// metricRealTimes: metricWithType,
latestMetricNames: metricConsts,
pageNo: pageIndex,
pageSize,
};
if (sortObj.sortField && sortObj.sortType) {
params.sortField = sortObj.sortField;
params.sortType = sortObj.sortType;
}
return Utils.post(
Api.getTopicGroupMetric({
clusterId,
groupName: hashData.groupName,
topicName: hashData.topicName,
}),
params
).then((data: any) => {
if (!data) return;
setConsumerListLoading(false);
setConsumerList(data?.bizData);
setPageIndex(data?.pagination?.pageNo);
setPageSize(data?.pagination?.pageSize);
setPageTotal(data?.pagination?.total);
});
};
const getTopicGroupPartitionsHistory = (hashData: HashData) => {
return Utils.request(Api.getTopicGroupPartitionsHistory(clusterId, hashData.groupName), {
params: {
startTime: timeRange[0],
endTime: timeRange[1],
},
});
};
const getTopicGroupMetricHistory = (partitions: Array<any>, hashData: HashData) => {
const params = {
aggType: 'sum',
groupTopics: partitions.map((p) => ({
partition: p.partition,
topic: hashData.topicName,
})),
group: hashData.groupName,
metricsNames: metricWithType.map((item) => item.metricName),
startTime: timeRange[0],
endTime: timeRange[1],
topNu: 0,
};
Utils.post(Api.getTopicGroupMetricHistory(clusterId), params).then((data: Array<MetricData>) => {
setAllGroupMetricsData(data);
});
};
const getConsumersMetadata = (hashData: HashData) => {
return Utils.request(Api.getConsumersMetadata(clusterId, hashData.groupName, hashData.topicName));
};
const onClose = () => {
setVisible(false);
setSortObj({
sortField: '',
sortType: '',
});
// clean hash'
scene === 'topicDetail' && history.goBack();
scene !== 'topicDetail' && window.history.pushState('', '', location.pathname);
};
useEffect(() => {
if (curPartition === '' || allGroupMetricsData.length === 0) return;
const filteredData = allGroupMetricsData.map((item) => {
const allData = item.metricLines.reduce(
(acc, cur) => {
if (acc.metricLine.metricPoints.length === 0) {
acc.metricLine.metricPoints = cur.metricPoints.map((p) => ({
timeStamp: p.timeStamp,
value: Number(p.value),
}));
} else {
acc.metricLine.metricPoints.forEach((mp) => {
const curMetricPoint = cur.metricPoints.find((curmp) => curmp.timeStamp === mp.timeStamp);
mp.value += curMetricPoint ? Number(curMetricPoint.value) : 0;
});
}
return acc;
},
{
metricName: item.metricName,
metricLine: {
name: 'all',
metricPoints: [],
},
}
);
return curPartition === '__all__'
? allData
: {
metricName: item.metricName,
// metricLine: curPartition === '__all__' ? {} : item.metricLines.find(line => line.name.indexOf(curPartition) >= 0)
metricLine: item.metricLines.find((line) => line.name.indexOf(curPartition) >= 0),
};
});
setGroupMetricsData(filteredData);
}, [curPartition, allGroupMetricsData]);
useEffect(() => {
visible && getTopicGroupMetric(hashData);
}, [sortObj]);
useEffect(() => {
const hashData = hashDataParse(location.hash);
if (!hashData.groupName || !hashData.topicName) return;
setHashData(hashData);
// 获取分区列表 为图表模式做准备
getConsumersMetadata(hashData).then((res: any) => {
if (!res.exist) {
setVisible(false);
history.push(`/cluster/${params?.clusterId}/consumers`);
return;
}
setVisible(true);
getTopicGroupPartitionsHistory(hashData)
.then((data: any) => {
if (data.length > 0) {
setCurPartition(data[0].partition);
}
setPartitionList(data);
return data;
})
.then((data) => {
getTopicGroupMetricHistory(data, hashData);
})
.catch((e) => {
history.push(`/cluster/${params?.clusterId}/consumers`);
setVisible(false);
});
// 获取Consumer列表 表格模式
getTopicGroupMetric(hashData);
});
}, [hashDataParse(location.hash).groupName]);
useEffect(() => {
if (partitionList.length === 0) return;
getTopicGroupMetricHistory(partitionList, hashData);
}, [timeRange]);
return (
<Drawer
push={false}
title="Consumer Group详情"
width={1080}
placement="right"
onClose={onClose}
visible={visible}
className="consumer-group-detail-drawer"
maskClosable={false}
destroyOnClose
extra={
<Space>
{global.hasPermission &&
global.hasPermission(
scene === 'topicDetail' ? ClustersPermissionMap.TOPIC_RESET_OFFSET : ClustersPermissionMap.CONSUMERS_RESET_OFFSET
) && <ResetOffsetDrawer record={hashData}></ResetOffsetDrawer>}
<Divider type="vertical" />
</Space>
}
>
<div className="consumer-group-detail">
<div className="title-and-mode">
<div className="title-and-mode-header">Consumer列表</div>
<div className="right">
{showMode === 'chart' && (
<Select
style={{ width: 140, marginRight: 8 }}
size="small"
value={curPartition}
onChange={(id) => {
setCurPartition(id);
}}
>
<Option value={'__all__'}>Partition</Option>
{partitionList.map((partition) => (
<Option key={partition.partition} value={partition.partition}>
{partition.partition}
</Option>
))}
</Select>
)}
{showMode === 'chart' && (
<DRangeTime
rangeTimeArr={timeRange}
timeChange={(o: any) => {
setTimeRange(o);
}}
></DRangeTime>
)}
{showMode === 'chart' && <div className="divider"></div>}
<SwitchTab defaultKey={showMode} onChange={(key) => setShowMode(key)}>
<SwitchTab.TabItem key="chart">
<div style={{ width: 34, height: 23 }}>
<IconFont type="icon-tubiao"></IconFont>
</div>
</SwitchTab.TabItem>
<SwitchTab.TabItem key="table">
<div style={{ width: 34, height: 23 }}>
<IconFont type="icon-biaoge"></IconFont>
</div>
</SwitchTab.TabItem>
</SwitchTab>
</div>
</div>
{showMode === 'table' && (
// <Table
// rowKey={'partitionId'}
// columns={columns}
// className="table"
// loading={consumerListLoading}
// dataSource={consumerList}
// pagination={{
// current: pageIndex,
// pageSize: pageSize,
// total: pageTotal,
// simple: true
// }}
// onChange={(pagination: any, filters: any, sorter: any) => {
// setSortObj({
// sortField: sorter.field || '',
// sortType: sorter.order ? sorter.order.substring(0, sorter.order.indexOf('end')) : '',
// });
// setPageIndex(pagination.current);
// }}
// ></Table>
<ProTable
showQueryForm={false}
tableProps={{
loading: consumerListLoading,
showHeader: false,
rowKey: 'partitionId',
columns: columns,
dataSource: consumerList,
paginationProps:
pageTotal > 0
? {
current: pageIndex,
total: pageTotal,
pageSize: pageSize,
}
: null,
attrs: {
sortDirections: ['descend', 'ascend', 'default'],
scroll: { x: 1032 },
// className: 'frameless-table', // 纯无边框表格类名
onChange: (pagination: any, filters: any, sorter: any) => {
setSortObj({
sortField: sorter.field || '',
sortType: sorter.order ? sorter.order.substring(0, sorter.order.indexOf('end')) : '',
});
setPageIndex(pagination.current);
},
bordered: false,
},
}}
/>
)}
{showMode === 'chart' && (
<div className="single-chart">
<SingleChart
showHeader={false}
wrapStyle={{
width: '100%',
height: 242,
}}
option={getBasicChartConfig({
xAxis: {
type: 'time',
},
title: {
show: false,
},
legend: {
left: 'center',
},
grid: {
left: 0,
right: 0,
top: 10,
},
tooltip: {
customWidth: 200,
},
})}
// option={{
// title: {
// show: false,
// },
// xAxis: {
// show: true,
// type: 'category',
// },
// yAxis: {
// type: 'value',
// show: true,
// },
// legend: {
// show: true,
// right: 'auto',
// top: 220,
// },
// toolBox: {
// show: false,
// },
// grid: {
// left: 0,
// right: 0,
// top: 10,
// },
// }}
chartTypeProp="line"
propChartData={groupMetricsData}
// xAxisCallback={(data: []) => {
// // @ts-ignore
// return data.length > 0 ? data[0].metricLine.metricPoints.map((item: any) => item.timeStamp) : '';
// }}
seriesCallback={(data: any) => {
// return data.map((metricData: any) => {
// const partitionMetricData = metricData.metricLine?.metricPoints || []
// return {
// name: metricData.metricName,
// data: partitionMetricData.map((pmd: any) => pmd.value)
// }
// })
return data.map((metricData: any) => {
const partitionMetricData = metricData.metricLine?.metricPoints || [];
return {
name: metricData.metricName,
data: partitionMetricData.map((item: any) => [item.timeStamp, item.value, item.unit]),
lineStyle: {
width: 1,
},
symbol: 'emptyCircle',
symbolSize: 4,
emphasis: {
disabled: true,
},
};
});
}}
/>
</div>
)}
</div>
</Drawer>
);
};

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
import { Button, DatePicker, Drawer, Form, notification, Radio, Utils, Space, Divider } from 'knowdesign';
import { useParams } from 'react-router-dom';
import EditTable from '../TestingProduce/component/EditTable';
import Api from '@src/api/index';
import moment from 'moment';
const CustomSelectResetTime = (props: { value?: string; onChange?: (val: Number | String) => void }) => {
const { value, onChange } = props;
const [timeSetMode, setTimeSetMode] = useState('newest');
useEffect(() => {
onChange('newest');
}, []);
return (
<>
<Radio.Group
style={{
marginBottom: 20,
}}
onChange={(e) => {
setTimeSetMode(e.target.value);
if (e.target.value === 'newest') {
onChange('newest');
}
}}
value={timeSetMode}
>
<Radio value={'newest'}>Offset</Radio>
<Radio value={'custom'}></Radio>
</Radio.Group>
{timeSetMode === 'custom' && (
<DatePicker
value={moment(value === 'newest' ? Date.now() : value)}
style={{ width: '100%' }}
showTime={true}
onChange={(v) => {
onChange(v.valueOf());
}}
></DatePicker>
)}
</>
);
};
export default (props: any) => {
const { record } = props;
const routeParams = useParams<{
clusterId: string;
}>();
const [form] = Form.useForm();
const defaultResetType = 'assignedTime';
const [resetType, setResetType] = useState(defaultResetType);
const [resetOffsetVisible, setResetOffsetVisible] = useState(false);
const customFormRef: any = React.createRef();
const clusterPhyId = Number(routeParams.clusterId);
useEffect(() => {
form.setFieldsValue({
resetType: defaultResetType,
});
}, []);
const confirm = () => {
let tableData;
if (customFormRef.current) {
tableData = customFormRef.current.getTableData();
}
const formData = form.getFieldsValue();
let resetParams: any = {
clusterId: clusterPhyId,
createIfNotExist: false,
groupName: record.groupName,
topicName: record.topicName,
};
if (formData.resetType === 'assignedTime') {
resetParams.resetType = formData.timestamp === 'newest' ? 0 : 2;
if (resetParams.resetType === 2) {
resetParams.timestamp = formData.timestamp;
}
}
if (formData.resetType === 'partition') {
resetParams.resetType = 3;
resetParams.offsetList = tableData
? tableData.map((item: { key: string; value: string }) => ({ partitionId: item.key, offset: item.value }))
: [];
}
Utils.put(Api.resetGroupOffset(), resetParams).then((data) => {
if (data === null) {
notification.success({
message: '重置offset成功',
});
setResetOffsetVisible(false);
} else {
notification.error({
message: '重置offset失败',
});
setResetOffsetVisible(false);
}
});
};
return (
<>
<Button
size="small"
type="primary"
onClick={(_) => {
setResetOffsetVisible(true);
}}
>
Offset
</Button>
<Drawer
title="重置Offset"
width={480}
visible={resetOffsetVisible}
maskClosable={false}
extra={
<Space>
<Button
size="small"
style={{ marginRight: 8 }}
onClick={(_) => {
setResetOffsetVisible(false);
}}
>
</Button>
<Button size="small" type="primary" onClick={confirm}>
</Button>
<Divider type="vertical" />
</Space>
}
className="cluster-detail-consumer-resetoffset"
onClose={(_) => {
setResetOffsetVisible(false);
}}
>
<Form form={form} labelCol={{ span: 5 }} layout="vertical" className="reset-offset-form">
<Form.Item name="resetType" label="重置类型" required>
<Radio.Group
defaultValue="assignedTime"
value={resetType}
onChange={(e) => {
setResetType(e.target.value);
}}
>
<Radio value={'assignedTime'}></Radio>
<Radio value={'partition'}></Radio>
</Radio.Group>
</Form.Item>
{resetType === 'assignedTime' && (
<Form.Item name="timestamp" label="时间" required>
<CustomSelectResetTime />
</Form.Item>
)}
{resetType === 'partition' && (
<Form.Item name="partition" label="分区及偏移" required>
<EditTable
ref={customFormRef}
colCustomConfigs={[
{
title: 'PartitionID',
inputType: 'number',
placeholder: '请输入Partition',
},
{
title: 'Offset',
inputType: 'number',
placeholder: '请输入Offset',
},
]}
></EditTable>
</Form.Item>
)}
</Form>
</Drawer>
</>
);
};

View File

@@ -0,0 +1,149 @@
.operating-state {
.operation-bar {
.left {
.dcloud-form-item {
margin-bottom: 0;
}
.dcloud-form-item:first-of-type {
margin-right: 12px;
}
}
}
.pro-table-wrap {
// padding: 17px 24px;
background: #fff;
border-radius: 12px;
}
}
.consumer-group-detail-drawer {
.dcloud-drawer-extra {
button {
width: 90px;
}
.divider {
width: 1px;
height: 17px;
margin: 0 16px;
background: rgba(206, 212, 218, 0.6);
}
}
.dcloud-drawer-body {
padding-top: 2px !important;
}
}
.consumer-group-detail {
// border: 1px solid #EFF2F7;
// border-radius: 8px;
// padding: 12px 16px;
.title-and-mode {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
&-header{
font-family: @font-family-bold;
font-size: 13px;
}
h4 {
font-family: PingFangSC-Semibold;
}
.right {
display: flex;
justify-content: center;
align-items: center;
.d-range-time-input {
height: 27px !important;
}
.divider {
width: 1px;
height: 20px;
background: rgba(206, 212, 218, 0.6);
margin: 0 8px;
}
.switch-mode {
.dcloud-radio-button-wrapper {
font-size: 14px;
width: 34px;
height: 23px;
padding: 0;
line-height: 22px;
text-align: center;
}
}
}
}
.single-chart,
.table {
background: #f8f9fa;
// margin-top: 12px;
border-radius: 8px;
.dcloud-table {
height: 210px;
overflow: auto;
background-color: transparent;
.dcloud-table-content .dcloud-table-cell {
background-color: transparent;
}
}
.dcloud-pagination {
height: 32px;
margin-bottom: 0;
margin-top: 8px;
}
}
.single-chart {
padding: 16px 22px 4px;
.single-chart-header {
display: none;
}
}
.content-with-copy {
display: flex;
align-items: center;
.content {
flex: 1;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
}
.copy-icon {
width: 20px;
height: 20px;
padding-top: 2px;
border-radius: 50%;
margin-left: 4px;
font-size: 16px;
color: #adb5bc;
opacity: 0;
&:hover {
background: rgba(33, 37, 41, 0.04);
color: #74788d;
}
}
}
.dcloud-table-cell-row-hover {
.copy-icon {
opacity: 1;
}
}
}
.cluster-detail-consumer-resetoffset {
.reset-offset-form {
.dcloud-radio-wrapper {
width: 154px;
}
}
.operate-wrap {
display: flex;
justify-content: center;
align-items: center;
.operate-btns-divider {
width: 1px;
height: 17px;
background: rgba(0, 0, 0, 0.13);
margin: 0 16px;
}
}
}

View File

@@ -0,0 +1,264 @@
/* eslint-disable react/display-name */
import React, { useState, useEffect } from 'react';
import { AppContainer, Form, Input, ProTable, Select, Utils } from 'knowdesign';
import './index.less';
import Api from '@src/api/index';
import { getOperatingStateListParams } from './interface';
import { useParams } from 'react-router-dom';
import ConsumerGroupDetail from './ConsumerGroupDetail';
import ConsumerGroupHealthCheck from '@src/components/CardBar/ConsumerGroupHealthCheck';
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
import { hashDataParse } from '@src/constants/common';
const { Option } = Select;
const AutoPage = (props: any) => {
const { scene, detailParams = { searchKeywords: '' } } = props;
const routeParams = useParams<{
clusterId: string;
}>();
const [global] = AppContainer.useGlobalValue();
const [consumerGroupList, setConsumerGroupList] = useState([]);
const [showMode, setShowMode] = useState('list');
const [searchGroupName, setSearchGroupName] = useState(detailParams.searchKeywords || '');
const [searchTopicName, setSearchTopicName] = useState('');
const [pageIndex, setPageIndex] = useState(1);
const [pageTotal, setPageTotal] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [consumersListLoading, setConsumersListLoading] = useState(false);
const clusterPhyId = Number(routeParams.clusterId);
const searchFn = () => {
const params: getOperatingStateListParams = {
pageNo: pageIndex,
pageSize,
fuzzySearchDTOList: [],
};
if (searchGroupName) {
// params.groupName = searchGroupName;
params.fuzzySearchDTOList.push({ fieldName: 'groupName', fieldValue: searchGroupName });
}
if (searchTopicName) {
params.fuzzySearchDTOList.push({ fieldName: 'topicName', fieldValue: searchTopicName });
}
const topicName = hashDataParse(location.hash)?.topicName;
if (topicName) {
params.topicName = topicName;
}
getOperatingStateList(params);
};
useEffect(() => {
searchFn();
}, []);
useEffect(() => {
setSearchGroupName(detailParams.searchKeywords);
}, [detailParams.searchKeywords]);
const getOperatingStateList = (params: getOperatingStateListParams) => {
setConsumersListLoading(true);
Utils.post(Api.getOperatingStateList(clusterPhyId), params).then((data: any) => {
setConsumersListLoading(false);
setConsumerGroupList(data?.bizData || []);
setPageIndex(data.pagination.pageNo);
setPageTotal(data.pagination.total);
setPageSize(data.pagination.pageSize);
});
};
const columns = () => {
const baseColumns: any = [
{
title: 'ConsumerGroup',
dataIndex: 'groupName',
key: 'groupName',
render: (v: any, r: any) => {
return (
<a
onClick={() => {
window.location.hash = `groupName=${v || ''}&topicName=${r.topicName}`;
}}
>
{v}
</a>
);
},
},
{
title: '消费的Topic',
dataIndex: 'topicName',
key: 'topicName',
},
// {
// title: 'Principle',
// dataIndex: 'kafkaUser',
// key: 'kafkaUser',
// },
{
title: 'Status',
dataIndex: 'state',
key: 'state',
},
{
title: 'Max Lag',
dataIndex: 'maxLag',
key: 'maxLag',
},
{
title: 'Member数',
dataIndex: 'memberCount',
key: 'memberCount',
},
];
// if (
// global.hasPermission &&
// global.hasPermission(
// scene === 'topicDetail' ? ClustersPermissionMap.TOPIC_RESET_OFFSET : ClustersPermissionMap.CONSUMERS_RESET_OFFSET
// )
// ) {
// baseColumns.push({
// title: '操作',
// dataIndex: 'desc',
// key: 'desc',
// render: (txt: any, record: any) => {
// return <ResetOffsetDrawer record={record}></ResetOffsetDrawer>;
// },
// });
// }
return baseColumns;
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
const params: getOperatingStateListParams = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
fuzzySearchDTOList: [],
};
// setFilteredInfo(filters);
if (searchGroupName) {
// params.groupName = searchGroupName;
params.fuzzySearchDTOList.push({ fieldName: 'groupName', fieldValue: searchGroupName });
}
if (searchTopicName) {
params.fuzzySearchDTOList.push({ fieldName: 'topicName', fieldValue: searchTopicName });
}
const topicName = hashDataParse(location.hash)?.topicName;
if (topicName) {
params.topicName = topicName;
}
getOperatingStateList(params);
};
const showModes = [
{ label: '列表模式', value: 'list' },
{ label: '关系图模式', value: 'graph' },
];
return (
<>
{scene !== 'topicDetail' && (
<div className="breadcrumb" style={{ marginBottom: '10px' }}>
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Consumers', aHref: `` },
]}
/>
</div>
)}
{scene !== 'topicDetail' && (
<div style={{ margin: '12px 0' }}>
{' '}
<ConsumerGroupHealthCheck></ConsumerGroupHealthCheck>
</div>
)}
<div className={`operating-state ${scene !== 'topicDetail' && 'clustom-table-content'}`}>
{/* <CardBar cardColumns={data}></CardBar> */}
{scene !== 'topicDetail' && (
<div className="operation-bar">
<div className="left">
{/* <Radio.Group
options={showModes}
optionType="button"
onChange={(e) => {
setShowMode(e.target.value);
}}
value={showMode}
/> */}
<Form.Item label="">
<Input
className="search-input-short"
placeholder="请输入Consumer Group"
// suffix={<SearchOutlined />}
onChange={(e) => {
setSearchGroupName(e.target.value);
}}
onPressEnter={searchFn}
/>
</Form.Item>
<Form.Item label="">
<Input
className="search-input-short"
placeholder="请输入Topic name"
// suffix={<SearchOutlined />}
onChange={(e) => {
setSearchTopicName(e.target.value);
}}
onPressEnter={searchFn}
/>
</Form.Item>
{/* <Button type="primary" className="add-btn" onClick={searchFn}>
查询
</Button> */}
</div>
<div className="right"></div>
</div>
)}
{/* <Table columns={columns} dataSource={consumerGroupList} scroll={{ x: 1500 }} />
{pageTotal > 0 && <Pagination
className='pro-table-pagination-custom'
defaultCurrent={1}
current={pageIndex}
total={pageTotal}
pageSize={pageSize} />} */}
<div className="pro-table-wrap">
<ProTable
showQueryForm={false}
tableProps={{
loading: consumersListLoading,
showHeader: false,
rowKey: 'groupName',
columns: columns(),
dataSource: consumerGroupList,
paginationProps:
pageTotal > 0
? {
current: pageIndex,
total: pageTotal,
pageSize: pageSize,
}
: null,
attrs: {
// expandable: {
// expandedRowRender: (record: any) => {
// return <ConsumerGroupDetail record={record}></ConsumerGroupDetail>;
// },
// rowExpandable: (record: any) => true,
// },
onChange: onTableChange,
scroll: { y: 'calc(100vh - 390px)' },
// className: `frameless-table ${scene !== 'topicDetail' && 'clustom-table-content'}`, // 纯无边框表格类名
},
}}
/>
</div>
</div>
{<ConsumerGroupDetail scene={scene}></ConsumerGroupDetail>}
</>
);
};
export default AutoPage;

View File

@@ -0,0 +1,56 @@
export interface getOperatingStateListParams {
fuzzySearchDTOList?: Array<{
fieldName: string;
fieldValue: string;
}>;
groupName?: string;
pageNo: number;
pageSize: number;
searchKeywords?: string;
topicName?: string;
}
export interface getTopicGroupMetricParams {
clusterId: number;
dto: {
metricRealTimes: Array<{
metricName: string;
metricType: number;
}>;
pageNo?: number;
pageSize?: number;
searchKeywords?: string;
};
topicName: string;
groupName: string;
}
export interface getTopicGroupMetricHistoryParams {
clusterPhyId: number;
param: {
groupTopics: Array<{
groupName: string;
partitionIdList: Array<string>;
topicName: string;
}>;
groups: Array<string>;
metricsName: Array<string>;
startTime: number;
endTime: number;
};
}
export interface getTopicGroupPartitionsHistoryParams {
clusterPhyId: number;
groupName: string;
startTime: number;
endTime: number;
}
export interface ResetGroupOffset {
clusterId: number;
groupName: string;
offsetList: Array<{
offset: number;
partitionId: number;
}>;
resetType: number;
timestamp: number;
topicName: string;
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { timeFormat } from '../../constants/common';
import moment from 'moment';
import { getSizeAndUnit } from '../../constants/common';
export const getGroupListColumns = (arg: any) => {
const columns = [
{
title: 'ConsumerGroup',
dataIndex: 'resourceName',
key: 'resourceName',
},
{
title: 'Operations',
dataIndex: 'aclOperation',
key: 'aclOperation',
},
{
title: 'Permission Type',
dataIndex: 'aclPermissionType',
key: 'aclPermissionType',
},
{
title: 'Pattern Type',
dataIndex: 'resourcePatternType',
key: 'resourcePatternType',
},
{
title: 'Principle',
dataIndex: 'kafkaUser',
key: 'kafkaUser',
},
];
return columns;
};
export const defaultPagination = {
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
};

View File

@@ -0,0 +1,106 @@
import React, { useState, useEffect } from 'react';
import { ProTable, Drawer, Utils, Input } from 'knowdesign';
import API from '../../api';
import { getGroupListColumns, defaultPagination } from './config';
import { dealTableRequestParams } from '../../constants/common';
const { request } = Utils;
const ControllerChangeLogList: React.FC = (props: any) => {
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [searchKeywords, setSearchKeywords] = useState('');
const [filteredInfo, setFilteredInfo] = useState(null);
const [pagination, setPagination] = useState<any>(defaultPagination);
// 默认排序
// const defaultSorter = {
// field: 'brokerId',
// order: 'ascend',
// };
const solveClick = (record: any) => {};
// 请求接口获取数据
const genData = async ({ pageNo, pageSize, filters = null, sorter = null }: any) => {
// if (clusterId === undefined) return;
filters = filters || filteredInfo;
setLoading(true);
const params = dealTableRequestParams({ searchKeywords, pageNo, pageSize, sorter, filters });
request(API.getGroupACLBindingList(2), { params })
.then((res: any) => {
setPagination({
current: res.pagination?.pageNo,
pageSize: res.pagination?.pageSize,
total: res.pagination?.total,
});
setData(res?.bizData || []);
setLoading(false);
})
.catch((err) => {
setPagination({
current: 1,
pageSize: 10,
total: 20,
});
// mock
setData([
{
aclClientHost: '127.0.0.1',
aclOperation: 1,
aclPermissionType: 2,
kafkaUser: 'know-streaming',
resourceName: 'know-streaming',
resourcePatternType: 2,
resourceType: 2,
},
]);
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
setFilteredInfo(filters);
genData({ pageNo: pagination.current, pageSize: pagination.pageSize, filters, sorter });
};
const getSearchKeywords = (value: string) => {
setSearchKeywords(value);
};
useEffect(() => {
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, [searchKeywords]);
return (
<>
<ProTable
showQueryForm={false}
tableProps={{
showHeader: true,
rowKey: 'path',
loading: loading,
columns: getGroupListColumns(solveClick),
dataSource: data,
paginationProps: { ...pagination },
tableHeaderSearchInput: {
// 搜索配置
submit: getSearchKeywords,
placeholder: '请输入Broker Host',
width: '248px',
searchTrigger: 'enter',
},
attrs: {
className: 'frameless-table', // 纯无边框表格类名
onChange: onTableChange,
},
}}
/>
</>
);
};
export default ControllerChangeLogList;

View File

@@ -0,0 +1,191 @@
import React, { useEffect, useState } from 'react';
import { Badge, ProTable, Tooltip, Utils, Progress } from 'knowdesign';
import { formatAssignSize, runningStatusEnum } from './config';
const columns: any = [
{
title: 'Partition',
dataIndex: 'partitionId',
key: 'partitionId',
render: (t: any, r: any) => {
// return runningStatusEnum[r?.status];
return (
<span>
{t}
<Badge
style={{ marginLeft: '6px' }}
status={r?.status === 1 ? 'warning' : r?.status === 4 ? 'error' : r?.status === 3 ? 'success' : 'warning'}
/>
</span>
);
},
},
{
title: '源BrokerID',
dataIndex: 'sourceBrokerIds',
key: 'sourceBrokerIds',
render: (t: any, r: any) => {
return t && t.length > 0 ? t.join('、') : '-';
},
},
{
title: '目标BrokerID',
dataIndex: 'desBrokerIds',
key: 'desBrokerIds',
render(t: any, r: any) {
return t && t.length > 0 ? t.join('、') : '-';
},
},
// {
// title: '需迁移MessageSizeMB',
// dataIndex: 'totalSize',
// key: 'totalSize',
// render(t: any, r: any) {
// return t || t === 0 ? formatAssignSize(t, 'MB') : '-';
// },
// },
// {
// title: '已完成MessageSize MB',
// dataIndex: 'movedSize',
// key: 'movedSize',
// render(t: any, r: any) {
// return t || t === 0 ? formatAssignSize(t, 'MB') : '-';
// },
// },
{
title: 'MessageSize迁移进度(MB)',
dataIndex: 'movedSize',
key: 'movedSize',
render(t: any, r: any) {
const movedSize = r.movedSize ? Number(Utils.formatAssignSize(t, 'MB')) : 0;
const totalSize = r.totalSize ? Number(Utils.formatAssignSize(t, 'MB')) : 0;
return (
<div className="message-size">
<Tooltip title={(movedSize > 0 && totalSize > 0 ? (movedSize / totalSize) * 100 : 0) + '%'}>
<Progress
percent={movedSize > 0 && totalSize > 0 ? (movedSize / totalSize) * 100 : 0}
strokeColor="#556EE6"
trailColor="#ECECF1"
showInfo={false}
/>
</Tooltip>
<span>{movedSize + '/' + totalSize}</span>
</div>
);
},
},
// {
// title: '状态',
// key: 'status',
// width: 100,
// render: (t: any, r: any) => {
// return (
// <span>
// <Badge status={r?.status === 1 ? 'warning' : r?.status === 4 ? 'error' : r?.status === 3 ? 'success' : 'warning'} />
// {runningStatusEnum[r?.status]}
// </span>
// );
// },
// },
// {
// title: 'BytesInMB/s',
// dataIndex: 'byteIn',
// key: 'byteIn',
// render(t: any, r: any) {
// return t ? t : '-';
// },
// },
// {
// title: '同步速率MB/s',
// dataIndex: 'byteMove',
// key: 'byteMove',
// render(t: any, r: any) {
// return t ? t : '-';
// },
// },
{
title: '预计剩余时长',
dataIndex: 'remainTime',
key: 'remainTime',
render(t: any, r: any) {
return t ? Utils.transUnitTime(t) : t === 0 ? t : '-';
},
},
];
export const ExpandedRow: any = ({ record, data, loading }: any) => {
const [pagination, setPagination] = useState<any>({
current: 1,
pageSize: 5,
// total: data[record.key]?.length,
simple: true,
hideOnSinglePage: false,
});
const [status, setStatus] = useState({
total: 0,
success: 0,
doing: 0,
fail: 0,
});
const onTableChange = (pagination: any, filters: any) => {
setPagination(pagination);
};
const calcStatus = () => {
const success = data[record.key]?.filter((item: any) => runningStatusEnum[item.status] === 'Success').length || 0;
const doing = data[record.key]?.filter((item: any) => runningStatusEnum[item.status] === 'Doing').length || 0;
const fail = data[record.key]?.filter((item: any) => runningStatusEnum[item.status] === 'Fail').length || 0;
const total = data[record.key]?.length || 0;
setStatus({ total, success, doing, fail });
};
useEffect(() => {
calcStatus();
}, [data[record.key]]);
return (
<div
key={record.key}
style={{ position: 'relative', padding: '12px 16px', border: '1px solid #EFF2F7', borderRadius: '8px', backgroundColor: '#ffffff' }}
>
<ProTable
// bordered
tableProps={{
showHeader: false,
loading: loading[record.key],
rowKey: 'key',
dataSource: data[record.key] || [],
columns,
lineFillColor: true,
// noPagination: true,
paginationProps: pagination,
attrs: {
className: 'expanded-table',
onChange: onTableChange,
scroll: { x: 'max-content' },
size: 'small',
bordered: false,
rowClassName: 'table-small-bgcolor',
},
}}
/>
<div style={{ position: 'absolute', bottom: '12px', left: '16px' }}>
<span></span>
<span>Partition总数 {(status?.success || 0) + (status?.doing || 0) + (status?.fail || 0)}</span>
<span style={{ marginLeft: '34px', display: 'inline-block' }}>
<Badge status="success" />
Success {status?.success || 0}
</span>
<span style={{ marginLeft: '34px', display: 'inline-block' }}>
<Badge status="error" />
Fail {status?.fail || 0}
</span>
<span style={{ marginLeft: '34px', display: 'inline-block' }}>
<Badge status="warning" />
Doing {status?.doing || 0}
</span>
{/* <Pagination size="small" onChange={onTableChange} simple total={data[record.key]?.length} /> */}
</div>
</div>
);
};

View File

@@ -0,0 +1,99 @@
import React, { useState, useEffect } from 'react';
import { Alert, Badge, Dropdown, ProTable, Space, Table, Utils } from 'knowdesign';
import { useParams } from 'react-router-dom';
import Api from '@src/api';
import { getNodeTrafficColumns } from './config';
const { request, post } = Utils;
const NodeTraffic = (props: any) => {
const { hashData } = props;
const urlParams = useParams<any>(); // 获取地址栏参数
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [pagination, setPagination] = useState<any>({
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
});
const [popoverVisible, setPopoverVisible] = useState(false);
// 获取
const genData = async ({ pageNo, pageSize }: any) => {
if (urlParams?.clusterId === undefined) return;
setData([]);
setLoading(true);
post(Api.getJobNodeTraffic(urlParams?.clusterId, props?.jobId))
.then((res: any) => {
// setPagination({
// current: res.pagination?.pageNo,
// pageSize: res.pagination?.pageSize,
// total: res.pagination?.total,
// });
const mockData = [];
for (let i = 0; i < 20; i++) {
mockData.push({
byteInJob: 0,
byteInTotal: 0,
byteOutJob: 0,
byteOutTotal: 0,
createTime: 1645608135717,
host: 'string',
id: 0,
updateTime: 1645608135717,
});
}
setData(res || mockData);
// setData(res?.bizData || []);
setLoading(false);
})
.catch((err) => {
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
// setPagination(pagination);
// const asc = sorter?.order && sorter?.order === 'ascend' ? true : false;
// const sortColumn = sorter.field && toLine(sorter.field);
genData({ pageNo: pagination.current, pageSize: pagination.pageSize });
};
useEffect(() => {
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, []);
return (
<>
<div style={{ maxWidth: '1032px' }}>
<ProTable
showQueryForm={false}
tableProps={{
showHeader: false,
rowKey: 'key',
loading: loading,
columns: getNodeTrafficColumns({ popoverVisible, setPopoverVisible }),
dataSource: data,
paginationProps: { ...pagination },
attrs: {
bordered: false,
// className: 'frameless-table', // 纯无边框表格类名
onChange: onTableChange,
size: 'middle',
},
}}
/>
</div>
</>
);
};
export default NodeTraffic;

View File

@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { Button, ProTable, Popover } from 'knowdesign';
import { CloseOutlined } from '@ant-design/icons';
export const PopoverBroker = (props: any) => {
const [visible, setVisible] = useState<boolean>(false);
const columns = [
{
title: 'BrokerID',
dataIndex: 'brokerId',
key: 'brokerHost',
},
{
title: 'Host',
dataIndex: 'brokerHost',
key: 'brokerHost',
},
];
const newPropsData = Object.keys(props.data).map((item) => {
return {
brokerId: item,
brokerHost: props.data[item],
};
});
return (
<Popover
// getPopupContainer={(triggerNode: any) => {
// return triggerNode;
// }}
placement="bottomLeft"
overlayClassName="custom-popover-borker"
trigger={'click'}
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '16px' }}>{props?.title}</div>
<Button
type="text"
icon={<CloseOutlined className="close-icon" />}
onClick={() => {
setVisible(false);
}}
/>
</div>
}
visible={visible}
onVisibleChange={(visible) => setVisible(visible)}
content={
<div className="container">
<div className="main">
<ProTable
showQueryForm={false}
tableProps={{
noPagination: true,
showHeader: false,
rowKey: 'key',
// loading: loading,
columns,
dataSource: newPropsData,
// paginationProps: { ...pagination },
attrs: {
bordered: false,
// className: 'frameless-table', // 纯无边框表格类名
// onChange: onTableChange,
size: 'middle',
},
}}
/>
</div>
</div>
}
>
{newPropsData && newPropsData.length > 0 && <a style={{ display: 'inline-block', marginLeft: '8px' }}></a>}
</Popover>
);
};

View File

@@ -0,0 +1,294 @@
import React, { useState, useEffect } from 'react';
import { Alert, Badge, Descriptions, Dropdown, ProTable, DTable, Table, Utils, Spin, Tag } from 'knowdesign';
import { useParams } from 'react-router-dom';
import Api from '@src/api';
import { getNodeTrafficColumns } from './config';
import { getSizeAndUnit } from '@src/constants/common';
interface PropsType {
jobId?: any;
balanceData?: any;
status?: any;
[name: string]: any;
// [clasgfag: any]: any;
}
const typeObj: any = {
1: '周期均衡',
2: '立即均衡',
};
const { request, post } = Utils;
const RebalancePlan = (props: PropsType) => {
const { jobId, balanceData, status } = props;
const urlParams = useParams<any>(); // 获取地址栏参数
const [loading, setLoading] = useState(false);
const [data, setData] = useState<any>({});
const [pagination, setPagination] = useState<any>({
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
// hideOnSinglePage: false,
// showTotal: (total: number) => `共 ${total} 条目`,
});
// 获取
const genData = async ({ pageNo, pageSize }: any) => {
if (urlParams?.clusterId === undefined) return;
setLoading(true);
if (balanceData) {
setData(balanceData);
setLoading(false);
return;
}
setData([]);
request(Api.getJobsPlanRebalance(urlParams?.clusterId, jobId))
.then((res: any) => {
// setPagination({
// current: res.pagination?.pageNo,
// pageSize: res.pagination?.pageSize,
// total: res.pagination?.total,
// });
// setData(mockData);
setData(res || []);
setLoading(false);
})
.catch((err) => {
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
setPagination(pagination);
// const asc = sorter?.order && sorter?.order === 'ascend' ? true : false;
// const sortColumn = sorter.field && toLine(sorter.field);
// genData({ pageNo: pagination.current, pageSize: pagination.pageSize });
};
useEffect(() => {
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, []);
const columns: any = [
{
title: '节点',
dataIndex: 'host',
key: 'host',
fixed: 'left',
render: (t: any, r: any) => {
return (
<span>
{t}
<Badge style={{ marginLeft: '4px' }} status={r?.status === 0 ? 'success' : 'error'} />
</span>
);
},
},
{
title: (
<div>
Disk使用率
<span className="balance-list-title">
{`(`}
<span className="titleImg"></span>
{`)`}
</span>
</div>
),
dataIndex: 'diskBefore',
key: 'diskBefore',
width: 140,
render: (t: any, r: any) => {
const befaore = r?.diskBefore || r?.diskBefore === 0 ? r?.diskBefore.toFixed(2) + '%' : '0%';
const after = r?.diskAfter || r?.diskAfter === 0 ? r?.diskAfter.toFixed(2) + '%' : '0%';
return (
<span className="balance-list-contert">
<span className="balance-list-contert-text">{befaore}</span>
<span className="balance-list-contert-img"></span>
<span className="balance-list-contert-text-right">{after}</span>
</span>
);
},
},
{
title: (
<div>
BytesIn使用率
<span className="balance-list-title">
{`(`}
<span className="titleImg"></span>
{`)`}
</span>
</div>
),
dataIndex: 'byteInBefore',
key: 'byteInBefore',
width: 140,
render: (t: any, r: any) => {
const befaore = r?.byteInBefore || r?.byteInBefore === 0 ? r?.byteInBefore.toFixed(2) + '%' : '0%';
const after = r?.byteInAfter || r?.byteInAfter === 0 ? r?.byteInAfter.toFixed(2) + '%' : '0%';
return (
<span className="balance-list-contert">
<span className="balance-list-contert-text">{befaore}</span>
<span className="balance-list-contert-img"></span>
<span className="balance-list-contert-text-right">{after}</span>
</span>
);
},
},
{
title: (
<div>
BytesOut使用率
<span className="balance-list-title">
{`(`}
<span className="titleImg"></span>
{`)`}
</span>
</div>
),
dataIndex: 'byteOutBefore',
key: 'byteOutBefore',
width: 140,
render: (t: any, r: any) => {
const befaore = r?.byteOutBefore || r?.byteOutBefore === 0 ? r?.byteOutBefore.toFixed(2) + '%' : '0%';
const after = r?.byteOutAfter || r?.byteOutAfter === 0 ? r?.byteOutAfter.toFixed(2) + '%' : '0%';
return (
<span className="balance-list-contert">
<span className="balance-list-contert-text">{befaore}</span>
<span className="balance-list-contert-img"></span>
<span className="balance-list-contert-text-right">{after}</span>
</span>
);
},
},
{
title: '移入情况',
dataIndex: 'inReplica',
key: 'inReplica',
render: (t: any, r: any) => {
return (t || t === 0 ? t : 0) + '/' + (r?.inSize || r?.inSize === 0 ? getSizeAndUnit(r?.inSize, 'B', 0).valueWithUnit : 0);
},
},
{
title: '移出情况',
dataIndex: 'outReplica',
key: 'outReplica',
render: (t: any, r: any) => {
return (t || t === 0 ? t : 0) + '/' + (r?.outSize || r?.outSize === 0 ? getSizeAndUnit(r?.outSize, 'B', 0).valueWithUnit : 0);
},
},
];
const mockdata: any = [];
for (let i = 0; i < 100; i++) {
mockdata.push({
key: i,
name: 'John Brown',
age: i + 1,
street: 'Lake Park',
building: 'C',
number: 2035,
companyAddress: 'Lake Street 42',
companyName: 'SoftLake Co',
gender: 'M',
});
}
// const download= (url:any, name:any)=>{
// const a:any = document.createElement(\'a\')
// a.download = name
// a.rel = \'noopener\'
// a.href = url
// // 触发模拟点击
// // a.dispatchEvent(new MouseEvent(\'click\'))
// // 或者模拟点击
// a.click()
// }
return (
<>
<h3 style={{ fontSize: '16px' }}></h3>
<Spin spinning={loading}>
<Descriptions
style={{ fontSize: '13px' }}
column={2}
labelStyle={{
width: '100px',
textAlign: 'right',
display: 'flex',
justifyContent: 'end',
color: '#74788D',
fontSize: '13px',
}}
contentStyle={{ fontSize: '13px' }}
>
<Descriptions.Item labelStyle={{ width: '79px' }} label="任务类型">
{typeObj[data?.type] || '-'}
</Descriptions.Item>
<Descriptions.Item labelStyle={{ width: '79px' }} label="总迁移大小">
{Utils.getSizeAndUnit(data?.moveSize, 'B').valueWithUnit}
</Descriptions.Item>
<Descriptions.Item label="Topic黑名单">
{data?.blackTopics && data?.blackTopics?.length > 0 ? data?.blackTopics.join('、') : '-'}
</Descriptions.Item>
<Descriptions.Item labelStyle={{ width: '79px' }} label="迁移副本数">
{data?.replicas || '-'}
</Descriptions.Item>
<Descriptions.Item labelStyle={{ width: '79px' }} label="均衡阈值">
{data?.clusterBalanceIntervalList
? data?.clusterBalanceIntervalList?.map((item: any) => {
return (
<Tag style={{ padding: '4px 8px', backgroundColor: 'rgba(33,37,41,0.08)', marginRight: '4px' }} key={item?.priority}>
{item.type + ':' + item.intervalPercent + '%'}
</Tag>
);
})
: '-'}
</Descriptions.Item>
</Descriptions>
</Spin>
<h3 style={{ fontSize: '16px' }}></h3>
<div style={{ maxWidth: '1032px' }}>
{/* <Table columns={columns} dataSource={mockdata} bordered scroll={{ x: 'max-content' }}></Table> */}
<ProTable
tableProps={{
showHeader: false,
loading,
rowKey: 'key',
dataSource: data?.detail,
paginationProps: pagination,
columns,
lineFillColor: true,
attrs: {
onChange: onTableChange,
scroll: { x: 'max-content' },
// className: 'remove-last-border',
},
}}
/>
</div>
{data?.reassignmentJson && (
<>
<h3 style={{ fontSize: '16px', marginTop: '22px' }}></h3>
<div>
<a href={`data:,${data?.reassignmentJson}`} rel="noopener" download="reassignment.json">
Reassignment json file
</a>
</div>
</>
)}
</>
);
};
export default RebalancePlan;

View File

@@ -0,0 +1,148 @@
/* eslint-disable react/display-name */
import React, { useState, useEffect } from 'react';
import { Alert, Badge, Dropdown, IconFont, ProTable, Space, Table, Utils } from 'knowdesign';
import { useParams } from 'react-router-dom';
import Api from '@src/api';
import { getTaskDetailsColumns, getMoveBalanceColumns } from './config';
import { ExpandedRow } from './ExpandedRow';
const { request, post } = Utils;
const TaskDetails = (props: any) => {
const { hashData } = props;
const urlParams = useParams<any>(); // 获取地址栏参数
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [expandedData, setExpandedData] = useState([]);
const [loadingObj, setLoadingObj] = useState<any>({});
const [pagination, setPagination] = useState<any>({
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
});
// TODO 获取行详情数据接口
const getRowDetailData = (topicName: string, key?: number) => {
return post(Api.getJobPartitionDetail(urlParams?.clusterId, props?.jobId, topicName));
};
// TODO 获取行详情数据接口
const queryExpandedData = async (record: any, key: any) => {
if (urlParams?.clusterId === undefined) return;
try {
const table = { ...expandedData };
const loading = { ...loadingObj };
getRowDetailData(record.topicName).then((res) => {
table[key] = res;
loading[key] = false;
setExpandedData(table);
setLoadingObj(loading);
});
} catch (error) {
console.log(error);
}
};
const onClickExpand = (expanded: any, record: any) => {
const key = record?.key;
// 之前展开过
if (expandedData[key]?.length) return;
// 第一次展开
const loading = { ...loadingObj };
loading[key] = true;
setLoadingObj(loading);
queryExpandedData(record, key);
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
setPagination(pagination);
// const asc = sorter?.order && sorter?.order === 'ascend' ? true : false;
// const sortColumn = sorter.field && toLine(sorter.field);
// genData({ pageNo: pagination.current, pageSize: pagination.pageSize });
};
const newData =
props?.detailData?.subJobs?.map((item: any, index: number) => {
return {
...item,
key: index,
};
}) || [];
return (
<>
<Alert
message={
<div style={{ color: '#592D00', fontSize: '14px' }}>
<span></span>
<span>Topic总数 {newData?.length}</span>
<span style={{ marginLeft: '34px', display: 'inline-block' }}>
<Badge status="success" />
Success {props?.detailData?.success}
</span>
<span style={{ marginLeft: '34px', display: 'inline-block' }}>
<Badge status="error" />
Fail {props?.detailData?.fail}
</span>
<span style={{ marginLeft: '34px', display: 'inline-block' }}>
<Badge status="warning" />
Doing {props?.detailData?.doing}
</span>
</div>
}
style={{ background: '#FFF9E6', padding: '6px 12px', border: 'none', marginBottom: '12px', borderRadius: '4px' }}
/>
<div className="job-detail">
<ProTable
showQueryForm={false}
tableProps={{
showHeader: false,
rowKey: 'key',
loading: loading,
columns: props?.detailData?.jobType === 1 ? getTaskDetailsColumns() : getMoveBalanceColumns(),
dataSource: newData,
paginationProps: { ...pagination },
// noPagination: true,
attrs: {
bordered: false,
onChange: onTableChange,
tableLayout: 'auto',
scroll: { x: 'max-content' },
expandable: {
expandedRowRender: (r: any) => <ExpandedRow record={r} data={expandedData} loading={loadingObj} />,
// expandedRowRender,
onExpand: onClickExpand,
columnWidth: '20px',
fixed: 'left',
expandIcon: ({ expanded, onExpand, record }: any) => {
return expanded ? (
<IconFont
style={{ fontSize: '16px' }}
type="icon-xia"
onClick={(e: any) => {
onExpand(record, e);
}}
/>
) : (
<IconFont
style={{ fontSize: '16px' }}
type="icon-jiantou_1"
onClick={(e: any) => {
onExpand(record, e);
}}
/>
);
},
},
style: {
width: '1032px',
},
},
}}
/>
</div>
</>
);
};
export default TaskDetails;

View File

@@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react';
import moment from 'moment';
import { useParams } from 'react-router-dom';
import { Button, Drawer, Utils, Descriptions, Tabs, Input, IconFont, message, Spin, InputNumber } from 'knowdesign';
import TaskDetails from './TeskDetails';
import NodeTraffic from './NodeTraffic';
import RebalancePlan from './RebalancePlan';
import { jobTypeEnum, runningStatusEnum } from './config';
import { timeFormat, getSizeAndUnit } from '../../constants/common';
import Api from '@src/api';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
const { request, post } = Utils;
const { TabPane } = Tabs;
export const ViewJobsProgress = (props: any) => {
const urlParams = useParams<any>(); // 获取地址栏参数
const [detailData, setDetailData] = useState(null);
const [loading, setLoading] = useState(false);
const [positionType, setPositionType] = useState<string>('TeskDetails');
const [editFlowLimitStatus, setEditFlowLimitStatus] = useState<boolean>(false); // 修改限流状态
const [flowLimit, setFlowLimit] = useState<any>(); // 修改限流数值
const callback = (key: any) => {
// setSearchValue('');
// setSearchKeywords('');
setPositionType(key);
};
// * 修改限流事件
const editFlowLimit = () => {
if (!flowLimit) {
message.error('限流值不能为空');
return;
}
if (flowLimit < 0) {
message.error('限流值最小值为0');
return;
}
const newSize = Utils.transMBToB(flowLimit);
post(Api.getJobTraffic(urlParams.clusterId, props?.record?.id, newSize)).then((res) => {
message.success('修改限流成功');
getDetailData();
setEditFlowLimitStatus(false);
});
};
// * 获取详情和任务明细数据
const getDetailData = () => {
setLoading(true);
props?.record &&
request(Api.getJobDetail(urlParams.clusterId, props?.record?.id))
.then((res: any) => {
setDetailData(res || []);
const newFlowLimit = Utils.transBToMB(res?.flowLimit as number);
setFlowLimit(newFlowLimit);
// setDetailData(res || mockData);
setLoading(false);
})
.catch((err) => {
setLoading(false);
});
};
useEffect(() => {
getDetailData();
}, [props?.record]);
return (
<Drawer
width={1080}
title="查看进度"
visible={props.visible}
onClose={() => props.setVisble(false)}
destroyOnClose={true}
maskClosable={false}
>
<Spin spinning={loading}>
<Descriptions
style={{ fontSize: '13px' }}
column={3}
labelStyle={{
width: '92px',
textAlign: 'right',
display: 'flex',
justifyContent: 'end',
color: '#74788D',
fontSize: '13px',
}}
contentStyle={{ fontSize: '13px' }}
>
<Descriptions.Item label="任务类型">{jobTypeEnum[detailData?.jobType] || '-'}</Descriptions.Item>
<Descriptions.Item label="运行状态">{runningStatusEnum[detailData?.jobStatus] || '-'}</Descriptions.Item>
<Descriptions.Item label="限流">
{/* 修改限流 */}
<div>
{editFlowLimitStatus ? (
<div className="edit-flow-limit" style={{ display: 'flex', alignItems: 'center' }}>
<InputNumber
controls={false}
size="small"
defaultValue={Utils.transBToMB(detailData?.flowLimit)}
value={flowLimit}
onChange={(e: any) => setFlowLimit(e)}
min={0}
max={99999}
style={{ width: '150px' }}
/>
<CheckOutlined style={{ margin: '0 10px', color: 'green' }} onClick={editFlowLimit} />
<CloseOutlined style={{ color: 'red' }} onClick={() => setEditFlowLimitStatus(false)} />
</div>
) : (
<span>
{detailData?.flowLimit ? Utils.transBToMB(detailData?.flowLimit) + ' MB/s ' : '-'}
{runningStatusEnum[detailData?.jobStatus] && runningStatusEnum[detailData?.jobStatus] !== 'Success' ? (
<IconFont style={{ fontSize: '14px' }} type="icon-bianji1" onClick={() => setEditFlowLimitStatus(true)} />
) : null}
</span>
)}
</div>
</Descriptions.Item>
<Descriptions.Item label="计划执行时间">
{detailData?.planTime ? moment(detailData?.planTime).format(timeFormat) : '-'}
</Descriptions.Item>
<Descriptions.Item label="实际执行时间">
{detailData?.startTime ? moment(detailData?.startTime).format(timeFormat) : '-'}
</Descriptions.Item>
<Descriptions.Item label="任务完成时间">
{detailData?.endTime ? moment(detailData?.endTime).format(timeFormat) : '-'}
</Descriptions.Item>
</Descriptions>
</Spin>
<Tabs className={'custom_tabs_class'} defaultActiveKey="Configuration" onChange={callback} destroyInactiveTabPane>
{props?.record?.jobType === 2 && (
<TabPane tab="均衡计划" key="Rebalance">
<RebalancePlan status={props?.record?.jobStatus} jobId={props?.record?.id} />
</TabPane>
)}
{/* {
<TabPane tab="均衡计划" key="Rebalance">
<RebalancePlan jobId={props?.record?.id} />
</TabPane>
} */}
<TabPane tab="任务明细" key="TeskDetails">
{<TaskDetails detailData={detailData} jobId={props?.record?.id} />}
</TabPane>
<TabPane tab="节点流量情况" key="NodeTraffic">
{<NodeTraffic jobId={props?.record?.id} />}
</TabPane>
</Tabs>
</Drawer>
);
};

View File

@@ -0,0 +1,669 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import React from 'react';
import { timeFormat, getSizeAndUnit } from '../../constants/common';
import moment from 'moment';
import { Tooltip, Tag, Badge, Utils, Progress } from 'knowdesign';
import { renderTableOpts } from 'knowdesign/es/common-pages/render-table-opts';
import TagsWithHide from '@src/components/TagsWithHide';
import { PopoverBroker } from './PopoverBroker';
// 任务类型下拉
export const jobType = [
{
label: 'Topic迁移',
value: 0,
},
{
label: '扩缩副本',
value: 1,
},
process.env.BUSSINESS_VERSION
? {
label: '集群均衡',
value: 2,
}
: undefined,
].filter((t) => t);
//
export const jobTypeEnum: any = {
0: 'Topic迁移',
1: '扩缩副本',
2: '集群均衡',
};
// 运行状态下拉
export const runningStatus = [
{
label: 'Doing',
value: 1,
},
{
label: 'Prepare',
value: 2,
},
{
label: 'Success',
value: 3,
},
{
label: 'Failed',
value: 4,
},
{
label: 'Canceled',
value: 5,
},
];
export const runningStatusEnum: any = {
1: 'Doing',
2: 'Prepare',
3: 'Success',
4: 'Failed',
5: 'Canceled',
};
export const getJobsListColumns = (arg?: any) => {
const columns = [
// {
// title: '任务队列',
// dataIndex: 'taskQueue',
// key: 'taskQueue',
// },
{
title: '任务ID',
dataIndex: 'id',
key: 'id',
},
{
title: '任务类型',
dataIndex: 'jobType',
key: 'jobType',
render(t: any, r: any) {
return jobTypeEnum[t];
},
},
{
title: '任务执行对象',
dataIndex: 'target',
key: 'target',
render(t: any, r: any) {
return (
<div style={{ width: '232px' }}>
<TagsWithHide placement="bottom" list={t.split(',')} expandTagContent={(num: any) => `共有${num}`} />
</div>
);
},
},
{
title: '运行状态',
dataIndex: 'jobStatus',
key: 'jobStatus',
render(t: any, r: any) {
return (
<Tag
className="12312512512"
style={{
background: t === 1 ? 'rgba(85,110,230,0.10)' : t === 4 ? '#fff3e4' : t === 3 ? 'rgba(0,192,162,0.10)' : '#ebebf6',
color: t === 1 ? '#556EE6' : t === 4 ? '#F58342' : t === 3 ? '#00C0A2' : '#495057',
padding: '3px 6px',
}}
>
{runningStatusEnum[t]}
</Tag>
);
},
},
{
title: '运行进度',
dataIndex: 'progress',
key: 'progress',
width: 90,
render: (_t: any, r: any) => {
const { success, total } = r;
return (success || 0) + '/' + (total || 0);
},
},
{
title: '运行结果',
dataIndex: 'result',
key: 'result',
// eslint-disable-next-line react/display-name
render: (_t: any, r: any) => {
const { success, fail, waiting, doing } = r;
return (
<div className="run-result">
<span>
:
<span>
{success === 0 || success ? (
(success + '').length < 3 ? (
success
) : (
<Tooltip title={success}>{(success + '').slice(0, 2) + '...'}</Tooltip>
)
) : (
'-'
)}
</span>
</span>
<span>
:
{fail === 0 || fail ? (fail + '').length < 3 ? fail : <Tooltip title={fail}>{(fail + '').slice(0, 2) + '...'}</Tooltip> : '-'}
</span>
<span>
:
{doing === 0 || doing ? (
(doing + '').length < 3 ? (
doing
) : (
<Tooltip title={doing}>{(doing + '').slice(0, 2) + '...'}</Tooltip>
)
) : (
'-'
)}
</span>
<span>
:
{waiting === 0 || waiting ? (
(waiting + '').length < 3 ? (
waiting
) : (
<Tooltip title={waiting}>{(waiting + '').slice(0, 2) + '...'}</Tooltip>
)
) : (
'-'
)}
</span>
</div>
);
},
},
{
title: '描述',
dataIndex: 'jobDesc',
key: 'jobDesc',
width: 150,
needTooltip: true,
},
{
title: '提交人',
dataIndex: 'creator',
key: 'creator',
},
{
title: '计划执行时间',
dataIndex: 'planTime',
width: 160,
key: 'planTime',
render: (t: any, r: any) => {
return t ? moment(t).format(timeFormat) : '-';
},
},
{
title: '实际执行时间',
dataIndex: 'startTime',
key: 'startTime',
width: 160,
render: (t: any, r: any) => {
if (moment(t).format('x') < moment(r.planTime).format('x')) {
return '已逾期';
}
// 判断是否为 mysql 默认的1971-01-01 00:00:00
if (+moment(t).format('x') === 31507200000) {
return '-';
}
return t ? moment(t).format(timeFormat) : '-';
},
},
{
title: '操作',
dataIndex: 'options',
key: 'options',
width: 150,
filterTitle: true,
fixed: 'right',
// eslint-disable-next-line react/display-name
render: (_t: any, r: any) => {
return (
<div>
{r.jobStatus !== 2 && r.jobStatus !== 5 ? <a onClick={() => arg.setViewProgress(r)}></a> : null}
{/* 编辑任务 */}
{r.jobStatus === 2 ? (
<a style={{ marginRight: '16px' }} onClick={() => arg.setViewProgress(r, r.jobType)}>
</a>
) : null}
{r.jobStatus === 2 ? <a onClick={() => arg.onDelete(r)}></a> : null}
</div>
);
},
},
];
return columns;
};
// * 获取任务明细-扩缩副本列表配置
export const getTaskDetailsColumns = (arg?: any) => {
const columns = [
{
title: 'Topic',
dataIndex: 'topicName',
key: 'topicName',
fixed: 'left',
width: 102,
render: (t: any, r: any) => {
return (
<span>
{t}
<Badge
style={{ marginLeft: '6px' }}
status={r?.status === 1 ? 'warning' : r?.status === 4 ? 'error' : r?.status === 3 ? 'success' : 'warning'}
/>
</span>
);
},
},
{
title: '当前副本数',
dataIndex: 'oldReplicaNu',
key: 'oldReplicaNu',
},
{
title: '源BrokerID',
dataIndex: 'sourceBrokers',
key: 'sourceBrokers',
render: (t: any, r: any) => {
return t && t.length > 0 ? t.join('、') : '-';
},
// render(t: any, r: any) {
// return (
// <div style={{ width: '100px' }}>
// <TagsWithHide list={t} expandTagContent={(num: any) => `共有${num}个`} />
// </div>
// );
// },
},
{
title: '新副本数',
dataIndex: 'newReplicaNu',
key: 'newReplicaNu',
},
{
title: '目标BrokerID',
dataIndex: 'desBrokers',
key: 'desBrokers',
render: (t: any, r: any) => {
return t && t.length > 0 ? t.join('、') : '-';
},
// render(t: any, r: any) {
// return (
// <div style={{ width: '100px' }}>
// <TagsWithHide list={t} expandTagContent={(num: any) => `共有${num}个`} />
// </div>
// );
// },
},
{
title: 'MessageSize迁移进度(MB)',
dataIndex: 'movedSize',
key: 'movedSize',
render(t: any, r: any) {
const movedSize = r.movedSize ? Number(Utils.formatAssignSize(t, 'MB')) : 0;
const totalSize = r.totalSize ? Number(Utils.formatAssignSize(t, 'MB')) : 0;
return (
<div className="message-size">
<Tooltip title={(movedSize > 0 && totalSize > 0 ? (movedSize / totalSize) * 100 : 0) + '%'}>
<Progress
percent={movedSize > 0 && totalSize > 0 ? (movedSize / totalSize) * 100 : 0}
strokeColor="#556EE6"
showInfo={false}
/>
</Tooltip>
<span>{movedSize + '/' + totalSize}</span>
</div>
);
},
},
{
title: '分区进度',
dataIndex: 'partitionRate',
key: 'partitionRate',
render: (t: any, r: any) => {
return (r.success || r.success === 0 ? r.success : '-') + '/' + (r.total || r.total === 0 ? r.total : '-');
},
},
{
title: '预计剩余时长',
dataIndex: 'remainTime',
key: 'remainTime',
render(t: any, r: any) {
return t ? Utils.transUnitTime(t) : t === 0 ? t : '-';
},
},
// {
// title: '当前副本数',
// dataIndex: 'progress',
// key: 'progress',
// render: (_t: any, r: any) => {
// return r.success + '/' + (r.success + r.doing + r.fail);
// },
// },
];
return columns;
};
// * 获取任务明细-Topic迁移列表配置
export const getMoveBalanceColumns = (arg?: any) => {
const columns = [
{
title: 'Topic',
dataIndex: 'topicName',
key: 'topicName',
fixed: 'left',
width: 102,
render: (t: any, r: any) => {
return (
<span>
{t}
<Badge
style={{ marginLeft: '6px' }}
status={r?.status === 1 ? 'warning' : r?.status === 4 ? 'error' : r?.status === 3 ? 'success' : 'warning'}
/>
</span>
);
},
},
{
title: '分区',
dataIndex: 'partitions',
key: 'partitions',
render: (t: any, r: any) => {
return t && t.length > 0 ? t.join('、') : '-';
},
},
{
title: '源BrokerID',
dataIndex: 'sourceBrokers',
key: 'sourceBrokers',
render: (t: any, r: any) => {
return t && t.length > 0 ? t.join('、') : '-';
},
// render(t: any, r: any) {
// return (
// <div style={{ width: '100px' }}>
// <TagsWithHide list={t} expandTagContent={(num: any) => `共有${num}个`} />
// </div>
// );
// },
},
{
title: '目标BrokerID',
dataIndex: 'desBrokers',
key: 'desBrokers',
render: (t: any, r: any) => {
return t && t.length > 0 ? t.join('、') : '-';
},
// render(t: any, r: any) {
// return (
// <div style={{ width: '100px' }}>
// <TagsWithHide list={t} expandTagContent={(num: any) => `共有${num}个`} />
// </div>
// );
// },
},
{
title: '当前数据保存时间 h',
dataIndex: 'currentTimeSpent',
key: 'currentTimeSpent',
render: (t: any, r: any) => {
return t || t === 0 ? Utils.transMSecondToHour(+t) : '-';
},
},
{
title: '迁移数据时间范围 h',
dataIndex: 'moveTimeSpent',
key: 'moveTimeSpent',
render: (t: any, r: any) => {
return t || t === 0 ? Utils.transMSecondToHour(+t) : '-';
},
},
{
title: 'MessageSize迁移进度(MB)',
dataIndex: 'movedSize',
key: 'movedSize',
render(t: any, r: any) {
const movedSize = r.movedSize ? Number(Utils.formatAssignSize(t, 'MB')) : 0;
const totalSize = r.totalSize ? Number(Utils.formatAssignSize(t, 'MB')) : 0;
return (
<div className="message-size">
<Tooltip title={(movedSize > 0 && totalSize > 0 ? (movedSize / totalSize) * 100 : 0) + '%'}>
<Progress
percent={movedSize > 0 && totalSize > 0 ? (movedSize / totalSize) * 100 : 0}
strokeColor="#556EE6"
showInfo={false}
/>
</Tooltip>
<span>{movedSize + '/' + totalSize}</span>
</div>
);
},
},
// {
// title: '需迁移MessageSizeMB',
// dataIndex: 'totalSize',
// key: 'totalSize',
// width: 100,
// render: (t: any, r: any) => {
// return t || t === 0 ? formatAssignSize(t, 'MB') : '-';
// },
// },
{
title: '分区进度',
dataIndex: 'partitionProgress',
key: 'partitionProgress',
render: (t: any, r: any) => {
return (r.success || r.success === 0 ? r.success : '-') + '/' + (r.total || r.total === 0 ? r.total : '-');
},
},
// {
// title: '已完成MessageSize MB',
// dataIndex: 'movedSize',
// key: 'movedSize',
// width: 100,
// render: (t: any, r: any) => {
// return t || t === 0 ? formatAssignSize(t, 'MB') : '-';
// },
// },
// {
// title: '状态',
// dataIndex: 'status',
// key: 'status',
// width: 100,
// render: (t: any) => {
// return (
// <span>
// <Badge status={t === 1 ? 'warning' : t === 4 ? 'error' : t === 3 ? 'success' : 'warning'} />
// {runningStatusEnum[t]}
// </span>
// );
// },
// },
{
title: '预计剩余时长',
dataIndex: 'remainTime',
key: 'remainTime',
render(t: any, r: any) {
return t ? Utils.transUnitTime(t) : t === 0 ? t : '-';
},
},
// {
// title: '当前副本数',
// dataIndex: 'progress',
// key: 'progress',
// render: (_t: any, r: any) => {
// return r.success + '/' + (r.success + r.doing + r.fail);
// },
// },
];
return columns;
};
// * 获取任务明细-集群均衡列表配置
export const getClusterBalanceColumns = (arg?: any) => {
const columns = [
{
title: 'Topic',
dataIndex: 'topicName',
key: 'topicName',
width: 102,
needTooltip: true,
},
{
title: '分区',
dataIndex: 'partition',
key: 'partition',
},
{
title: '源BrokerID',
dataIndex: 'sourceBrokerIds',
key: 'sourceBrokerIds',
},
{
title: '目标BrokerID',
dataIndex: 'desBrokerIds',
key: 'desBrokerIds',
},
{
title: '当前数据保存时间 h',
dataIndex: 'currentTimeSpent',
key: 'currentTimeSpent',
},
{
title: '迁移数据时间范围 h',
dataIndex: 'currentTimeSpent',
key: 'currentTimeSpent',
},
{
title: '需迁移MessageSizeMB',
dataIndex: 'totalSize',
key: 'totalSize',
width: 100,
},
{
title: '分区进度',
dataIndex: 'partitionProgress',
key: 'partitionProgress',
},
{
title: '已完成MessageSize MB',
dataIndex: 'movedSize',
key: 'movedSize',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render(t: any, r: any) {
return runningStatusEnum[t];
},
width: 100,
},
{
title: '预计剩余时长',
dataIndex: 'remainTime',
key: 'remainTime',
},
// {
// title: '当前副本数',
// dataIndex: 'progress',
// key: 'progress',
// render: (_t: any, r: any) => {
// return r.success + '/' + (r.success + r.doing + r.fail);
// },
// },
];
return columns;
};
export const getNodeTrafficColumns = (arg?: any) => {
const columns = [
{
title: 'Broker',
dataIndex: 'brokerId',
key: 'brokerId',
},
{
title: 'Host',
dataIndex: 'brokerHost',
key: 'brokerHost',
},
{
title: 'Job Bytes InMB/s',
dataIndex: 'byteInJob',
key: 'byteInJob',
render: (t: any, r: any) => {
return (
<div>
<span>{t || t === 0 ? Utils.transBToMB(t) : '-'}</span>
{<PopoverBroker title="Job Bytes In" data={r?.inBrokers} />}
</div>
);
},
},
{
title: 'Job Bytes OutMB/s',
dataIndex: 'byteOutJob',
key: 'byteOutJob',
render: (t: any, r: any) => {
return (
<div>
<span>{t || t === 0 ? Utils.transBToMB(t) : '-'}</span>
{r?.outBrokers && <PopoverBroker title="Job Bytes Out" data={r?.outBrokers} />}
</div>
);
},
},
{
title: 'Total BytesInMB/s',
dataIndex: 'byteInTotal',
key: 'byteInTotal',
render: (t: any, r: any) => {
return t || t === 0 ? Utils.transBToMB(t) : '-';
},
},
{
title: 'Total Bytes OutMB/s',
dataIndex: 'byteOutTotal',
key: 'byteOutTotal',
render: (t: any, r: any) => {
return t || t === 0 ? Utils.transBToMB(t) : '-';
},
},
];
return columns;
};
export const defaultPagination = {
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
};
const KB = 1024;
const MB = KB * KB;
const GB = MB * KB;
const TB = GB * KB;
export const formatAssignSize = (size: number, convertTarget: string, fix = 2) => {
if (size === undefined || size === null) return '';
if (convertTarget === undefined || convertTarget === null) return size;
if (convertTarget === 'KB') return `${(size / KB).toFixed(fix)}`;
if (convertTarget === 'MB') return `${(size / MB).toFixed(fix)}`;
if (convertTarget === 'GB') return `${(size / GB).toFixed(fix)}`;
return `${(size / TB).toFixed(fix)}TB`;
};

View File

@@ -0,0 +1,156 @@
// .table {
// background: #F8F9FA;
// // margin-top: 12px;
// border-radius: 8px;
// .dcloud-table {
// height: 210px;
// overflow: auto;
// background-color: transparent;
// .dcloud-table-content .dcloud-table-cell {
// background-color: transparent;
// }
// }
// .dcloud-pagination {
// height: 32px;
// margin-bottom: 0;
// margin-top: 8px;
// }
// }
.edit-flow-limit {
display: flex;
align-items: center;
input {
height: 20px;
}
}
.custom-expanded-table {
border-radius: 20px;
.dcloud-table-container {
overflow: hidden;
border-radius: 4px;
}
.dcloud-table {
td,
th {
background-color: #f8f9fa !important;
}
}
}
.custom-popover-borker {
width: 281px;
padding-top: 0 !important;
.dcloud-popover-arrow {
display: none;
}
.dcloud-popover-inner {
border-radius: 12px !important;
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.04), 0 6px 12px 12px rgba(0, 0, 0, 0.04), 0 6px 10px 0 rgba(0, 0, 0, 0.08);
}
.dcloud-popover-inner-content {
padding: 0 24px 12px;
}
.dcloud-popover-title {
padding: 12px 24px;
font-family: @font-family-bold;
font-size: 16px;
border-bottom: 0;
}
// .dcloud-table{
// margin: 0 !important;
// }
}
.job-detail {
.dcloud-table-cell {
padding: 7px 16px 8px 2px !important;
}
.dcloud-table-row-expand-icon-cell {
padding: 7px 7px 5px 24px !important;
}
.dcloud-table-expanded-row-fixed {
padding: 16px 20px !important;
.dcloud-table-cell {
font-size: 12px !important;
}
}
}
.run-result {
& > span {
display: inline-block;
}
& > span:not(:last-child) {
margin-right: 18px;
}
}
.expanded-table {
.dcloud-table-container::after {
box-shadow: none !important;
}
.dcloud-pagination{
margin-bottom: 0 !important;
}
}
.balance-list-title {
font-size: 12px;
color: #74788d;
display: inline-flex;
align-items: center;
.titleImg {
display: inline-block;
width: 16px;
height: 17px;
margin-left: 2px;
background-image: url('../../assets/arrowSmall.svg');
background-position: center, center;
background-repeat: no-repeat, repeat;
}
}
.balance-list-contert {
display: inline-flex;
align-items: center;
&-text,&-text-right{
display: inline-block;
width: 50px;
}
&-text-right{
text-align: right;
color: #556EE6;
}
&-img {
display: inline-block;
width: 26px;
height: 17px;
margin: 0 3px 0 5px;
background-image: url('../../assets/arrowLarge.svg');
background-position: center, center;
background-repeat: no-repeat, repeat;
}
}
.message-size {
display: flex;
align-items: center;
.dcloud-progress {
width: 140px;
&-inner {
background-color: #ececf1;
border-radius: 5px;
}
}
& > span {
margin-left: 4px;
color: #adb5bc;
transform: scale(0.83, 0.83);
text-align: left;
}
}

View File

@@ -0,0 +1,285 @@
import React, { useState, useEffect, memo } from 'react';
import { useParams, useHistory, useLocation } from 'react-router-dom';
import { ProTable, Drawer, Utils, AppContainer, Form, Select, Input, Button, message, Modal } from 'knowdesign';
import API from '../../api';
import { getJobsListColumns, defaultPagination, runningStatus, jobType } from './config';
import JobsCheck from '@src/components/CardBar/JobsCheck';
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
import { ViewJobsProgress } from './ViewJobsProgress';
import './index.less';
import ReplicaChange from '@src/components/TopicJob/ReplicaChange';
import ReplicaMove from '@src/components/TopicJob/ReplicaMove';
import BalanceDrawer from '../LoadRebalance/BalanceDrawer';
const { request } = Utils;
const JobsList: React.FC = (props: any) => {
const [global] = AppContainer.useGlobalValue();
const [form] = Form.useForm();
const urlParams = useParams<any>(); // 获取地址栏参数
const history = useHistory();
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [searchWords, setSearchWords] = useState<{ type: any; jobTarget: any; status: any }>();
// const [filteredInfo, setFilteredInfo] = useState(null);
const [pagination, setPagination] = useState<any>(defaultPagination);
const [viewVisble, setViewVisble] = useState<any>(false);
const [record, setRecord] = useState(null); // 获取当前点击行的数据;
const [changeVisible, setChangeVisible] = useState(false);
const [moveVisible, setMoveVisible] = useState(false);
const [balanceVisible, setBalanceVisible] = useState(false);
const [balanceFromData, setBalanceFormData] = useState(false);
// 默认排序
const defaultSorter = {
sortField: 'brokerId',
sortType: 'asc',
};
// 请求接口获取数据
const genData = async ({ pageNo, pageSize }: any) => {
if (urlParams?.clusterId === undefined) return;
// filters = filters || filteredInfo;
setLoading(true);
const params = {
...searchWords,
pageNo,
pageSize,
type: searchWords?.type === 0 || searchWords?.type ? searchWords?.type : -1,
};
request(API.getJobsList(urlParams?.clusterId), { method: 'POST', data: params })
.then((res: any) => {
setPagination({
current: res.pagination?.pageNo,
pageSize: res.pagination?.pageSize,
total: res.pagination?.total,
});
setData(res?.bizData || []);
setLoading(false);
})
.catch((err) => {
setData([]);
setLoading(false);
});
};
// 获取立即均衡formData
const getBalanceFormData = (jobId: any, clusterId: any) => {
const params = {
clusterId,
jobId,
};
return request(API.getJobsTaskData(params.clusterId, params.jobId), params);
};
// 查询
const onFinish = (formData: any) => {
setSearchWords(formData);
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
// setFilteredInfo(filters);
genData({ pageNo: pagination.current, pageSize: pagination.pageSize, filters, sorter });
};
const getSearchKeywords = (value: string) => {
// setSearchKeywords(value);
};
// 删除modal
const onDelete = (record: any) => {
Modal.confirm({
title: `确认删除"任务【${record.id}】"吗?`,
okText: '删除',
okType: 'primary',
centered: true,
cancelText: '取消',
okButtonProps: {
// disabled: record.status === 1,
size: 'small',
danger: true,
},
cancelButtonProps: {
size: 'small',
},
maskClosable: false,
onOk(close) {
// 缺少判断当前任务是否是doing的状态
return Utils.delete(API.getJobsDelete(urlParams?.clusterId, record?.id)).then((_) => {
message.success('删除任务成功');
// getConfigList();
genData({
pageNo: 1,
pageSize: pagination.pageSize,
});
close();
});
},
});
};
const setViewProgress = (record: any, visibleType?: number) => {
setRecord(record);
if (visibleType === 0) {
setMoveVisible(true);
} else if (visibleType === 1) {
setChangeVisible(true);
} else if (visibleType === 2) {
getBalanceFormData(record.id, urlParams.clusterId).then((res: any) => {
const jobData = (res && JSON.parse(res?.jobData)) || {};
setBalanceFormData({
...jobData,
jobId: record.id,
record,
jobData,
});
setBalanceVisible(true);
});
} else {
setViewVisble(true);
}
};
const onclose = () => {
setChangeVisible(false);
setMoveVisible(false);
setBalanceVisible(false);
setRecord(null);
};
useEffect(() => {
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, [searchWords]);
return (
<div key="brokerList">
<div className="breadcrumb">
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Job', aHref: `` },
]}
/>
</div>
<div style={{ margin: '12px 0' }}>
<JobsCheck />
</div>
{/* <Form form={form} layout="inline" onFinish={onFinish}> */}
<div className="clustom-table-content">
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
<Form form={form} layout="inline" onFinish={onFinish}>
<Form.Item name="type">
<Select
allowClear
options={jobType}
style={{ width: '190px' }}
className={'detail-table-select'}
placeholder="选择任务类型"
/>
</Form.Item>
<Form.Item name="jobTarget">
<Input allowClear style={{ width: '190px' }} placeholder="请输入执行任务对象" />
</Form.Item>
<Form.Item name="status">
<Select
mode="multiple"
maxTagCount={1}
options={runningStatus}
style={{ width: '190px' }}
className={'detail-table-select'}
placeholder="选择运行状态"
showArrow
allowClear
/>
</Form.Item>
</Form>
<div>
<Form style={{ justifyContent: 'flex-end' }} form={form} layout="inline" onFinish={onFinish}>
<Form.Item style={{ marginRight: 0 }}>
<Button type="primary" ghost htmlType="submit">
</Button>
</Form.Item>
</Form>
</div>
</div>
{/* </Form> */}
<ProTable
key="brokerTable"
showQueryForm={false}
tableProps={{
tableId: 'jobs_list',
showHeader: false,
rowKey: 'jobs_list',
loading: loading,
columns: getJobsListColumns({ onDelete, setViewProgress }),
dataSource: data,
paginationProps: { ...pagination },
attrs: {
// className: 'frameless-table', // 纯无边框表格类名
onChange: onTableChange,
scroll: { x: 'max-content', y: 'calc(100vh - 400px)' }, // calc(100vh - 270px)
bordered: false,
},
}}
/>
</div>
{viewVisble && <ViewJobsProgress visible={viewVisble} setVisble={setViewVisble} record={record} />}
{/* 批量扩缩副本 */}
{changeVisible && (
<ReplicaChange
drawerVisible={changeVisible}
jobId={record?.id}
jobStatus={record?.jobStatus}
topics={record?.target?.split(',') || []}
onClose={onclose}
genData={() =>
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
})
}
></ReplicaChange>
)}
{/* 批量迁移 */}
{moveVisible && (
<ReplicaMove
drawerVisible={moveVisible}
jobId={record?.id}
jobStatus={record?.jobStatus}
topics={record?.target?.split(',') || []}
onClose={onclose}
genData={() =>
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
})
}
></ReplicaMove>
)}
{/* 立即均衡 */}
{balanceVisible && (
<BalanceDrawer
visible={balanceVisible}
formData={balanceFromData}
onClose={onclose}
genData={() =>
genData({
pageNo: 1,
pageSize: pagination.pageSize,
})
}
/>
)}
</div>
);
};
export default JobsList;

View File

@@ -0,0 +1,706 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Utils,
Drawer,
Button,
Form,
Space,
Divider,
AppContainer,
Radio,
InputNumber,
Transfer,
Select,
message,
IconFont,
Tooltip,
} from 'knowdesign';
import CronInput from './CronInput';
import BalanceEditTable from './BalanceEditTable';
import PlanDrawer from './PlanDrawer';
import api from '../../api';
import './style/BalanceDrawer.less';
interface PropsType {
onClose: Function;
visible: boolean;
isCycle?: boolean;
formData?: any;
genData?: any;
}
const IndexCalculations = [
{
label: '近5mins',
value: 5 * 60,
},
{
label: '近10mins',
value: 10 * 60,
},
{
label: '近30mins',
value: 30 * 60,
},
{
label: '近1h',
value: 60 * 60,
},
{
label: '近6h',
value: 6 * 60 * 60,
},
{
label: '近12h',
value: 12 * 60 * 60,
},
{
label: '近24h',
value: 24 * 60 * 60,
},
];
const BalancedDimensions = [
// 本期没有CPU
// {
// label: 'CPU',
// value: 'cpu',
// },
{
label: 'Disk',
value: 'disk',
},
{
label: 'BytesIn',
value: 'bytesIn',
},
{
label: 'BytesOut',
value: 'bytesOut',
},
];
const Schedules1 = [
{
label: '每天',
value: 1,
},
{
label: '每周',
value: 2,
},
{
label: '每月',
value: 3,
},
];
const SchedulesWeeks = [
{
label: '每周一',
value: 1,
},
{
label: '每周二',
value: 2,
},
{
label: '每周三',
value: 3,
},
{
label: '每周四',
value: 4,
},
{
label: '每周五',
value: 5,
},
{
label: '每周六',
value: 6,
},
{
label: '每周日',
value: 7,
},
];
const SchedulesMouths: any[] = [];
for (let i = 1; i <= 28; i++) {
SchedulesMouths.push({
label: `每月${i}`,
value: i,
});
}
const BalanceDrawer: React.FC<PropsType> = ({ onClose, visible, isCycle = false, formData, genData }) => {
const [global] = AppContainer.useGlobalValue();
const [form] = Form.useForm();
const customFormRef = useRef<any>();
const [nodeData, setNodeData] = useState([]);
const [nodeTargetKeys, setNodeTargetKeys] = useState([]);
const [topicData, setTopicData] = useState([]);
const [topicTargetKeys, setTopicTargetKeys] = useState([]);
const [tableData, setTableData] = useState<any[]>([]);
const [dimension, setDimension] = useState<string[]>(['disk', 'bytesIn', 'bytesOut']);
const [planVisible, setPlanVisible] = useState<boolean>(false);
const [planDetailData, setPlanDetailData] = useState({});
const [parallelNum, setParallelNum] = useState(0);
const [executionStrategy, setExecutionStrategy] = useState(1);
useEffect(() => {
getNodeList();
getTopicList();
}, []);
useEffect(() => {
init();
}, [visible, formData]);
useEffect(() => {
setNodeTargetKeys(formData?.brokers || []);
}, [nodeData]);
useEffect(() => {
setTopicTargetKeys(formData?.topicBlackList || []);
}, [topicData]);
const init = () => {
if (formData && Object.keys(formData).length > 0) {
console.log(formData, '有FormData');
const tableData = formData?.clusterBalanceIntervalList?.map((item: any) => {
const finfIndex = BalancedDimensions.findIndex((item1) => item1?.value === item?.type);
return {
...item,
name: BalancedDimensions[finfIndex]?.label,
};
});
setTableData(tableData || []);
const dimension = formData?.clusterBalanceIntervalList?.map((item: any) => {
return item?.type;
});
form.setFieldsValue({
...formData,
brokers: formData?.brokers || [],
topicBlackList: formData?.topicBlackList || [],
dimension,
throttleUnitM: formData?.throttleUnitB / 1024 / 1024,
});
} else {
const defaultDimension = ['disk', 'bytesIn', 'bytesOut'];
form.resetFields();
form.setFieldsValue({ dimension: defaultDimension, metricCalculationPeriod: 600 });
const res = defaultDimension?.map((item, index) => {
const finfIndex = BalancedDimensions.findIndex((item1) => item1.value === item);
return {
type: item,
name: BalancedDimensions[finfIndex]?.label,
intervalPercent: 10,
priority: index + 1,
};
});
console.log(res, '表单回显立即均衡');
setTableData(res);
setDimension(['disk', 'bytesIn', 'bytesOut']);
setNodeTargetKeys([]);
setTopicTargetKeys([]);
}
};
const submit = () => {
// 周期均衡 / 立即均衡
customFormRef.current.editTableValidate().then((res: any) => {
form.validateFields().then((values) => {
const params = {
...values,
clusterId: global?.clusterInfo?.id,
clusterBalanceIntervalList: tableData,
scheduleJob: isCycle || false,
throttleUnitB: values?.throttleUnitM * 1024 * 1024,
};
if (!isCycle) {
if (values?.priority === 'throughput') {
params.parallelNum = 0;
params.executionStrategy = 1;
} else if (values?.priority === 'stability') {
params.parallelNum = 1;
params.executionStrategy = 2;
}
}
if (formData?.jobId) {
const handledData = {
creator: JSON.parse(global?.userInfo)?.userName,
jobType: 2, // type 0 topic迁移 1 扩缩容 2集群均衡
planTime: formData?.record?.planTime,
jobStatus: formData?.record?.jobStatus || 2, //status 2 创建
target: formData?.record?.target,
id: formData?.record?.id,
jobDesc: formData?.record?.description || '',
jobData: JSON.stringify({ ...formData?.jobData, ...params }),
};
Utils.put(api.putJobsTaskData(global?.clusterInfo?.id), handledData)
.then(() => {
message.success('集群均衡任务编辑成功');
drawerClose(true);
setPlanVisible(false);
genData();
})
.catch((err: any) => {
console.log(err, 'err');
});
} else {
Utils.request(api.balanceStrategy(global?.clusterInfo?.id), {
method: 'POST',
data: params,
}).then((res: any) => {
const dataDe = res || [];
message.success(isCycle ? '成功创建周期均衡策略' : '成功创建立即均衡策略');
drawerClose(true);
setPlanVisible(false);
isCycle && genData();
});
}
});
});
};
const preview = () => {
// 立即均衡
customFormRef.current.editTableValidate().then((res: any) => {
form.validateFields().then((values) => {
const params = {
...values,
clusterId: global?.clusterInfo?.id,
clusterBalanceIntervalList: tableData,
scheduleJob: isCycle || false,
throttleUnitB: values?.throttleUnitM * 1024 * 1024,
// parallelNum: !isCycle ? values?.priority === 'throughput'? 0 : : null
};
if (!isCycle) {
if (values?.priority === 'throughput') {
params.parallelNum = 0;
params.executionStrategy = 1;
} else if (values?.priority === 'stability') {
params.parallelNum = 1;
params.executionStrategy = 2;
}
}
// 预览计划
if (formData?.jobId) {
Utils.request(api.getBalancePlan(global?.clusterInfo?.id, formData?.jobId), {
method: 'GET',
}).then((res: any) => {
const dataDe = res || {};
setPlanDetailData(dataDe);
setPlanVisible(true);
});
} else {
Utils.request(api.balancePreview(global?.clusterInfo?.id), {
method: 'POST',
data: params,
}).then((res: any) => {
const dataDe = res || {};
setPlanDetailData(dataDe);
setPlanVisible(true);
});
}
});
});
};
const nodeChange = (val: any) => {
setNodeTargetKeys(val);
};
const topicChange = (val: any) => {
setTopicTargetKeys(val);
};
const getNodeList = () => {
Utils.request(api.getBrokersMetaList(global?.clusterInfo?.id), {
method: 'GET',
}).then((res: any) => {
const dataDe = res || [];
const dataHandle = dataDe.map((item: any) => {
return {
...item,
key: item.brokerId,
title: `${item.brokerId} (${item.host})`,
};
});
setNodeData(dataHandle);
});
};
const getTopicList = () => {
Utils.request(api.getTopicMetaList(global?.clusterInfo?.id), {
method: 'GET',
}).then((res: any) => {
const dataDe = res || [];
const dataHandle = dataDe.map((item: any) => {
return {
...item,
key: item.topicName,
title: item.topicName,
};
});
setTopicData(dataHandle);
});
};
const dimensionChange = (val: string[]) => {
const res = val?.map((item, index) => {
const finfIndex = BalancedDimensions.findIndex((item1) => item1.value === item);
const tableIndex = tableData?.findIndex((item2) => item2.type === item);
return {
type: item,
name: BalancedDimensions[finfIndex]?.label,
intervalPercent: tableIndex > -1 ? tableData[tableIndex].intervalPercent : 10,
priority: index + 1,
};
});
setTableData(res);
setDimension(val);
};
const tableDataChange = (data: any[]) => {
setTableData(data);
};
const planClose = () => {
setPlanVisible(false);
// onClose();
};
const balanceImmediate = () => {
submit();
};
const drawerClose = (isArg?: boolean) => {
isArg ? onClose(isArg) : onClose();
form.resetFields();
};
const priorityChange = (e: any) => {
if (e.target.value === 'throughput') {
setParallelNum(0);
setExecutionStrategy(1);
} else if (e.target.value === 'stability') {
setParallelNum(1);
setExecutionStrategy(2);
} else {
form.setFieldsValue({ parallelNum, executionStrategy });
}
};
return (
<>
<PlanDrawer
visible={planVisible}
onClose={planClose}
balanceImmediate={balanceImmediate}
detailData={planDetailData}
isPrevew={true}
isEdit={formData?.jobId ? true : false}
/>
<Drawer
title={isCycle ? '周期均衡' : '立即均衡'}
width="600px"
destroyOnClose={true}
className="balance-drawer"
onClose={() => drawerClose()}
visible={visible}
maskClosable={false}
extra={
<Space>
<Button size="small" onClick={() => drawerClose()}>
</Button>
{isCycle ? (
<Button type="primary" size="small" disabled={false} onClick={submit}>
</Button>
) : (
<Button className="btn-width84" type="primary" size="small" disabled={false} onClick={preview}>
</Button>
)}
<Divider type="vertical" />
</Space>
}
>
<Form
form={form}
layout="vertical"
preserve={false}
initialValues={{
status: 1,
}}
>
{/* {!isCycle && (
<>
<h6 className="form-title">均衡节点范围</h6>
<Form.Item
name="brokers"
rules={[
{
required: true,
message: `请选择!`,
},
]}
>
<Transfer
dataSource={nodeData}
titles={['待选节点', '已选节点']}
customHeader
showSelectedCount
locale={{
itemUnit: '',
itemsUnit: '',
}}
showSearch
filterOption={(inputValue, option) => option.host.indexOf(inputValue) > -1}
targetKeys={nodeTargetKeys}
onChange={nodeChange}
render={(item) => item.title}
suffix={<IconFont type="icon-fangdajing" />}
/>
</Form.Item>
</>
)} */}
<h6 className="form-title"></h6>
<Form.Item
name="metricCalculationPeriod"
label="指标计算周期"
rules={[
{
required: true,
message: `请选择!`,
},
]}
>
<Select placeholder={`请选择指标计算周期`} options={IndexCalculations} />
</Form.Item>
<Form.Item
label="均衡维度"
name="dimension"
rules={[
{
required: true,
message: `请选择!`,
},
]}
>
<Select
placeholder={`请选择均衡维度`}
mode="multiple"
value={dimension}
options={BalancedDimensions}
onChange={dimensionChange}
/>
</Form.Item>
<Form.Item>
<BalanceEditTable ref={customFormRef} tableData={tableData} tableDataChange={tableDataChange} />
</Form.Item>
<Form.Item
name="topicBlackList"
label="Topic黑名单"
rules={[
{
required: false,
message: `请选择!`,
},
]}
>
<Transfer
dataSource={topicData}
titles={['待选黑名单', '已选黑名单']}
customHeader
showSelectedCount
locale={{
itemUnit: '',
itemsUnit: '',
}}
showSearch
filterOption={(inputValue, option) => option.topicName.indexOf(inputValue) > -1}
targetKeys={topicTargetKeys}
onChange={topicChange}
render={(item) => item.title}
suffix={<IconFont type="icon-fangdajing" />}
/>
</Form.Item>
<h6 className="form-title"></h6>
{!isCycle && (
<Form.Item label="" name="priority" rules={[{ required: true, message: 'Principle 不能为空' }]} initialValue="throughput">
<Radio.Group onChange={priorityChange}>
<Radio value="throughput"></Radio>
<Radio value="stability"></Radio>
<Radio value="custom"></Radio>
</Radio.Group>
</Form.Item>
)}
{!isCycle && (
<Form.Item dependencies={['priority']} style={{ marginBottom: 0 }}>
{({ getFieldValue }) =>
getFieldValue('priority') === 'custom' ? (
<div className="form-item-group">
<Form.Item
name="parallelNum"
label={
<span>
<Tooltip title="每个节点同时迁移的副本数量">
<IconFont style={{ fontSize: '14px', marginLeft: '5px' }} type="icon-zhushi" />
</Tooltip>
</span>
}
rules={[
{
required: true,
message: `请输入!`,
},
]}
>
<InputNumber min={0} max={999} placeholder="请输入任务并行度" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="executionStrategy"
label={
<span>
<Tooltip title="不同大小副本执行的顺序">
<IconFont style={{ fontSize: '14px', marginLeft: '5px' }} type="icon-zhushi" />
</Tooltip>
</span>
}
rules={[
{
required: true,
message: `请选择!`,
},
]}
>
<Radio.Group>
<Radio value={1}></Radio>
<Radio value={2}></Radio>
</Radio.Group>
</Form.Item>
</div>
) : null
}
</Form.Item>
)}
{isCycle && (
<Form.Item
name="parallelNum"
label={
<span>
<Tooltip title="每个节点同时迁移的副本数量">
<IconFont style={{ fontSize: '14px', marginLeft: '5px' }} type="icon-zhushi" />
</Tooltip>
</span>
}
rules={[
{
required: true,
message: `请输入!`,
},
]}
>
<InputNumber min={0} max={999} placeholder="请输入任务并行度" style={{ width: '100%' }} />
</Form.Item>
)}
{isCycle && (
<Form.Item
className="schedule-cron"
name="scheduleCron"
label="任务周期"
rules={[
{
required: true,
message: `请输入!`,
},
{
validator: (_, value) => {
const valArr = value.split(' ');
if (valArr[1] === '*' || valArr[2] === '*') {
return Promise.reject(new Error('任务周期必须指定分钟、小时'));
}
return Promise.resolve();
},
},
]}
>
<CronInput />
</Form.Item>
)}
{isCycle && (
<Form.Item
name="executionStrategy"
label={
<span>
<Tooltip title="不同大小副本执行的顺序">
<IconFont style={{ fontSize: '14px', marginLeft: '5px' }} type="icon-zhushi" />
</Tooltip>
</span>
}
rules={[
{
required: true,
message: `请选择!`,
},
]}
>
<Radio.Group>
<Radio value={1}></Radio>
<Radio value={2}></Radio>
</Radio.Group>
</Form.Item>
)}
<Form.Item
name="throttleUnitM"
label="限流"
rules={[
{
required: true,
message: `请输入!`,
},
]}
>
<InputNumber min={1} max={99999} placeholder="请输入限流" addonAfter="MB/s" style={{ width: '100%' }} />
</Form.Item>
{isCycle && (
<>
<Form.Item name="status" label="是否启用">
<Radio.Group>
<Radio value={1}></Radio>
<Radio value={0}></Radio>
</Radio.Group>
</Form.Item>
</>
)}
</Form>
</Drawer>
</>
);
};
export default BalanceDrawer;

View File

@@ -0,0 +1,183 @@
/* eslint-disable react/display-name */
import React, { useState, useEffect, useRef } from 'react';
import { Table, Input, InputNumber, Form, Tooltip, Button, message, IconFont } from 'knowdesign';
import { QuestionCircleOutlined } from '@ant-design/icons';
const EditableCell = ({ dataIndex, editable, title, inputType, handleSave, placeholder, record, index, children, ...restProps }: any) => {
const inputRef = useRef(null);
const save = async () => {
try {
handleSave({ ...record });
} catch (errInfo) {
console.log('Save failed:', errInfo);
}
};
const inputNode =
inputType === 'number' ? (
<InputNumber
style={{ width: '130px' }}
min={1}
max={100}
autoComplete="off"
placeholder={placeholder}
ref={inputRef}
formatter={(value: number | string) => `${value}%`}
parser={(value: number | string) => value!.toString().replace('%', '')}
onPressEnter={save}
onBlur={save}
prefix="avg+-"
/>
) : (
<Input autoComplete="off" placeholder={placeholder} />
);
return (
<td {...restProps}>
{editable ? (
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ marginRight: '5px' }}>avg ±</span>
<Form.Item
name={dataIndex}
style={{
margin: 0,
}}
rules={[
{
required: true,
message: `请输入!`,
},
]}
>
{inputNode}
</Form.Item>
</div>
) : (
children
)}
</td>
);
};
const BalanceEditTable = React.forwardRef((props: any, ref: any) => {
const { colCustomConfigs, tableData, tableDataChange } = props;
const [form] = Form.useForm();
const [dataSource, setDataSource] = useState(tableData);
useEffect(() => {
setDataSource(tableData);
const formData: any = {};
tableData?.forEach((item: any) => {
formData[`${item.type}-intervalPercent`] = item.intervalPercent;
});
form.setFieldsValue({ ...formData });
}, [tableData]);
const editTableValidate = async () => {
const values = await form.validateFields();
return values;
};
const handleSave = async (row: any) => {
let values: any = {};
form
.validateFields()
.then((data) => {
values = data;
tableChange(values);
})
.catch((errs) => {
values = errs?.values;
tableChange(values);
});
};
const tableChange = (formData: any) => {
const newData = [...dataSource];
Object.keys(formData).forEach((key) => {
const type = key.split('-')[0];
const index = newData.findIndex((item) => type === item.type);
const item = newData[index];
newData.splice(index, 1, {
...item,
intervalPercent: formData[key],
});
});
tableDataChange(newData);
};
React.useImperativeHandle(ref, () => ({
editTableValidate,
}));
const columns = [
{
title: '均衡维度',
dataIndex: 'name',
width: '40%',
},
{
title: () => (
<span>
{' '}
<Tooltip title="单位:%大于0小于100">
{' '}
<QuestionCircleOutlined />
</Tooltip>
</span>
),
dataIndex: 'intervalPercent',
width: '40%',
editable: true,
},
{
title: '优先级',
dataIndex: 'priority',
width: '20%',
},
];
const mergedColumns = columns.map((col, index) => {
if (!col.editable) {
return col;
}
return {
...col,
onCell: (record: any, index: number) =>
Object.assign(
{
record,
inputType: 'number',
dataIndex: `${record.type}-${col.dataIndex}`,
title: col.title,
editable: col.editable,
index: index,
handleSave,
form,
},
colCustomConfigs?.[index]
),
title: colCustomConfigs?.[index]?.title || col.title,
};
});
return (
<div className="edit-table-form">
<Form form={form} component={false}>
<Table
components={{
body: {
cell: EditableCell,
},
}}
dataSource={dataSource}
columns={mergedColumns}
rowClassName={() => 'editable-row'}
pagination={false}
/>
</Form>
</div>
);
});
export default BalanceEditTable;

View File

@@ -0,0 +1,240 @@
import React, { useEffect, useState } from 'react';
import { Button, Popover, IconFont, Row, Col, Select } from 'knowdesign';
import { CloseOutlined } from '@ant-design/icons';
const balancePrefix = 'custom-popover-balance';
interface FilterListType {
id: number;
firstLevel: string;
secondLevel: number;
}
const filterNorm = [
{
label: 'Disk',
value: 'disk',
disabled: false,
},
{
label: 'Byte In',
value: 'bytesIn',
disabled: false,
},
{
label: 'Byte Out',
value: 'bytesOut',
disabled: false,
},
];
const isBalance = [
{
label: '已均衡',
value: 0,
},
{
label: '未均衡',
value: 2,
},
];
export const BalanceFilter = (props: any) => {
const [visible, setVisible] = useState<boolean>(false);
const [filterList, setFilterList] = useState<FilterListType[]>(null);
const [filterNormList, setFilterNormList] = useState(filterNorm);
// 添加一个筛选条件
const addClick = (key: number) => {
if (filterList.length >= filterNorm.length) return;
const newFilterList = JSON.parse(JSON.stringify(filterList));
const getDate = new Date().getTime();
newFilterList.push({ id: getDate, firstLevel: '', secondLevel: null });
setFilterList(newFilterList);
};
// 减少一个筛选条件
const reduceClick = (key: number) => {
const newFilterList = filterList.filter((item, index) => {
return item.id !== key;
});
// 取消已清除的指标的禁用
const filterNewFilterList = newFilterList.map((item: any) => item.firstLevel);
const newfilterNormList = JSON.parse(JSON.stringify(filterNorm)).map((item: any) => {
if (filterNewFilterList.includes(item.value)) {
return { ...item, disabled: true };
}
return item;
});
newfilterNormList.sort((a: any, b: any) => a.disabled - b.disabled);
setFilterList(newFilterList);
setFilterNormList(newfilterNormList);
};
// 第一列下拉操作
const firstLevelChange = (value: any, rowId: number) => {
const newFilterList = JSON.parse(JSON.stringify(filterList));
const newFilterListIndex = newFilterList.findIndex((item: any) => item.id === rowId);
newFilterList[newFilterListIndex].firstLevel = value;
// 控制已选中的指标无法再次被选中
const filterNewFilterList = newFilterList.map((item: any) => item.firstLevel);
const newfilterNormList = JSON.parse(JSON.stringify(filterNorm)).map((item: any) => {
if (filterNewFilterList.includes(item.value)) {
return { ...item, disabled: true };
}
return item;
});
newfilterNormList.sort((a: any, b: any) => a.disabled - b.disabled);
setFilterList(newFilterList);
setFilterNormList(newfilterNormList);
};
// 第二列下拉操作
const secondLevelChange = (value: any, rowId: number) => {
const newFilterList = JSON.parse(JSON.stringify(filterList));
const newFilterListIndex = newFilterList.findIndex((item: any) => item.id === rowId);
newFilterList[newFilterListIndex].secondLevel = value;
setFilterList(newFilterList);
};
const submitClick = () => {
const newFilterList = filterList.filter((item: any) => {
return item.firstLevel && (item.secondLevel === 0 || item.secondLevel);
});
props?.getNorms(newFilterList);
onClose();
};
const onClose = () => {
// 控制已选中的指标无法再次被选中
if (props?.filterList && props?.filterList.length > 0) {
const filterNewFilterList = props?.filterList.map((item: any) => item.firstLevel);
const newfilterNormList = JSON.parse(JSON.stringify(filterNorm)).map((item: any) => {
if (filterNewFilterList.includes(item.value)) {
return { ...item, disabled: true };
}
return item;
});
newfilterNormList.sort((a: any, b: any) => a.disabled - b.disabled);
setFilterList(props?.filterList);
setFilterNormList(newfilterNormList);
} else {
setFilterList([
{
id: 0,
firstLevel: null,
secondLevel: null,
},
]);
setFilterNormList(filterNorm);
}
setVisible(false);
};
useEffect(() => {
// 控制已选中的指标无法再次被选中
if (props?.filterList && props?.filterList.length > 0) {
const filterNewFilterList = props?.filterList.map((item: any) => item.firstLevel);
const newfilterNormList = JSON.parse(JSON.stringify(filterNorm)).map((item: any) => {
if (filterNewFilterList.includes(item.value)) {
return { ...item, disabled: true };
}
return item;
});
newfilterNormList.sort((a: any, b: any) => a.disabled - b.disabled);
setFilterList(props?.filterList);
setFilterNormList(newfilterNormList);
} else {
setFilterList([
{
id: 0,
firstLevel: null,
secondLevel: null,
},
]);
setFilterNormList(filterNorm);
}
}, [props?.filterList]);
return (
<Popover
placement="bottomLeft"
overlayClassName={balancePrefix}
trigger={'click'}
title={
<div className={`${balancePrefix}-title`}>
<div style={{ fontSize: '16px' }}>{props?.title}</div>
<Button type="text" icon={<CloseOutlined className="close-icon" />} onClick={onClose} />
</div>
}
visible={visible}
onVisibleChange={(visible) => setVisible(visible)}
destroyTooltipOnHide
content={
<div className={`${balancePrefix}-container`}>
<div className={`${balancePrefix}-container-main`}>
<div>
{filterList &&
filterList.length > 0 &&
filterList.map((item, index) => {
return (
<Row key={item.id} style={{ padding: '9px 0' }}>
{index !== 0 && (
<Col span={2}>
<span className={`${balancePrefix}-container-main-andlink`}>
<span>and</span>
</span>
</Col>
)}
<Col span={index !== 0 ? 10 : 12}>
<Select
size="small"
value={item.firstLevel || null}
placeholder="请选择"
style={{ width: index !== 0 ? 148 : 180 }}
options={filterNormList}
onChange={(e) => firstLevelChange(e, item.id)}
></Select>
</Col>
<Col span={10}>
<Select
size="small"
value={item.secondLevel}
placeholder="请选择"
options={isBalance}
style={{ width: 150 }}
onChange={(e) => secondLevelChange(e, item.id)}
></Select>
</Col>
<Col className={`${balancePrefix}-container-main-option`} span={2}>
<span onClick={() => addClick(item.id)}>
<IconFont type="icon-jiahao" />
</span>
{index !== 0 && (
<span onClick={() => reduceClick(item.id)}>
<IconFont type="icon-jian" />
</span>
)}
</Col>
</Row>
);
})}
</div>
<div className={`${balancePrefix}-container-main-footer`}>
<Button onClick={onClose} size="small">
</Button>
<Button onClick={submitClick} style={{ marginLeft: '10px' }} size="small" type="primary">
</Button>
</div>
</div>
</div>
}
>
<Button icon={<IconFont type="icon-shaixuan" />}></Button>
</Popover>
);
};

View File

@@ -0,0 +1,194 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Utils,
Drawer,
Button,
Form,
Space,
Divider,
AppContainer,
Input,
Transfer,
message,
IconFont,
InputNumber,
} from 'knowdesign';
import { CloseOutlined } from '@ant-design/icons';
import api from '../../api';
import './style/BalanceDrawer.less';
interface PropsType extends React.HTMLAttributes<HTMLDivElement> {
onClose: () => void;
visible: boolean;
isCycle?: boolean;
formData?: any;
genData?: any;
}
const ClusterNorms: React.FC<PropsType> = ({ onClose, visible, genData }) => {
const [global] = AppContainer.useGlobalValue();
const [form] = Form.useForm();
const [nodeData, setNodeData] = useState([]);
const [nodeTargetKeys, setNodeTargetKeys] = useState([]);
useEffect(() => {
visible && getNodeList();
visible && getTopicList();
}, [visible]);
const submit = () => {
// 周期均衡
form.validateFields().then((values) => {
const params = values?.brokers?.map((item: any) => {
const brokerId = nodeData?.filter((key) => key.brokerId === item && item)[0]?.brokerId;
const newValue = brokerId && { brokerId, cpu: values?.cpu, disk: values?.disk, flow: values?.flow };
return {
clusterId: global?.clusterInfo?.id + '',
value: JSON.stringify(newValue),
valueGroup: 'BROKER_SPEC',
valueName: brokerId + '',
description: '',
};
});
Utils.put(api.putPlatformConfig(), params).then((res: any) => {
const dataDe = res || [];
onClose();
message.success('设置集群规格成功');
genData();
});
});
};
const nodeChange = (val: any) => {
setNodeTargetKeys(val);
};
const getNodeList = () => {
Utils.request(api.getBrokersMetaList(global?.clusterInfo?.id), {
method: 'GET',
}).then((res: any) => {
const dataDe = res || [];
const dataHandle = dataDe.map((item: any) => {
return {
...item,
key: item.brokerId,
title: `${item.brokerId} (${item.host})`,
};
});
setNodeData(dataHandle);
});
};
const getTopicList = () => {
Utils.request(api.getPlatformConfig(global?.clusterInfo?.id, 'BROKER_SPEC')).then((res: any) => {
const targetKeys = res?.map((item: any) => {
return JSON.parse(item.value).brokerId;
});
setNodeTargetKeys(targetKeys || []);
const newValues = JSON.parse(res?.[0].value);
const fieldValue = {
cpu: newValues?.cpu,
disk: newValues?.disk,
flow: newValues?.flow,
brokers: targetKeys || [],
};
form.setFieldsValue(fieldValue);
});
};
return (
<>
<Drawer
title={'设置集群规格'}
width="600px"
destroyOnClose={true}
className="balance-drawer"
onClose={onClose}
visible={visible}
maskClosable={false}
extra={
<Space>
<Button size="small" onClick={onClose}>
</Button>
<Button type="primary" size="small" disabled={false} onClick={submit}>
</Button>
<Divider type="vertical" />
</Space>
}
>
<Form
form={form}
layout="vertical"
preserve={false}
initialValues={{
status: 1,
}}
>
<Form.Item
name="brokers"
rules={[
{
required: true,
message: `请选择!`,
},
]}
>
<Transfer
dataSource={nodeData}
showSearch
filterOption={(inputValue, option) => option.host.indexOf(inputValue) > -1}
targetKeys={nodeTargetKeys}
onChange={nodeChange}
render={(item) => item.title}
titles={['待选节点', '已选节点']}
customHeader
showSelectedCount
locale={{ itemUnit: '', itemsUnit: '' }}
suffix={<IconFont type="icon-fangdajing" />}
/>
</Form.Item>
<Form.Item
name="cpu"
label="单机核数"
rules={[
{
required: true,
message: `请输入!`,
},
]}
>
<InputNumber decimalSeparator={'0'} min={0} max={99999} placeholder="请输入单机核数" addonAfter="C" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="disk"
label="单机磁盘"
rules={[
{
required: true,
message: `请输入!`,
},
]}
>
<InputNumber min={0} max={99999} placeholder="请输入磁盘大小" addonAfter="GB" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="flow"
label="单机网络"
rules={[
{
required: true,
message: `请输入!`,
},
]}
>
<InputNumber min={0} max={99999} placeholder="请输入单机网络" addonAfter="MB/s" style={{ width: '100%' }} />
</Form.Item>
</Form>
</Drawer>
</>
);
};
export default ClusterNorms;

View File

@@ -0,0 +1,27 @@
// @ts-nocheck
import React from 'react';
import { Dropdown, Input } from 'knowdesign';
import Cron from 'react-cron-antd';
// import QnnReactCron, { CronProps, CronFns } from "qnn-react-cron";
import './style/CronInput.less';
interface PropsType extends React.HTMLAttributes<HTMLDivElement> {
onChange?: Function
value?: boolean;
}
const CronInput: React.FC<PropsType> = (props) => {
const { value, onChange } = props;
return (
<Dropdown
trigger={['click']}
placement="bottomLeft"
overlayClassName="cron-input-dropDown"
overlay={<Cron className="cron-input" style={{ width: '553px' }} value={value} onOk={onChange} />}
>
<Input value={value} placeholder="请输入任务周期" />
</Dropdown>
);
}
export default CronInput;

View File

@@ -0,0 +1,170 @@
import React, { useState, useEffect } from 'react';
import { Utils, Drawer, Button, ProTable, Space, Divider, AppContainer } from 'knowdesign';
import { CloseOutlined } from '@ant-design/icons';
import moment from 'moment';
import api from '../../api';
import { defaultPagination } from './index';
import PlanDrawer from './PlanDrawer';
import { timeFormat } from '../../constants/common';
interface PropsType extends React.HTMLAttributes<HTMLDivElement> {
onClose: () => void;
visible: boolean;
}
const HistoryDrawer: React.FC<PropsType> = ({ onClose, visible }) => {
const [global] = AppContainer.useGlobalValue();
const [loading, setLoading] = useState<boolean>(true);
const [pagination, setPagination] = useState<any>(defaultPagination);
const [data, setData] = useState([]);
const [planDetailData, setPlanDetailData] = useState({});
const [planVisible, setPlanVisible] = useState<boolean>(false);
useEffect(() => {
getList();
}, []);
const columns = () => [
{
title: '执行时间',
dataIndex: 'begin',
key: 'begin',
render: (t: number) => (t ? moment(t).format(timeFormat) : '-'),
},
{
title: '结束时间',
dataIndex: 'end',
key: 'end',
render: (t: number) => (t ? moment(t).format(timeFormat) : '-'),
},
// {
// title: 'CPU均衡率',
// dataIndex: 'cpu',
// render: (text: any, row: any) => {
// return `${row?.sub?.cpu?.successNu} (已均衡) / ${row?.sub?.cpu?.failedNu} (未均衡)`
// }
// },
{
title: 'Disk均衡率',
dataIndex: 'disk',
render: (text: any, row: any) => {
return `${row?.sub?.disk?.successNu} (已均衡) / ${row?.sub?.disk?.failedNu} (未均衡)`;
},
},
{
title: 'BytesIn均衡率',
dataIndex: 'bytesIn',
render: (text: any, row: any) => {
return `${row?.sub?.bytesIn?.successNu} (已均衡) / ${row?.sub?.bytesIn?.failedNu} (未均衡)`;
},
},
{
title: 'BytesOut均衡率',
dataIndex: 'bytesOut',
render: (text: any, row: any) => {
return `${row?.sub?.bytesOut?.successNu} (已均衡) / ${row?.sub?.bytesOut?.failedNu} (未均衡)`;
},
},
{
title: '操作',
width: 150,
fixed: 'right',
render: (text: any, row: any) => {
return (
<Button type="link" size="small" onClick={() => CheckDetail(row.jobId)}>
</Button>
);
},
},
];
const getList = (query = {}) => {
const queryParams = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
...query,
};
setLoading(true);
Utils.request(api.getBalanceHistory(global?.clusterInfo?.id), {
method: 'POST',
data: queryParams,
}).then(
(res: any) => {
const { pageNo, pageSize, pages, total } = res.pagination;
setPagination({
...pagination,
current: pageNo,
pageSize,
total,
});
const dataDe = res?.bizData || [];
const dataHandle = dataDe.map((item: any) => {
return {
...item,
};
});
setData(dataHandle);
setLoading(false);
},
() => setLoading(false)
);
};
const CheckDetail = (jobId: any) => {
Utils.request(api.getBalancePlan(global?.clusterInfo?.id, jobId), {
method: 'GET',
}).then((res: any) => {
const dataDe = res || {};
setPlanDetailData(dataDe);
setPlanVisible(true);
});
};
const onTableChange = (curPagination: any) => {
getList({ page: curPagination.current, size: curPagination.pageSize });
};
return (
<>
<PlanDrawer visible={planVisible} onClose={() => setPlanVisible(false)} detailData={planDetailData} isPrevew={false} />
<Drawer
title={'均衡历史'}
width="1080px"
destroyOnClose={true}
className="plan-drawer"
onClose={onClose}
visible={visible}
closable={false}
maskClosable={false}
extra={
<Space>
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} />
</Space>
}
>
<ProTable
tableProps={{
showHeader: false,
loading,
rowKey: 'jobId',
dataSource: data,
paginationProps: pagination,
columns: columns() as any,
lineFillColor: true,
attrs: {
onChange: onTableChange,
scroll: {
x: 'max-content',
},
},
}}
/>
</Drawer>
</>
);
};
export default HistoryDrawer;

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { message, Drawer, Button, Space, Divider, AppContainer, IconFont } from 'knowdesign';
import RebalancePlan from '../Jobs/RebalancePlan';
interface PropsType extends React.HTMLAttributes<HTMLDivElement> {
onClose: () => void;
balanceImmediate?: Function;
visible: boolean;
isPrevew?: boolean;
detailData?: any;
isEdit?: boolean;
}
const PlanDrawer: React.FC<PropsType> = ({ onClose, visible, detailData, isPrevew, balanceImmediate, isEdit }) => {
const [global] = AppContainer.useGlobalValue();
const submit = () => {
if (detailData?.replicas === 0) {
message['warning']('replicas=0,该集群已达到均衡要求,不需要再执行均衡任务。');
} else {
balanceImmediate && balanceImmediate();
}
};
return (
<>
<Drawer
title={
<Space size={0}>
<Button type="text" className="drawer-title-left-button" icon={<IconFont type="icon-fanhui1" />} onClick={onClose} />
<Divider type="vertical" />
<span></span>
</Space>
}
width="1080px"
destroyOnClose={true}
className="plan-drawer"
onClose={onClose}
visible={visible}
maskClosable={false}
extra={
<Space>
{!!isPrevew && (
<>
<Button size="small" onClick={onClose}>
</Button>
<Button className={isEdit ? '' : 'btn-width84'} type="primary" size="small" disabled={false} onClick={submit}>
{isEdit ? '确定' : '立即均衡'}
</Button>
<Divider type="vertical" />
</>
)}
</Space>
}
>
<RebalancePlan balanceData={detailData} />
</Drawer>
</>
);
};
export default PlanDrawer;

View File

@@ -0,0 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import React from 'react';
import { timeFormat, getSizeAndUnit } from '../../constants/common';
import moment from 'moment';
import { Tooltip } from 'knowdesign';

View File

@@ -0,0 +1,145 @@
.load-rebalance-container {
.balance-main {
padding: 16px 24px;
background: #FFFFFF;
box-shadow: 0 2px 4px 0 rgba(0,0,0,0.01), 0 3px 6px 3px rgba(0,0,0,0.01), 0 2px 6px 0 rgba(0,0,0,0.03);
// border-radius: 12px;
.dcloud-table-container {
.dot {
display: inline-block;
width: 12px;
height: 12px;
zoom: 0.5;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
margin-top: -2px;
}
.isbalance {
.dot {
background-color: #00C0A2;
}
}
.noBalance {
color: #FF7066;
.dot {
background-color: #FF7066;
}
}
}
}
.header-con {
overflow: hidden;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
.dcloud-form {
float: left;
.dcloud-form-item {
margin-right: 12px;
}
}
.float-r {
font-family: @font-family-bold;
float: right;
.dcloud-btn {
margin-left: 8px;
line-height: normal;
}
}
}
}
.balance-drawer{
.form-item-group {
padding: 16px 20px 1px 20px;
margin-bottom: 16px;
background: #f8f9fa;
border-radius: 8px;
}
.dcloud-form-item-control-input {
min-height: 0;
}
}
.custom-popover-balance{
width: 432px;
padding-top: 0 !important;
&-title{
display: flex;
justify-content: space-between;
align-items: center;
}
&-container{
height: 100%;
&-main{
height: 100%;
display: flex;
justify-content: space-between;
flex-direction: column;
&-andlink{
display: inline-block;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #556EE6;
color: #ffffff;
line-height: 24px;
text-align: center;
font-size: 12px;
&>span{
display: inline-block;
font-size: 12px;
transform: scale(0.83,0.83);
}
}
&-option{
display: flex !important;
align-items: center;
justify-content: space-between;
}
&-footer{
display: flex;
justify-content: flex-end;
align-items: center;
padding: 23px 0 0;
}
}
}
.dcloud-popover-content{
min-height: 268px;
}
.dcloud-popover-arrow{
display: none;
}
.dcloud-popover-inner{
height: 100%;
border-radius: 12px !important;
box-shadow: 0 0 8px 0 rgba(0,0,0,0.04), 0 6px 12px 12px rgba(0,0,0,0.04), 0 6px 10px 0 rgba(0,0,0,0.08);
}
.dcloud-popover-inner-content{
padding: 0 24px 16px;
height: 220px;
}
.dcloud-popover-title{
padding: 12px 24px 0;
font-family: @font-family-bold;
font-size: 16px;
border-bottom: 0;
}
// .dcloud-table{
// margin: 0 !important;
// }
}

View File

@@ -0,0 +1,433 @@
import React, { useState, useEffect, useRef } from 'react';
import { Select, Form, Utils, AppContainer, Input, Button, ProTable, Badge, Tag, SearchInput } from 'knowdesign';
import BalanceDrawer from './BalanceDrawer';
import HistoryDrawer from './HistoryDrawer';
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
import { getSizeAndUnit } from '../../constants/common';
import api from '../../api';
import './index.less';
import LoadRebalanceCardBar from '@src/components/CardBar/LoadRebalanceCardBar';
import { BalanceFilter } from './BalanceFilter';
const Balance_Status_OPTIONS = [
{
label: '全部',
value: null,
},
{
label: '已均衡',
value: 0,
},
{
label: '未均衡',
value: 2,
},
];
const balanceStatus: any = {
0: '已均衡',
2: '未均衡',
};
const filterNorms: any = {
['disk']: 'Disk',
['bytesIn']: 'Byte In',
['bytesOut']: 'Byte Out',
};
export const defaultPagination = {
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
};
const LoadBalance: React.FC = (props: any) => {
const [global] = AppContainer.useGlobalValue();
const [form] = Form.useForm();
const [pagination, setPagination] = useState<any>(defaultPagination);
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState([]);
const [visible, setVisible] = useState<boolean>(false);
const [isCycle, setIsCycle] = useState<boolean>(false);
const [planVisible, setPlanVisible] = useState<boolean>(false);
const [circleFormData, setCircleFormData] = useState(null);
const [trigger, setTrigger] = useState(false);
const [filterList, setFilterList] = useState<any>(null);
const [balanceList, setBalanceList] = useState<string>(null);
const [searchKeywords, setSearchKeywords] = useState<string>('');
const [searchValue, setSearchValue] = useState<string>('');
const columns = () => [
{
title: 'Broker ID',
dataIndex: 'brokerId',
key: 'brokerId',
fixed: 'left',
width: 140,
},
{
title: 'Host',
dataIndex: 'host',
key: 'host',
width: 140,
},
{
title: 'Leader',
dataIndex: 'leader',
key: 'leader',
width: 100,
},
{
title: 'Replicas',
dataIndex: 'replicas',
key: 'replicas',
width: 100,
},
// {
// title: 'CPU',
// children: [
// {
// title: '规格',
// dataIndex: 'cpu_spec',
// key: 'cpu_spec',
// render: (text: any, row: any) => {
// return text !== null ? `${text}` : '-';
// },
// },
// {
// title: 'AVG',
// dataIndex: 'cpu_avg',
// key: 'cpu_avg',
// render: (text: any, row: any) => {
// return text !== null ? `${text} (${(row.cpu_avg * 100 / row.cpu_spec).toFixed(2)}%)` : '-';
// },
// },
// {
// title: '是否均衡',
// dataIndex: 'cpu_status',
// key: 'cpu_status',
// render: (text: any, row: any) => {
// // 0:已均衡非0:未均衡
// return text !== null ? (text === 0 ? (
// <span className="isbalance">
// <span className="dot"></span>已均衡
// </span>
// ) : (
// <span className="noBalance">
// <span className="dot"></span>未均衡
// </span>
// )) : '-';
// },
// },
// ],
// },
{
title: 'Disk规格',
dataIndex: 'disk_spec',
key: 'disk_spec',
width: '150px',
render: (text: any, row: any) => {
return text !== null ? `${text}GB` : '-';
},
},
{
title: 'Disk AVG',
dataIndex: 'disk_avg',
key: 'disk_avg',
width: '200px',
render: (text: any, row: any) => {
return text !== null ? (
<span>
<Badge status={row?.disk_status === 0 ? 'success' : 'error'} />
{`${getSizeAndUnit(text, 'B').valueWithUnit} (${((row.disk_avg * 100) / Utils.transGBToB(row.disk_spec)).toFixed(2)}%)`}
</span>
) : (
'-'
);
},
},
{
title: 'BytesIn规格',
dataIndex: 'bytesIn_spec',
key: 'bytesIn_spec',
width: '150px',
render: (text: any, row: any) => {
return text !== null ? `${text}MB/s` : '-';
},
},
{
title: 'BytesIn AVG',
dataIndex: 'bytesIn_avg',
key: 'bytesIn_avg',
width: '200px',
render: (text: any, row: any) => {
return text !== null ? (
<span>
<Badge status={row?.bytesIn_status === 0 ? 'success' : 'error'} />
{`${getSizeAndUnit(text, 'B/s').valueWithUnit} (${((row.bytesIn_avg * 100) / (row.bytesIn_spec * 1024 * 1024)).toFixed(2)}%)`}
</span>
) : (
'-'
);
},
},
{
title: 'BytesOut规格',
dataIndex: 'bytesOut_spec',
key: 'bytesOut_spec',
width: '150px',
render: (text: any, row: any) => {
return text !== null ? `${text}MB/s` : '-';
},
},
{
title: 'BytesOut AVG',
dataIndex: 'bytesOut_avg',
key: 'bytesOut_avg',
width: '200px',
// eslint-disable-next-line react/display-name
render: (text: any, row: any) => {
return text !== null ? (
<span>
<Badge status={row?.bytesOut_status === 0 ? 'success' : 'error'} />
{`${getSizeAndUnit(text, 'B/s').valueWithUnit} (${((row.bytesOut_avg * 100) / (row.bytesOut_spec * 1024 * 1024)).toFixed(2)}%)`}
</span>
) : (
'-'
);
},
},
];
useEffect(() => {
getList();
}, []);
const onTableChange = (curPagination: any) => {
setPagination({
...curPagination,
});
getList({ pageNo: curPagination.current, pageSize: curPagination.pageSize });
};
const resetList = () => {
setPagination({
...pagination,
pageNo: 1,
});
getList();
};
const hostSearch = (e: any) => {
setFilterList([]);
setPagination({
...pagination,
pageNo: 1,
});
setSearchKeywords(e);
getList({ searchKeywords: e, stateParam: balanceList });
};
const getList = (query = {}) => {
const formData = form.getFieldsValue();
const queryParams = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
...formData,
...query,
};
setLoading(true);
Utils.request(api.getBalanceList(global?.clusterInfo?.id), {
method: 'POST',
data: queryParams,
}).then(
(res: any) => {
const { pageNo, pageSize, pages, total } = res.pagination;
setPagination({
...pagination,
current: pageNo,
pageSize,
total,
});
const dataDe = res?.bizData || [];
const dataHandle = dataDe.map((item: any) => {
return {
...item,
cpu_spec: item?.sub?.cpu?.spec,
cpu_avg: item?.sub?.cpu?.avg,
cpu_status: item?.sub?.cpu?.status,
disk_spec: item?.sub?.disk?.spec,
disk_avg: item?.sub?.disk?.avg,
disk_status: item?.sub?.disk?.status,
bytesIn_spec: item?.sub?.bytesIn?.spec,
bytesIn_avg: item?.sub?.bytesIn?.avg,
bytesIn_status: item?.sub?.bytesIn?.status,
bytesOut_spec: item?.sub?.bytesOut?.spec,
bytesOut_avg: item?.sub?.bytesOut?.avg,
bytesOut_status: item?.sub?.bytesOut?.status,
};
});
setData(dataHandle);
setLoading(false);
},
() => setLoading(false)
);
};
const drawerClose = (sure?: boolean) => {
if (sure) {
setTrigger(!trigger);
}
setVisible(false);
};
const balanceClick = (val: boolean = false) => {
if (val) {
Utils.request(api.getBalanceForm(global?.clusterInfo?.id), {
method: 'GET',
})
.then((res: any) => {
const dataDe = res || {};
setCircleFormData(dataDe);
})
.catch(() => {
setCircleFormData(null);
});
} else {
setCircleFormData(null);
}
setIsCycle(val);
setVisible(true);
};
const getNorms = (stateParamArr: any) => {
const stateParam: any = {};
stateParamArr.forEach((item: any) => {
stateParam[item.firstLevel] = item.secondLevel;
});
setFilterList(stateParamArr);
setPagination({
...pagination,
pageNo: 1,
});
setBalanceList(stateParam);
getList({ searchKeywords, stateParam });
};
const filterNormsClose = (rowId: any) => {
const newFilterList = filterList.filter((item: any) => item.id !== rowId);
getNorms(newFilterList);
};
return (
<>
<div className="breadcrumb" style={{ marginBottom: '10px' }}>
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Load Rebalance', aHref: `` },
]}
/>
</div>
<div style={{ margin: '12px 0' }}>
<LoadRebalanceCardBar trigger={trigger} genData={resetList} filterList={filterList} />
</div>
<div className="load-rebalance-container">
<div className="balance-main clustom-table-content">
<div className="header-con">
{/* <Form form={form} layout="inline" onFinish={resetList}>
<Form.Item name="status">
<Select className="grid-select" placeholder="请选择状态" style={{ width: '180px' }} options={Balance_Status_OPTIONS} />
</Form.Item>
<Form.Item name="searchKeywords">
<Input placeholder="请输入Host" style={{ width: '180px' }} />
</Form.Item>
<Form.Item>
<Button type="primary" ghost htmlType="submit">
查询
</Button>
</Form.Item>
</Form> */}
<BalanceFilter title="负载均衡列表筛选" data={[]} getNorms={getNorms} filterList={filterList} />
<div className="float-r">
<SearchInput
onSearch={hostSearch}
attrs={{
value: searchValue,
onChange: setSearchValue,
placeholder: '请输入 Host',
style: { width: '210px' },
maxLength: 128,
}}
/>
<Button type="primary" ghost onClick={() => setPlanVisible(true)}>
</Button>
<Button type="primary" ghost onClick={() => balanceClick(true)}>
</Button>
<Button type="primary" onClick={() => balanceClick(false)}>
</Button>
</div>
</div>
{filterList && filterList.length > 0 && (
<div style={{ marginBottom: '12px' }}>
<span style={{ marginRight: '6px' }}>:{pagination?.total || 0}</span>
{filterList.map((item: any) => {
return (
<Tag
style={{ padding: '6px 10px', backgroundColor: 'rgba(33,37,41,0.08)', color: '#495057', borderRadius: '6px' }}
key={item.id}
closable
onClose={() => filterNormsClose(item.id)}
>
<span>{filterNorms[item.firstLevel]}:</span>
<span>{balanceStatus[item.secondLevel]}</span>
</Tag>
);
})}
</div>
)}
<ProTable
tableProps={{
showHeader: false,
loading,
rowKey: 'brokerId',
dataSource: data,
paginationProps: pagination,
columns: columns() as any,
lineFillColor: true,
attrs: {
onChange: onTableChange,
// bordered: false,
// className: 'remove-last-border',
scroll: { x: 'max-content', y: 'calc(100vh - 440px)' },
},
}}
/>
</div>
<BalanceDrawer visible={visible} isCycle={isCycle} onClose={drawerClose} formData={circleFormData} genData={getList} />
{planVisible && (
<HistoryDrawer
visible={planVisible}
onClose={() => {
setPlanVisible(false);
}}
/>
)}
</div>
</>
);
};
export default LoadBalance;

View File

@@ -0,0 +1,19 @@
.balance-drawer {
.form-title {
font-size: 16px;
color: #212529;
letter-spacing: 0;
line-height: 25px;
margin: 16px 0;
}
.schedule-cron {
.dcloud-select {
margin-right: 10px;
}
}
.dcloud-drawer-body{
padding: 0 20px 14px !important;
}
}

View File

@@ -0,0 +1,46 @@
.cron-input-dropDown {
.cron-input {
width: 553px;
}
.ant-tabs-nav-list {
// 秒
// .ant-tabs-tab:nth-child(1) {
// display: none;
// }
.ant-tabs-tab:nth-child(7) {
display: none;
}
}
.ant-tabs-content {
// 秒
.ant-tabs-tabpane:nth-child(1) {
.ant-radio-group {
.ant-radio-wrapper:nth-child(2) {
display: none !important;
}
.ant-radio-wrapper:nth-child(3) {
display: none !important;
}
.ant-radio-wrapper:nth-child(4) {
display: none !important;
}
}
}
// 分
.ant-tabs-tabpane:nth-child(2) {
.ant-radio-group {
.ant-radio-wrapper:nth-child(1) {
display: none !important;
}
}
}
// 时
.ant-tabs-tabpane:nth-child(3) {
.ant-radio-group {
.ant-radio-wrapper:nth-child(1) {
display: none !important;
}
}
}
}
}

View File

@@ -0,0 +1,238 @@
/* eslint-disable react/display-name */
import { FormItemType, LoginForm } from './login';
import React from 'react';
import { CloseCircleFilled, LockOutlined, UserOutlined } from '@ant-design/icons';
import { Input } from 'knowdesign';
import { debounce } from 'lodash';
export enum LOGIN_TAB_KEY {
login = 'login',
register = 'register',
}
export const LOGIN_MENU = [
{
label: '账号密码登录',
key: LOGIN_TAB_KEY.login,
render: (fn: unknown) => <LoginForm fn={fn} />,
},
] as any[];
const menuMap = new Map<string, any>();
LOGIN_MENU.forEach((d) => {
menuMap.set(d.key, d);
});
export const LOGIN_MENU_MAP = menuMap;
export const FormMap = [
{
key: 'userName',
label: '账号',
type: FormItemType.input,
rules: [
{
required: true,
message: (
<>
<CloseCircleFilled />
</>
),
},
],
attrs: {
placeholder: '请输入账号',
prefix: <></>,
// prefix: <UserOutlined style={{ color: 'rgba(0,0,0,.25)' }} />,
},
},
{
key: 'pw',
type: FormItemType.inputPassword,
label: '密码',
rules: [
{
required: true,
message: (
<>
<CloseCircleFilled />
</>
),
},
],
attrs: {
placeholder: '请输入密码',
// prefix: <LockOutlined style={{ color: 'rgba(0,0,0,.25)' }} />,
},
},
];
// 用户校验
const UserNameCheck = (props: any) => {
const onChange = (e: any) => {
checkUserNameRepeat(e.target.value);
};
const checkUserNameRepeat = debounce(async (value) => {
//
}, 1000) as any;
return (
<>
<Input
key={'user-name1'}
placeholder={'6-20个字符支持英文字母、数字、标点符号除空格'}
prefix={<LockOutlined style={{ color: 'rgba(0,0,0,.25)' }} />}
onChange={onChange}
/>
</>
);
};
export const RegisterFormMap = [
{
key: 'username',
label: '用户账号',
type: FormItemType.custom,
customFormItem: <UserNameCheck />,
rules: [
{
required: true,
validator: (rule: any, value: string) => {
const flat_5_50 = value && value.length > 4 && value.length <= 50;
const reg = /^[0-9a-zA-Z_]{1,}$/;
if (value === '-1') {
return Promise.reject('账号重复');
}
if (flat_5_50 && reg.test(value)) {
return Promise.resolve();
} else {
return Promise.reject('账号设置不符合要求');
}
},
},
],
},
{
key: 'password',
type: FormItemType.inputPassword,
label: '密码',
rules: [
{
required: true,
message: '密码设置不符合要求',
validator: (rule: any, value: string) => {
const flat_6_20 = value && value.length > 5 && value.length <= 20;
const reg = /^[a-zA-Z0-9_-]*$/;
if (flat_6_20 && reg.test(value)) {
return Promise.resolve();
} else {
return Promise.reject();
}
},
},
],
attrs: {
placeholder: '6-20个字符支持英文字母、数字、标点符号除空格',
prefix: <LockOutlined style={{ color: 'rgba(0,0,0,.25)' }} />,
},
},
{
key: 'confirm',
type: FormItemType.inputPassword,
label: '确认密码',
rules: [
{
required: true,
message: '两次密码不统一',
},
({ getFieldValue }: any) => ({
validator(params: any, value: string) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject('两次密码不统一');
},
}),
],
attrs: {
placeholder: '请再次输入密码',
prefix: <LockOutlined style={{ color: 'rgba(0,0,0,.25)' }} />,
},
},
{
key: 'realName',
label: '真实姓名',
type: FormItemType.input,
rules: [
{
required: false,
validator: (rule: any, value: string) => {
if (!value) {
return Promise.resolve();
}
const flat_1_50 = value && value.length > 0 && value.length <= 50;
const reg = /^[a-zA-Z\u4e00-\u9fa5]+$/;
if (!reg.test(value)) {
return Promise.reject('请输入中文或英文');
} else if (!flat_1_50) {
return Promise.reject('1-50字符');
} else {
return Promise.resolve();
}
},
},
],
attrs: {
placeholder: '真实姓名',
},
},
{
key: 'phone',
label: '手机号',
type: FormItemType.input,
rules: [
{
required: false,
validator: (rule: any, value: string) => {
if (!value) {
return Promise.resolve();
}
const reg = /^[1][3-9][0-9]{9}$/;
if (!reg.test(value)) {
return Promise.reject('请输入正确手机号码');
} else {
return Promise.resolve();
}
},
},
],
attrs: {
placeholder: '手机号',
},
},
{
key: 'mailbox',
label: '邮箱',
type: FormItemType.input,
rules: [
{
required: false,
validator: (rule: any, value: string) => {
if (!value) {
return Promise.resolve();
}
const reg = /^[\w.-]+@(?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,3}$/;
if (!reg.test(value)) {
return Promise.reject('请输入完整的邮件格式');
} else {
return Promise.resolve();
}
},
},
],
attrs: {
placeholder: '邮箱',
},
},
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,187 @@
@login-container-width: 500px;
@layout-main-height: 700px;
@media screen and (max-width: 960px) {
.login-page {
.login-page-left {
display: none;
}
.login-page-right {
width: 100vw;
}
}
}
.login-page {
display: flex;
width: 100vw;
height: 100vh;
min-height: @layout-main-height;
overflow: auto;
&-left {
display: flex;
justify-content: center;
align-items: center;
width: calc(100% - @login-container-width);
height: 100%;
background: no-repeat right top / 23% url('./img/login-bg-1.png'), no-repeat top left / cover url('./img/login-bg.png');
.dcloud-carousel {
width: 100%;
height: 100%;
.slick-slider {
height: 100%;
}
.slick-dots {
li {
width: 22px;
button {
height: 6px;
opacity: 0.7;
border-radius: 2px;
background-color: #fff;
}
&.slick-active {
button {
opacity: 1;
width: 26px;
background-color: @primary-color;
}
}
}
}
.carousel-eg-ctr {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
min-height: @layout-main-height;
&-one {
&-img {
width: 100%;
&.img-one {
max-width: 271px;
}
&.img-two {
max-width: 940px;
}
}
&-desc {
&.desc-one {
margin: 21px 0 17px 0;
font-size: 20px;
color: rgba(0, 0, 0, 0.84);
line-height: 30px;
}
}
}
&-two {
&-img {
width: 100%;
&.img-one {
max-width: 820px;
}
}
&-desc {
&.desc-one {
margin: 20px 0 10px 0;
font-size: 15px;
color: #525570;
line-height: 33px;
& > span {
&:first-child {
font-family: @font-family-bold;
color: #333554;
opacity: 0.84;
}
&:nth-child(2) {
font-size: 20px;
color: @primary-color;
}
}
}
&.desc-two {
padding: 0 24px;
text-align: justify;
font-size: 15px;
opacity: 0.6;
color: #333554;
}
}
}
}
}
}
&-right {
display: flex;
align-items: center;
width: @login-container-width;
height: 100%;
background-color: #ffffff;
&-content {
width: 340px;
margin: 0 auto;
&-title {
display: flex;
flex-direction: column;
align-items: center;
.logo {
width: 240px;
height: 31px;
}
.desc {
margin-top: 12px;
font-size: 16px;
color: rgba(14, 16, 51, 0.84);
line-height: 30px;
}
}
&-content {
width: 100%;
padding-top: 85px;
.login-form {
.dcloud-form-item-label > label {
font-size: 14px;
font-family: @font-family;
color: #303a51;
line-height: 20px;
}
.dcloud-input .dcloud-input-affix-wrapper {
height: 40px;
}
.submit-btn {
width: 100%;
height: 50px;
margin-top: 22px;
> span {
font-family: @font-family-bold;
font-size: 18px;
color: #ffffff;
}
}
}
}
&-bottom {
position: absolute;
bottom: 0;
width: 100%;
height: 50px;
p {
font-size: 12px;
color: #999999;
letter-spacing: 0;
text-align: center;
line-height: 18px;
}
}
}
}
}

View File

@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react';
import { LOGIN_MENU, LOGIN_MENU_MAP } from './config';
import './index.less';
import { AppContainer, Carousel } from 'knowdesign';
import Logo from '@src/assets/ks-logo.png';
import egOneTitle from './img/eg1-title.png';
import egOneContent from './img/eg1-content.png';
import egTwoContent from './img/eg2-content.png';
const carouselList = [
<div key="2">
<div className="carousel-eg-ctr carousel-eg-ctr-two">
<img className="carousel-eg-ctr-two-img img-one" src={egTwoContent} />
<div className="carousel-eg-ctr-two-desc desc-one">
<span>Github: </span>
<span>4K</span>
<span>+ Star的项目 Know Streaming</span>
</div>
<div className="carousel-eg-ctr-two-desc desc-two">
2000+ 使线
</div>
</div>
</div>,
// <div key="1">
// <div className="carousel-eg-ctr carousel-eg-ctr-one">
// <img className="carousel-eg-ctr-one-img img-one" src={egOneTitle} />
// <div className="carousel-eg-ctr-one-desc desc-one">可能是北半球最简单易用的 Kafka 管控平台</div>
// <img className="carousel-eg-ctr-one-img img-two" src={egOneContent} />
// </div>
// </div>,
];
export const Login: React.FC<any> = () => {
const [global, setGlobal] = AppContainer.useGlobalValue();
const [selectedKeys, setSelectedKeys] = useState([LOGIN_MENU[0].key]);
const renderContent = () => {
return LOGIN_MENU_MAP.get(selectedKeys[0])?.render(handleMenuClick) || LOGIN_MENU_MAP.get(LOGIN_MENU[0].key)?.render(handleMenuClick);
};
const handleMenuClick = (e: string) => {
setSelectedKeys([e]);
window.location.hash = e;
};
// 跳转到登录页时,清空全局状态
useEffect(() => {
if (Object.keys(global).length) {
setGlobal({});
}
}, [global]);
return (
<div className="login-page">
<div className="login-page-left">
<Carousel autoplay={true} autoplaySpeed={5000}>
{carouselList}
</Carousel>
</div>
<div className="login-page-right">
<div className="login-page-right-content">
<div className="login-page-right-content-title">
<img className="logo" src={Logo} />
<div className="desc"> Kafka </div>
</div>
<div className="login-page-right-content-content">{renderContent()}</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Form, Button, Input, Row, InputNumber, Utils, message } from 'knowdesign';
import { FormMap } from './config';
import Api from '../../api';
import { useHistory } from 'react-router-dom';
import { systemCipherKey } from '@src/constants/common';
export enum FormItemType {
input = 'input',
inputPassword = 'inputPassword',
inputNumber = 'inputNumber',
custom = 'custom',
}
export interface IFormItem {
key: string;
type: FormItemType;
attrs?: any;
rules?: any[];
invisible?: boolean;
customFormItem?: any;
}
export const renderFormItem = (item: IFormItem) => {
switch (item.type) {
default:
case FormItemType.input:
return <Input key={item.key} {...item.attrs} />;
case FormItemType.inputPassword:
return <Input.Password key={item.key} {...item.attrs} />;
case FormItemType.inputNumber:
return <InputNumber key={item.key} {...item.attrs} />;
case FormItemType.custom:
return (item as IFormItem).customFormItem;
}
};
export const LoginForm: React.FC<any> = (props) => {
const [form] = Form.useForm();
const history = useHistory();
const handleSubmit = async ({ userName, pw }: { userName: string; pw: string }) => {
Utils.post(Api.login, {
userName,
pw: Utils.encryptAES(pw, systemCipherKey),
}).then((res) => {
message.success('登录成功');
localStorage.setItem('userInfo', JSON.stringify(res));
const redirectPath = window.location.search.slice('?redirect='.length) || '/';
history.replace(redirectPath);
});
};
return (
<>
<Form name="normal_login" form={form} className="login-form" onFinish={handleSubmit} layout={'vertical'}>
{FormMap.map((formItem) => {
return (
<Row key={formItem.key}>
<Form.Item key={formItem.key} name={formItem.key} label={formItem.label} rules={formItem.rules} style={{ width: '100%' }}>
{renderFormItem(formItem)}
</Form.Item>
</Row>
);
})}
<Form.Item key={'submit'}>
<Row>
<Button className="submit-btn" type="primary" htmlType="submit">
</Button>
</Row>
</Form.Item>
</Form>
</>
);
};

View File

@@ -0,0 +1,630 @@
/* eslint-disable react/display-name */
import { Button, Divider, Drawer, Form, Input, InputNumber, message, Radio, Select, Spin, Space, Utils } from 'knowdesign';
import * as React from 'react';
import { useIntl } from 'react-intl';
import api from '../../api';
import { regClusterName, regUsername } from '../../common/reg';
import { bootstrapServersErrCodes, jmxErrCodes, zkErrCodes } from './config';
import CodeMirrorFormItem from '@src/components/CodeMirrorFormItem';
const rows = 4;
const lowKafkaVersion = '2.8.0';
const clientPropertiesPlaceholder = `用于创建Kafka客户端进行信息获取的相关配置
例如开启SCRAM-SHA-256安全管控模式的集群需输入如下配置
未开启安全管控可不进行任何输入:
{
"security.protocol": "SASL_PLAINTEXT",
"sasl.mechanism": "SCRAM-SHA-256",
"sasl.jaas.config":
"org.apache.kafka.common.security.scram.
ScramLoginModule required username="xxxxxx"
password="xxxxxx";"
}
`;
const AccessClusters = (props: any): JSX.Element => {
const intl = useIntl();
const [form] = Form.useForm();
const { afterSubmitSuccess, infoLoading, clusterInfo, visible } = props;
const [loading, setLoading] = React.useState(false);
const [security, setSecurity] = React.useState(clusterInfo?.security || 'None');
const [extra, setExtra] = React.useState({
versionExtra: '',
zooKeeperExtra: '',
bootstrapExtra: '',
jmxExtra: '',
});
const [isLowVersion, setIsLowVersion] = React.useState<any>(false);
const [zookeeperErrorStatus, setZookeeperErrorStatus] = React.useState<any>(false);
const lastFormItemValue = React.useRef({
bootstrap: clusterInfo?.bootstrapServers || '',
zookeeper: clusterInfo?.zookeeper || '',
clientProperties: clusterInfo?.clientProperties || {},
});
React.useEffect(() => {
const showLowVersion = !(clusterInfo?.zookeeper || !clusterInfo?.kafkaVersion || clusterInfo?.kafkaVersion >= lowKafkaVersion);
lastFormItemValue.current.bootstrap = clusterInfo?.bootstrapServers || '';
lastFormItemValue.current.zookeeper = clusterInfo?.zookeeper || '';
lastFormItemValue.current.clientProperties = clusterInfo?.clientProperties || {};
setIsLowVersion(showLowVersion);
setExtra({
...extra,
versionExtra: showLowVersion ? intl.formatMessage({ id: 'access.cluster.low.version.tip' }) : '',
});
form.setFieldsValue({ ...clusterInfo });
}, [clusterInfo]);
const onHandleValuesChange = (value: any, allValues: any) => {
Object.keys(value).forEach((key) => {
switch (key) {
case 'security':
setSecurity(value.security);
break;
case 'zookeeper':
setExtra({
...extra,
zooKeeperExtra: '',
bootstrapExtra: '',
jmxExtra: '',
});
break;
case 'bootstrapServers':
setExtra({
...extra,
zooKeeperExtra: '',
bootstrapExtra: '',
jmxExtra: '',
});
break;
case 'kafkaVersion':
setExtra({
...extra,
versionExtra: '',
});
break;
}
});
};
const onCancel = () => {
form.resetFields();
setLoading(false);
setZookeeperErrorStatus(false);
setIsLowVersion(false);
setSecurity('None');
setExtra({
versionExtra: '',
zooKeeperExtra: '',
bootstrapExtra: '',
jmxExtra: '',
});
lastFormItemValue.current = { bootstrap: '', zookeeper: '', clientProperties: {} };
props.setVisible && props.setVisible(false);
};
const onSubmit = () => {
form.validateFields().then((res) => {
let clientProperties = null;
try {
clientProperties = res.clientProperties && JSON.parse(res.clientProperties);
} catch (err) {
console.error(err);
}
const params = {
bootstrapServers: res.bootstrapServers,
clientProperties: clientProperties || {},
description: res.description || '',
jmxProperties: {
jmxPort: res.jmxPort,
maxConn: res.maxConn,
openSSL: res.security === 'Password',
token: res.token,
username: res.username,
},
kafkaVersion: res.kafkaVersion,
name: res.name,
zookeeper: res.zookeeper || '',
};
setLoading(true);
if (!isNaN(clusterInfo?.id)) {
Utils.put(api.phyCluster, {
...params,
id: clusterInfo?.id,
})
.then(() => {
message.success('编辑成功');
afterSubmitSuccess && afterSubmitSuccess();
onCancel();
})
.finally(() => {
setLoading(false);
});
} else {
Utils.post(api.phyCluster, params)
.then(() => {
message.success('集群接入成功。注意新接入集群数据稳定需要1-2分钟');
afterSubmitSuccess && afterSubmitSuccess();
onCancel();
})
.finally(() => {
setLoading(false);
});
}
});
};
const connectTest = () => {
const bootstrapServers = form.getFieldValue('bootstrapServers');
const zookeeper = form.getFieldValue('zookeeper');
let clientProperties = {};
try {
clientProperties = form.getFieldValue('clientProperties') && JSON.parse(form.getFieldValue('clientProperties'));
} catch (err) {
console.error(`JSON.parse(form.getFieldValue('clientProperties')) ERROR: ${err}`);
}
setLoading(true);
setIsLowVersion(false);
setZookeeperErrorStatus(false);
return Utils.post(api.kafkaValidator, {
bootstrapServers: bootstrapServers || '',
zookeeper: zookeeper || '',
clientProperties,
})
.then((res: any) => {
form.setFieldsValue({
jmxPort: res.jmxPort,
});
if (props.kafkaVersion.indexOf(res.kafkaVersion) > -1) {
form.setFieldsValue({
kafkaVersion: res.kafkaVersion,
});
} else {
form.setFieldsValue({
kafkaVersion: undefined,
});
}
form.setFieldsValue({
zookeeper: zookeeper || res.zookeeper,
});
const errList = res.errList || [];
const extraMsg = extra;
// 初始化信息为连接成功
extraMsg.bootstrapExtra = bootstrapServers ? '连接成功' : '';
extraMsg.zooKeeperExtra = zookeeper ? '连接成功' : '';
// 处理错误信息
errList.forEach((item: any) => {
const { code, message } = item;
let modifyKey: 'bootstrapExtra' | 'zooKeeperExtra' | 'jmxExtra' | undefined;
if (bootstrapServersErrCodes.includes(code)) {
modifyKey = 'bootstrapExtra';
} else if (zkErrCodes.includes(code)) {
modifyKey = 'zooKeeperExtra';
} else if (jmxErrCodes.includes(code)) {
modifyKey = 'jmxExtra';
}
if (modifyKey) {
extraMsg[modifyKey] = `连接失败。${message}`;
}
});
// 如果kafkaVersion小于最低版本则提示
const showLowVersion = !(clusterInfo?.zookeeper || !clusterInfo?.kafkaVersion || clusterInfo?.kafkaVersion >= lowKafkaVersion);
setIsLowVersion(showLowVersion);
setExtra({
...extraMsg,
versionExtra: showLowVersion ? intl.formatMessage({ id: 'access.cluster.low.version.tip' }) : '',
});
return res;
})
.finally(() => {
setLoading(false);
});
};
return (
<>
<Drawer
className="drawer-content drawer-access-cluster"
onClose={onCancel}
maskClosable={false}
extra={
<div className="operate-wrap">
<Space>
<Button size="small" onClick={onCancel}>
</Button>
<Button size="small" type="primary" onClick={onSubmit}>
</Button>
<Divider type="vertical" />
</Space>
</div>
}
title={intl.formatMessage({ id: props.title || 'access.cluster' })}
visible={props.visible}
placement="right"
width={480}
>
<Spin spinning={loading || !!infoLoading}>
<Form
form={form}
initialValues={{
security,
...clusterInfo,
}}
layout="vertical"
onValuesChange={onHandleValuesChange}
>
<Form.Item
name="name"
label="集群名称"
validateTrigger="onBlur"
rules={[
{
required: true,
validator: async (rule: any, value: string) => {
if (!value) {
return Promise.reject('集群名称不能为空');
}
if (value === clusterInfo?.name) {
return Promise.resolve();
}
if (value?.length > 128) {
return Promise.reject('集群名称长度限制在1128字符');
}
if (!new RegExp(regClusterName).test(value)) {
return Promise.reject(
'集群名称支持中英文、数字、特殊字符 ! " # $ % & \' ( ) * + , - . / : ; < = > ? @ [ ] ^ _ ` { | } ~'
);
}
return Utils.request(api.getClusterBasicExit(value)).then((res: any) => {
const data = res || {};
if (data?.exist) {
return Promise.reject('集群名称重复');
} else {
return Promise.resolve();
}
});
},
},
]}
>
<Input />
</Form.Item>
<Form.Item
name="bootstrapServers"
label="Bootstrap Servers"
extra={
extra.bootstrapExtra.includes('连接成功') ? (
<span>{extra.bootstrapExtra}</span>
) : (
<span className="error-extra-info">{extra.bootstrapExtra}</span>
)
}
validateTrigger={'onBlur'}
rules={[
{
required: true,
validator: async (rule: any, value: string) => {
if (!value) {
return Promise.reject('Bootstrap Servers不能为空');
}
if (value.length > 2000) {
return Promise.reject('Bootstrap Servers长度限制在2000字符');
}
if (value && value !== lastFormItemValue.current.bootstrap) {
return connectTest()
.then((res: any) => {
lastFormItemValue.current.bootstrap = value;
return Promise.resolve('');
})
.catch((err) => {
return Promise.reject('连接失败');
});
}
return Promise.resolve('');
},
},
]}
>
<Input.TextArea
rows={3}
placeholder="请输入Bootstrap Servers地址例如192.168.1.1:9092,192.168.1.2:9092,192.168.1.3:9092"
/>
</Form.Item>
<Form.Item
name="zookeeper"
label="Zookeeper"
extra={
extra.zooKeeperExtra.includes('连接成功') ? (
<span>{extra.zooKeeperExtra}</span>
) : (
<span className="error-extra-info">{extra.zooKeeperExtra}</span>
)
}
validateStatus={zookeeperErrorStatus ? 'error' : 'success'}
validateTrigger={'onBlur'}
rules={[
{
required: false,
validator: async (rule: any, value: string) => {
if (!value) {
setZookeeperErrorStatus(false);
return Promise.resolve('');
}
if (value.length > 2000) {
return Promise.reject('Zookeeper长度限制在2000字符');
}
if (value && value !== lastFormItemValue.current.zookeeper) {
return connectTest()
.then((res: any) => {
lastFormItemValue.current.zookeeper = value;
setZookeeperErrorStatus(false);
return Promise.resolve('');
})
.catch((err) => {
setZookeeperErrorStatus(true);
return Promise.reject('连接失败');
});
}
return Promise.resolve('');
},
},
]}
>
<Input.TextArea
rows={3}
placeholder="请输入Zookeeper地址例如192.168.0.1:2181,192.168.0.2:2181,192.168.0.2:2181/ks-kafka"
/>
</Form.Item>
<Form.Item
className="no-item-control"
name="Metrics"
label="Metrics"
rules={[
{
required: false,
message: '',
},
]}
>
<></>
</Form.Item>
<Form.Item
name="jmxPort"
label="JMX Port"
className="inline-item adjust-height-style"
extra={extra.jmxExtra}
rules={[
{
required: false,
message: '',
},
]}
>
<InputNumber style={{ width: 134 }} min={0} max={99999} />
</Form.Item>
<Form.Item
name="maxConn"
label="MaxConn"
className="inline-item adjust-height-style"
rules={[
{
required: false,
message: '',
},
]}
>
<InputNumber style={{ width: 134 }} min={0} max={99999} />
</Form.Item>
<Form.Item
name="security"
label="Security"
className="inline-item adjust-height-style"
rules={[
{
required: false,
message: '',
},
]}
>
<Radio.Group>
<Radio value="None">None</Radio>
<Radio value="Password">Password Authentication</Radio>
</Radio.Group>
</Form.Item>
{security === 'Password' ? (
<>
<Form.Item
className="inline-item max-width-66"
name="username"
label="User Info"
style={{ width: '58%' }}
rules={[
{
required: security === 'Password' || clusterInfo?.security === 'Password',
validator: async (rule: any, value: string) => {
if (!value) {
return Promise.reject('用户名不能为空');
}
if (!new RegExp(regUsername).test(value)) {
return Promise.reject('仅支持大小写、下划线、短划线(-');
}
if (value.length > 128) {
return Promise.reject('用户名长度限制在1128字符');
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
className="inline-item"
name="token"
label=""
style={{ width: '38%', marginRight: 0 }}
rules={[
{
required: security === 'Password' || clusterInfo?.security === 'Password',
validator: async (rule: any, value: string) => {
if (!value) {
return Promise.reject('密码不能为空');
}
if (!new RegExp(regUsername).test(value)) {
return Promise.reject('密码只能由大小写、下划线、短划线(-)组成');
}
if (value.length < 6 || value.length > 32) {
return Promise.reject('密码长度限制在632字符');
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="请输入密码" />
</Form.Item>
</>
) : null}
<Form.Item
name="kafkaVersion"
label="Version"
extra={<span className="error-extra-info">{extra.versionExtra}</span>}
validateStatus={isLowVersion ? 'error' : 'success'}
rules={[
{
required: true,
validator: async (rule: any, value: any) => {
if (!value) {
setIsLowVersion(true);
return Promise.reject('版本号不能为空');
}
// 检测版本号小于2.8.0如果没有填zookeeper信息才会提示
const zookeeper = form.getFieldValue('zookeeper');
if (value < lowKafkaVersion && !zookeeper) {
setIsLowVersion(true);
setExtra({
...extra,
versionExtra: intl.formatMessage({ id: 'access.cluster.low.version.tip' }),
});
return Promise.resolve();
}
setIsLowVersion(false);
return Promise.resolve();
},
},
]}
>
<Select placeholder="请选择Kafka Version如无匹配则选择相近版本">
{(props.kafkaVersion || []).map((item: string) => (
<Select.Option key={item} value={item}>
{item}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="clientProperties"
label="集群配置"
rules={[
{
required: false,
message: '请输入集群配置',
},
() => ({
validator(_, value) {
try {
if (value) {
JSON.parse(value);
}
return Promise.resolve();
} catch (e) {
return Promise.reject(new Error('输入内容必须为 JSON'));
}
},
}),
]}
>
<div>
<CodeMirrorFormItem
resize
defaultInput={form.getFieldValue('clientProperties')}
placeholder={clientPropertiesPlaceholder}
onBeforeChange={(clientProperties: string) => {
form.setFieldsValue({ clientProperties });
form.validateFields(['clientProperties']);
}}
onBlur={(value: any) => {
form.validateFields(['clientProperties']).then(() => {
const bootstrapServers = form.getFieldValue('bootstrapServers');
const zookeeper = form.getFieldValue('zookeeper');
const clientProperties = form.getFieldValue('clientProperties');
if (
clientProperties &&
clientProperties !== lastFormItemValue.current.clientProperties &&
(!!bootstrapServers || !!zookeeper)
) {
connectTest()
.then((res: any) => {
lastFormItemValue.current.clientProperties = clientProperties;
})
.catch((err) => {
message.error('连接失败');
});
}
});
}}
/>
</div>
</Form.Item>
<Form.Item
name="description"
label="集群描述"
rules={[
{
required: false,
validator: async (rule: any, value: string) => {
if (!value) {
return Promise.resolve('');
}
if (value && value.length > 200) {
return Promise.reject('集群描述长度限制在200字符');
}
return Promise.resolve();
},
},
]}
>
<Input.TextArea rows={rows} />
</Form.Item>
</Form>
</Spin>
</Drawer>
</>
);
};
export default AccessClusters;

View File

@@ -0,0 +1,130 @@
import { DoubleRightOutlined } from '@ant-design/icons';
import { Checkbox } from 'knowdesign';
import { debounce } from 'lodash';
import React, { useEffect } from 'react';
const CheckboxGroup = Checkbox.Group;
interface IVersion {
firstLine: string[];
leftVersion: string[];
}
const CustomCheckGroup = (props: { kafkaVersion: string[]; onChangeCheckGroup: any }) => {
const { kafkaVersion, onChangeCheckGroup } = props;
const [checkedKafkaVersion, setCheckedKafkaVersion] = React.useState<IVersion>({
firstLine: [],
leftVersion: [],
});
const [allVersion, setAllVersion] = React.useState<IVersion>({
firstLine: [],
leftVersion: [],
});
const [indeterminate, setIndeterminate] = React.useState(false);
const [checkAll, setCheckAll] = React.useState(true);
const [moreGroupWidth, setMoreGroupWidth] = React.useState(400);
const [showMore, setShowMore] = React.useState(false);
useEffect(() => {
document.addEventListener('click', handleDocumentClick);
return () => {
document.removeEventListener('click', handleDocumentClick);
}
}, []);
const handleDocumentClick = (e: Event) => {
setShowMore(false);
}
const setCheckAllStauts = (list: string[], otherList: string[]) => {
onChangeCheckGroup([...list, ...otherList]);
setIndeterminate(!!list.length && list.length + otherList.length < kafkaVersion.length);
setCheckAll(list.length + otherList.length === kafkaVersion.length);
};
const getTwoPanelVersion = () => {
const width = (document.getElementsByClassName('custom-check-group')[0] as any)?.offsetWidth;
const checkgroupWidth = width - 100 - 86;
const num = (checkgroupWidth / 108) | 0;
const firstLine = Array.from(kafkaVersion).splice(0, num);
setMoreGroupWidth(num * 108 + 88 + 66);
const leftVersion = Array.from(kafkaVersion).splice(num);
return { firstLine, leftVersion };
};
const onFirstVersionChange = (list: []) => {
setCheckedKafkaVersion({
...checkedKafkaVersion,
firstLine: list,
});
setCheckAllStauts(list, checkedKafkaVersion.leftVersion);
};
const onLeftVersionChange = (list: []) => {
setCheckedKafkaVersion({
...checkedKafkaVersion,
leftVersion: list,
});
setCheckAllStauts(list, checkedKafkaVersion.firstLine);
};
const onCheckAllChange = (e: any) => {
const versions = getTwoPanelVersion();
setCheckedKafkaVersion(
e.target.checked
? versions
: {
firstLine: [],
leftVersion: [],
}
);
onChangeCheckGroup(e.target.checked ? [...versions.firstLine, ...versions.leftVersion] : []);
setIndeterminate(false);
setCheckAll(e.target.checked);
};
React.useEffect(() => {
const handleVersionLine = () => {
const versions = getTwoPanelVersion();
setAllVersion(versions);
setCheckedKafkaVersion(versions);
};
handleVersionLine();
window.addEventListener('resize', handleVersionLine); //监听窗口大小改变
return () => window.removeEventListener('resize', debounce(handleVersionLine, 500));
}, []);
return (
<>
<div className="custom-check-group" onClick={(e) => e.nativeEvent.stopImmediatePropagation()}>
<div>
<Checkbox className="check-all" indeterminate={indeterminate} onChange={onCheckAllChange} checked={checkAll}>
</Checkbox>
</div>
<CheckboxGroup options={allVersion.firstLine} value={checkedKafkaVersion.firstLine} onChange={onFirstVersionChange} />
{showMore ? (
<CheckboxGroup
style={{ width: moreGroupWidth }}
className="more-check-group"
options={allVersion.leftVersion}
value={checkedKafkaVersion.leftVersion}
onChange={onLeftVersionChange}
/>
) : null}
<div className="more-btn" onClick={() => setShowMore(!showMore)}>
<a>
{!showMore ? '展开更多' : '收起更多'} <DoubleRightOutlined style={{ transform: `rotate(${showMore ? '270' : '90'}deg)` }} />
</a>
</div>
</div>
</>
);
};
export default CustomCheckGroup;

View File

@@ -0,0 +1,317 @@
import React, { useEffect, useMemo, useRef, useState, useReducer } from 'react';
import { Slider, Input, Select, Checkbox, Button, Utils, Spin, IconFont, AppContainer } from 'knowdesign';
import API from '../../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 AccessClusters from './AccessCluster';
import CustomCheckGroup from './CustomCheckGroup';
import { ClustersPermissionMap } from '../CommonConfig';
const CheckboxGroup = Checkbox.Group;
const { Option } = Select;
const MultiClusterPage = () => {
const [run, setRun] = useState<boolean>(false);
const [global] = AppContainer.useGlobalValue();
const [statusList, setStatusList] = React.useState([1, 0]);
const [kafkaVersion, setKafkaVersion] = React.useState({});
const [visible, setVisible] = React.useState(false);
const [list, setList] = useState<[]>([]);
const [healthScoreRange, setHealthScoreRange] = React.useState([0, 100]);
const [checkedKafkaVersion, setCheckedKafkaVersion] = React.useState({});
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({
downCount: 0,
liveCount: 0,
total: 0,
});
const [pagination, setPagination] = useState({
pageNo: 1,
pageSize: 10,
total: 0,
});
const searchKeyword = useRef('');
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: checkedKafkaVersion,
},
],
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 = () => {
setVersionLoading(true);
Utils.request(API.supportKafkaVersion)
.then((res) => {
setKafkaVersion(res || {});
setVersionLoading(false);
setCheckedKafkaVersion(res ? Object.keys(res) : []);
})
.catch((err) => {
setVersionLoading(false);
});
};
const getPhyClusterState = () => {
Utils.request(API.phyClusterState)
.then((res: any) => {
setStateInfo(res);
})
.finally(() => {
setPageLoading(false);
});
};
useEffect(() => {
getPhyClusterState();
getSupportKafkaVersion();
}, []);
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, checkedKafkaVersion, 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: []) => {
setCheckedKafkaVersion(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">
{Object.keys(kafkaVersion).length ? (
<CustomCheckGroup kafkaVersion={Object.keys(kafkaVersion)} 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>
<Spin spinning={clusterLoading}>{renderList}</Spin>
</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()}
<AccessClusters
visible={visible}
setVisible={setVisible}
kafkaVersion={Object.keys(kafkaVersion)}
afterSubmitSuccess={afterSubmitSuccessAccessClusters}
/>
</>
);
};
export default MultiClusterPage;

View File

@@ -0,0 +1,373 @@
import { AppContainer, Divider, Form, IconFont, Input, List, message, Modal, Progress, Spin, Tooltip, Utils } from 'knowdesign';
import moment from 'moment';
import React, { useEffect, useMemo, useState, useReducer } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
import { Link, useHistory } from 'react-router-dom';
import { timeFormat } from '../../constants/common';
import { IMetricPoint, linesMetric } from './config';
import { useIntl } from 'react-intl';
import api, { MetricType } from '../../api';
import { getHealthClassName, getHealthProcessColor, getHealthText } from '../SingleClusterDetail/config';
import { ClustersPermissionMap } from '../CommonConfig';
import { getUnit, getDataNumberUnit } from '@src/constants/chartConfig';
import SmallChart from '@src/components/SmallChart';
const ListScroll = (props: { loadMoreData: any; list: any; pagination: any; getPhyClusterState: any }) => {
const history = useHistory();
const [global] = AppContainer.useGlobalValue();
const [form] = Form.useForm();
const [list, setList] = useState<[]>(props.list || []);
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const [clusterInfo, setClusterInfo] = useState({} as any);
const [pagination, setPagination] = useState(
props.pagination || {
pageNo: 1,
pageSize: 10,
total: 0,
}
);
const intl = useIntl();
useEffect(() => {
setList(props.list || []);
setPagination(props.pagination || {});
}, [props.list, props.pagination]);
useEffect(() => {
if (visible) {
form.resetFields();
}
}, [visible]);
const loadMoreData = async () => {
if (loading) {
return;
}
setLoading(true);
const res = await props.loadMoreData(pagination.pageNo + 1, pagination.pageSize);
const _data = list.concat(res.bizData || []) as any;
setList(_data);
setPagination(res.pagination);
setLoading(false);
};
const RenderItem = (itemData: any) => {
itemData = itemData || {};
const metrics = linesMetric;
const metricPoints = [] as IMetricPoint[];
metrics.forEach((item) => {
const line = {
metricName: item,
value: itemData.latestMetrics?.metrics?.[item] || 0,
unit: (global.getMetricDefine && global.getMetricDefine(MetricType.Cluster, item).unit) || '',
metricLines: {
name: item,
data: itemData.metricLines
.find((metric: any) => metric.metricName === item)
?.metricPoints.map((point: IMetricPoint) => [point.timeStamp, point.value]),
},
} as IMetricPoint;
// 如果单位是 字节 ,进行单位换算
if (line.unit.toLowerCase().includes('byte')) {
const [unit, size] = getUnit(line.value);
line.value = Number((line.value / size).toFixed(2));
line.unit = line.unit.toLowerCase().replace('byte', unit);
}
// Messages 指标值特殊处理
if (line.metricName === 'LeaderMessages') {
const [unit, size] = getDataNumberUnit(line.value);
line.value = Number((line.value / size).toFixed(2));
line.unit = unit + line.unit;
}
metricPoints.push(line);
});
const {
Brokers: brokers,
Zookeepers: zks,
HealthCheckPassed: healthCheckPassed,
HealthCheckTotal: healthCheckTotal,
HealthScore: healthScore,
ZookeepersAvailable: zookeepersAvailable,
LoadReBalanceCpu: loadReBalanceCpu,
LoadReBalanceDisk: loadReBalanceDisk,
LoadReBalanceEnable: loadReBalanceEnable,
LoadReBalanceNwIn: loadReBalanceNwIn,
LoadReBalanceNwOut: loadReBalanceNwOut,
} = itemData.latestMetrics?.metrics || {};
return (
<List.Item
onClick={() => {
history.push(`/cluster/${itemData.id}/cluster`);
}}
>
<div className={'multi-cluster-list-item'}>
<div className="multi-cluster-list-item-healthy">
<Progress
type="circle"
status={!itemData.alive ? 'exception' : healthScore >= 90 ? 'success' : 'normal'}
strokeWidth={4}
// className={healthScore > 90 ? 'green-circle' : ''}
className={+itemData.alive <= 0 ? 'red-circle' : +healthScore < 90 ? 'blue-circle' : 'green-circle'}
strokeColor={getHealthProcessColor(healthScore, itemData.alive)}
percent={itemData.alive ? healthScore : 100}
format={() => (
<div className={`healthy-percent ${getHealthClassName(healthScore, itemData?.alive)}`}>
{getHealthText(healthScore, itemData?.alive)}
</div>
)}
width={70}
/>
<div className="healthy-degree">
<span className="healthy-degree-status"></span>
<span className="healthy-degree-proportion">
{healthCheckPassed}/{healthCheckTotal}
</span>
</div>
</div>
<div className="multi-cluster-list-item-right">
<div className="multi-cluster-list-item-base">
<div className="multi-cluster-list-item-base-left">
<div className="base-name">{itemData.name ?? '-'}</div>
<span className="base-version">{itemData.kafkaVersion ?? '-'}</span>
{loadReBalanceEnable !== undefined && (
<div
style={{
display: 'flex',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{[
['BytesIn', loadReBalanceEnable && loadReBalanceNwIn],
['BytesOut', loadReBalanceEnable && loadReBalanceNwOut],
['Disk', loadReBalanceEnable && loadReBalanceDisk],
].map(([name, isBalanced]) => {
return isBalanced ? (
<div className="balance-box balanced">{name} </div>
) : loadReBalanceEnable ? (
<div className="balance-box unbalanced">{name} </div>
) : (
<Tooltip
title={
<span>
{name}
<Link to={`/cluster/${itemData.id}/cluster/balance`}></Link>
</span>
}
>
<div className="balance-box unbalanced">{name} </div>
</Tooltip>
);
})}
</div>
)}
</div>
<div className="multi-cluster-list-item-base-date">{moment(itemData.createTime).format(timeFormat)}</div>
</div>
<div className="multi-cluster-list-item-Indicator">
<div className="indicator-left">
<div className="indicator-left-item">
<div className="indicator-left-item-title">
<span
className="indicator-left-item-title-dot"
style={{
background: itemData.latestMetrics?.metrics?.BrokersNotAlive ? '#FF7066' : '#34C38F',
}}
></span>
Brokers
</div>
<div className="indicator-left-item-value">{brokers}</div>
</div>
<div className="indicator-left-item">
<div className="indicator-left-item-title">
<span
className="indicator-left-item-title-dot"
style={{
background: zookeepersAvailable === -1 ? '#e9e7e7' : zookeepersAvailable === 0 ? '#FF7066' : '#34C38F',
}}
></span>
ZK
</div>
<div className="indicator-left-item-value">{zookeepersAvailable === -1 ? '-' : zks}</div>
</div>
</div>
<div className="indicator-right">
{metricPoints.map((row, index) => {
return (
<div
key={row.metricName + index}
className={`indicator-right-item ${row.metricName === 'LeaderMessages' ? 'first-right-item' : ''}`}
>
<div className="indicator-right-item-total">
<div className="indicator-right-item-total-name">
{row.metricName === 'TotalLogSize' ? 'MessageSize' : row.metricName}
</div>
<div className="indicator-right-item-total-value">
{row.value}
<span className="total-value-unit">{row.unit}</span>
</div>
</div>
<div className="indicator-right-item-chart">
<SmallChart width={79} height={40} chartData={row.metricLines} />
</div>
</div>
);
})}
</div>
</div>
</div>
{global.hasPermission && global.hasPermission(ClustersPermissionMap.CLUSTER_DEL) ? (
<div className="multi-cluster-list-item-btn">
<div className="icon" onClick={(event) => onClickDeleteBtn(event, itemData)}>
<IconFont type="icon-shanchu1" />
</div>
</div>
) : (
<></>
)}
</div>
</List.Item>
);
};
const onFinish = () => {
form.validateFields().then((formData) => {
Utils.delete(api.phyCluster, {
params: {
clusterPhyId: clusterInfo.id,
},
}).then((res) => {
message.success('删除成功');
setVisible(false);
props?.getPhyClusterState();
const fliterList: any = list.filter((item: any) => {
return item?.id !== clusterInfo.id;
});
setList(fliterList || []);
});
});
};
const onClickDeleteBtn = (event: any, clusterInfo: any) => {
event.stopPropagation();
setClusterInfo(clusterInfo);
setVisible(true);
};
return (
<>
{useMemo(
() => (
<InfiniteScroll
dataLength={list.length}
next={loadMoreData}
hasMore={list.length < pagination.total}
loader={<Spin style={{ paddingLeft: '50%', paddingTop: 15 }} spinning={loading} />}
endMessage={
!pagination.total ? (
''
) : (
<Divider className="load-completed-tip" plain>
{pagination.total}
</Divider>
)
}
scrollableTarget="scrollableDiv"
>
<List
bordered={false}
split={false}
className="multi-cluster-list"
itemLayout="horizontal"
dataSource={list}
renderItem={RenderItem}
/>
</InfiniteScroll>
),
[list, pagination, loading]
)}
<Modal
width={570}
destroyOnClose={true}
centered={true}
className="custom-modal"
wrapClassName="del-topic-modal delete-modal"
title={intl.formatMessage({
id: 'delete.cluster.confirm.title',
})}
visible={visible}
onOk={onFinish}
okText={intl.formatMessage({
id: 'btn.delete',
})}
cancelText={intl.formatMessage({
id: 'btn.cancel',
})}
onCancel={() => setVisible(false)}
okButtonProps={{
style: {
width: 56,
},
danger: true,
size: 'small',
}}
cancelButtonProps={{
style: {
width: 56,
},
size: 'small',
}}
>
<div className="tip-info">
<IconFont type="icon-warning-circle"></IconFont>
<span>
{intl.formatMessage({
id: 'delete.cluster.confirm.tip',
})}
</span>
</div>
<Form form={form} className="form" labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete="off">
<Form.Item label="集群名称" name="name" rules={[{ required: false, message: '' }]}>
<span>{clusterInfo.name}</span>
</Form.Item>
<Form.Item
label="集群名称"
name="clusterName"
rules={[
{
required: true,
message: intl.formatMessage({
id: 'delete.cluster.confirm.cluster',
}),
validator: (rule: any, value: string) => {
value = value || '';
if (!value.trim() || value.trim() !== clusterInfo.name)
return Promise.reject(
intl.formatMessage({
id: 'delete.cluster.confirm.cluster',
})
);
return Promise.resolve();
},
},
]}
>
<Input />
</Form.Item>
</Form>
</Modal>
</>
);
};
export default ListScroll;

View File

@@ -0,0 +1,155 @@
import { FormItemType, IFormItem } from 'knowdesign/lib/extend/x-form';
export const bootstrapServersErrCodes = [10, 11, 12];
export const zkErrCodes = [20, 21];
export const jmxErrCodes = [30, 31];
export const statusFilters = [
{
label: 'Live',
value: 1,
},
{
label: 'Down',
value: 0,
},
];
export const sortFieldList = [
{
label: '接入时间',
value: 'createTime',
},
{
label: '健康分',
value: 'HealthScore',
},
{
label: 'Messages',
value: 'LeaderMessages',
},
{
label: 'MessageSize',
value: 'TotalLogSize',
},
{
label: 'BytesIn',
value: 'BytesIn',
},
{
label: 'BytesOut',
value: 'BytesOut',
},
{
label: 'Brokers',
value: 'Brokers',
},
];
export const sortTypes = [
{
label: '升序',
value: 'asc',
},
{
label: '降序',
value: 'desc',
},
];
export const linesMetric = ['LeaderMessages', 'TotalLogSize', 'BytesIn', 'BytesOut'];
export const pointsMetric = ['HealthScore', 'HealthCheckPassed', 'HealthCheckTotal', 'Brokers', 'Zookeepers', ...linesMetric].concat(
process.env.BUSINESS_VERSION
? ['LoadReBalanceCpu', 'LoadReBalanceDisk', 'LoadReBalanceEnable', 'LoadReBalanceNwIn', 'LoadReBalanceNwOut']
: []
);
export const metricNameMap = {
LeaderMessages: 'Messages',
TotalLogSize: 'LogSize',
} as {
[key: string]: string;
};
export const healthSorceList = {
0: 0,
10: '',
20: 20,
30: '',
40: 40,
50: '',
60: 60,
70: '',
80: 80,
90: '',
100: 100,
};
export interface IMetricPoint {
aggType: string;
createTime: number;
metricName: string;
timeStamp: number;
unit: string;
updateTime: number;
value: number;
metricLines?: {
name: string;
data: [number | string, number | string];
};
}
export const getFormConfig = () => {
return [
{
key: 'name',
label: '集群名称',
type: FormItemType.input,
rules: [{ required: true, message: '请输入集群名称' }],
},
{
key: 'bootstrap',
label: 'Bootstrap Servers',
type: FormItemType.textArea,
rules: [
{
required: true,
message: '请输入Bootstrap Servers',
},
],
},
{
key: 'Zookeeper',
label: 'Zookeeper',
type: FormItemType.textArea,
rules: [
{
required: true,
message: '请输入Zookeeper',
},
],
},
{
key: 'config',
label: '集群配置',
type: FormItemType.textArea,
rules: [
{
required: true,
message: '请输入集群配置',
},
],
},
{
key: 'desc',
label: '集群描述',
type: FormItemType.textArea,
rules: [
{
required: true,
message: '请输入集群描述',
},
],
},
] as unknown as IFormItem[];
};

View File

@@ -0,0 +1,750 @@
@error-color: #f46a6a;
.multi-cluster-page {
position: absolute;
left: 0;
top: 48px;
width: 100%;
min-width: 1440px;
height: calc(100% - 48px);
overflow: auto;
.dcloud-checkbox-wrapper {
font-size: 13px;
}
&-fixed {
position: sticky;
top: 0;
z-index: 100;
width: 100%;
.content-container {
box-sizing: content-box;
max-width: 1400px;
min-width: 1200px;
margin: 0 auto;
padding: 24px 40px 20px 40px;
background-image: linear-gradient(#ebebf3, #ebebf3 95%, transparent);
}
.multi-cluster-header {
display: flex;
width: 100%;
margin-bottom: 20px;
.dcloud-checkbox-group {
font-size: 13px;
}
.dcloud-slider:hover {
opacity: 1;
.dcloud-slider-rail {
background-color: #ececf1;
}
.dcloud-slider-track {
background-color: #556ee6;
background: #556ee6;
}
.dcloud-slider-track-1 {
background-color: #556ee6;
background: #556ee6;
}
.dcloud-slider-handle:not(.dcloud-tooltip-open) {
border-color: #556ee6;
}
.dcloud-slider-handle:focus {
border-color: #556ee6;
background-color: #556ee6;
background: #556ee6;
}
}
.cluster-header-card {
position: relative;
width: 26%;
min-width: 330px;
max-width: 350px;
height: 168px;
margin-right: 12px;
padding: 23px 30px 0;
box-sizing: border-box;
border-radius: 10px;
color: #fff;
background-image: linear-gradient(to bottom right, #556ee6, #7389f3);
&-bg-left {
position: absolute;
left: 0;
top: 0;
width: 113px;
height: 135px;
background-image: url('../../assets/leftTop.png');
background-size: cover;
background-position-x: right;
border-radius: 12px;
overflow: hidden;
}
&-bg-right {
position: absolute;
right: 0;
bottom: 0;
width: 186px;
height: 145px;
background-size: cover;
background-image: url('../../assets/rightBottom.png');
}
.header-card-title {
color: #fff;
font-size: 16px;
line-height: 20px;
font-weight: normal;
margin-bottom: 0;
z-index: 1;
position: relative;
.chinese-text {
font-size: 14px;
}
}
.header-card-total {
font-family: DIDIFD-Medium;
margin-bottom: 18px;
font-size: 56px;
line-height: 56px;
z-index: 1;
position: relative;
}
.header-card-info {
font-size: 13px;
letter-spacing: 0;
text-align: justify;
line-height: 20px;
.card-info-item {
display: inline-block;
position: relative;
font-size: 13px;
letter-spacing: 0;
text-align: justify;
line-height: 20px;
padding: 0 6px;
> div {
margin-bottom: 2px;
}
&-live {
background: no-repeat url('../../assets/clusters-live-bg.png') bottom;
background-size: 100% 12px;
margin-left: -4px;
margin-right: 16px;
}
&-down {
background: no-repeat url('../../assets/clusters-down-bg.png') bottom;
background-size: 100% 12px;
}
.info-item-value {
position: relative;
font-family: DIDIFD-Black;
font-size: 22px;
letter-spacing: 0;
text-align: justify;
line-height: 16px;
margin-left: 3px;
em {
position: relative;
font-style: normal;
z-index: 999;
}
}
}
}
}
.cluster-header-filter {
flex: 1;
.header-filter-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
&-input {
flex: 1;
.icon {
font-size: 16px;
color: #adb5bc;
}
.dcloud-input-group-wrapper {
.dcloud-input-affix-wrapper {
border-radius: 6px;
}
.dcloud-input-group-addon {
background: rgba(33, 37, 41, 0.04);
.dcloud-btn {
border: none;
}
}
}
}
&-divider {
width: 1px;
height: 24px;
border: 1px solid #ced4da;
margin: 0 18px;
}
&-button {
width: 108px;
line-height: 19px;
.text {
font-family: @font-family-bold;
font-size: 14px;
color: #ffffff;
letter-spacing: 0;
margin-left: 6px;
}
}
}
.header-filter-bottom {
display: flex;
justify-content: space-around;
height: 120px;
box-sizing: border-box;
padding: 24px 0px 0;
background: #ffffff;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
border-radius: 12px;
&-item {
&-checkbox {
flex: 1;
}
&-slider {
width: 298px;
padding-right: 20px;
}
&-title {
margin-bottom: 24px;
font-size: 14px;
color: #495057;
letter-spacing: 0;
padding-left: 20px;
font-family: @font-family-bold;
&.title-right {
padding-left: 0;
}
}
&-content {
&.flex {
display: flex;
margin-right: 43px;
.dcloud-checkbox + span {
padding-right: 8px;
padding-left: 4px;
width: 86px;
}
.check-all {
height: 26px;
.dcloud-checkbox + span {
margin-right: 12px;
width: 40px;
font-size: 13px;
color: #495057;
letter-spacing: 0;
}
}
.custom-check-group {
display: inherit;
width: 100%;
position: relative;
.check-all {
padding-left: 20px;
}
.more-check-group {
position: absolute;
top: 34px;
float: left;
padding-left: 88px;
position: absolute;
width: 42%;
background: #ffffff;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
border-radius: 8px;
border-radius: 8px;
max-height: 92px;
overflow-x: hidden;
.dcloud-checkbox-group-item {
margin-bottom: 12px;
}
}
}
.more-btn {
height: 30px;
}
}
}
}
}
}
}
.multi-cluster-filter {
display: flex;
justify-content: space-between;
width: 100%;
height: 36px;
line-height: 38px;
&-select {
width: 26%;
max-width: 350px;
min-width: 330px;
display: flex;
}
&-checkbox {
.dcloud-checkbox-group {
font-size: 13px;
.dcloud-checkbox + span {
padding-left: 4px;
}
&-item:nth-child(1) {
margin-right: 12px;
}
}
}
}
}
.multi-cluster-list {
box-sizing: content-box;
max-width: 1420px;
min-width: 1220px;
margin: 0 auto;
padding: 0 30px;
.dcloud-list-item {
box-sizing: content-box;
width: calc(100% - 20px);
padding: 0;
text-align: center;
margin: 8px auto;
border-radius: 12px;
background: #fff;
position: relative;
transition: all 0.3s ease;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
&:first-child {
margin-top: 0;
}
}
.dcloud-list-item:hover {
padding: 0 10px;
box-shadow: 0 0 8px 0 rgba(101, 98, 240, 0.04), 0 6px 12px 12px rgba(101, 98, 240, 0.04), 0 6px 10px 0 rgba(101, 98, 240, 0.08);
.multi-cluster-list-item-btn {
opacity: 1;
.icon {
color: #74788d;
font-size: 14px;
}
.icon:hover {
color: #495057;
}
}
}
.multi-cluster-list-item-btn {
opacity: 0;
position: absolute;
right: 20px;
top: 8px;
z-index: 10;
text-align: right;
width: 24px;
height: 24px;
background: rgba(33, 37, 41, 0.04);
border-radius: 14px;
text-align: center;
line-height: 24px;
}
.multi-cluster-list-item {
display: flex;
width: 100%;
height: 128px;
margin: 0 auto;
padding: 16px 32px 12px 32px;
box-sizing: border-box;
border-radius: 12px;
cursor: pointer;
text-align: center;
transition: 0.5s all;
&-healthy {
margin-right: 24px;
.dcloud-progress-inner {
border-radius: 50%;
}
.green-circle {
.dcloud-progress-inner {
background: #f5fdfc;
}
}
.red-circle {
.dcloud-progress-inner {
background: #fffafa;
}
}
.healthy-percent {
font-family: DIDIFD-Regular;
font-size: 40px;
text-align: center;
line-height: 36px;
color: #00c0a2;
&.less-90 {
color: @primary-color;
}
&.no-info {
color: #e9e7e7;
}
&.down {
font-family: @font-family-bold;
font-size: 22px;
color: #ff7066;
text-align: center;
line-height: 30px;
}
}
.healthy-degree {
font-family: @font-family-bold;
font-size: 12px;
color: #495057;
line-height: 15px;
margin-top: 6px;
text-align: center;
&-status {
margin-right: 6px;
color: #74788d;
}
&-proportion {
}
}
}
&-right {
flex: 1;
display: flex;
flex-direction: column;
.multi-cluster-list-item-base {
display: flex;
justify-content: space-between;
width: 100%;
padding-top: 16px;
&-left {
display: flex;
align-items: center;
height: 22px;
.base-name {
margin-right: 8px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
font-family: @font-family-bold;
font-size: 18px;
color: #495057;
line-height: 22px;
}
.base-version {
height: 18px;
font-size: 12px;
line-height: 16px;
background: #ececf6;
border-radius: 4px;
padding: 1px 6px;
margin-right: 4px;
}
.balance-box {
height: 18px;
margin-right: 4px;
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
line-height: 16px;
&.balanced {
background: rgba(85, 110, 230, 0.1);
color: #556ee6;
}
&.unbalanced {
background: rgba(255, 136, 0, 0.1);
color: #f58342;
}
}
}
&-date {
font-size: 12px;
color: #adb5bc;
letter-spacing: 0;
text-align: right;
line-height: 18px;
}
}
.multi-cluster-list-item-Indicator {
flex: 1;
display: flex;
padding-top: 12px;
justify-content: space-between;
.indicator-left {
display: flex;
&-item {
margin-right: 32px;
&-title {
display: flex;
align-items: center;
font-size: 14px;
color: #74788d;
letter-spacing: 0;
text-align: justify;
line-height: 16px;
margin-bottom: 4px;
&-dot {
width: 6px;
height: 6px;
margin-right: 3px;
border-radius: 50%;
}
}
&-value {
margin-left: 10px;
font-size: 16px;
color: #495057;
letter-spacing: 0;
text-align: justify;
line-height: 20px;
font-family: @font-family-bold;
}
}
}
.indicator-right {
display: flex;
justify-content: space-between;
&-item {
flex: 1;
display: flex;
margin-right: 40px;
&-total {
margin-right: 10px;
text-align: left;
width: 96px;
&-name {
font-size: 12px;
color: #74788d;
line-height: 16px;
margin-bottom: 6px;
}
&-value {
font-size: 14px;
color: #212529;
letter-spacing: 0;
line-height: 20px;
.total-value-unit {
margin-left: 4px;
font-size: 12px;
color: #74788d;
letter-spacing: 0;
line-height: 14px;
}
}
}
&.first-right-item {
.indicator-right-item-total {
width: 116px;
}
}
&-chart {
}
}
&-item:last-child {
margin-right: 0px;
}
}
}
}
}
.multi-cluster-list-load {
font-size: 13px;
color: #74788d;
text-align: center;
margin-top: 12px;
height: 32px;
line-height: 32px;
}
}
.dcloud-divider-horizontal.dcloud-divider-with-text.load-completed-tip {
box-sizing: content-box;
width: calc(100% - 80px);
max-width: 1400px;
min-width: 1200px;
padding: 16px 40px 40px 40px;
margin: 0 auto;
}
}
.delete-modal {
.form {
padding-top: 16px;
.dcloud-col-4 {
max-width: 13.67%;
}
}
}
.drawer-content {
.dcloud-form-item-extra {
min-height: unset;
.error-extra-info {
color: @error-color;
}
}
.inline-item.dcloud-form-item {
display: -webkit-inline-box;
margin-right: 16px;
&.adjust-height-style{
.dcloud-form-item-label {
padding: 0;
label {
height: 36px;
}
}
.dcloud-form-item-control {
&-input {
height: 36px;
}
}
}
&.max-width-66 {
.dcloud-form-item-control {
max-width: 66%;
}
}
.dcloud-form-item-label {
margin-right: 12px;
label {
font-family: @font-family;
}
}
}
.no-item-control {
margin-bottom: 8px !important;
.dcloud-form-item-control {
display: none;
}
}
}
.empty-page {
height: 100%;
display: flex;
margin-top: 100px;
align-items: center;
flex-direction: column;
.header-filter-top-button {
width: 156px;
height: 36px;
background: #556ee6;
border-radius: 8px;
.text {
font-family: @font-family-bold;
font-size: 14px;
color: #ffffff;
letter-spacing: 0;
padding-left: 6px;
}
}
.title {
font-family: @font-family-bold;
font-size: 32px;
color: #212529;
text-align: center;
line-height: 48px;
}
.img {
display: flex;
margin-top: 22px;
margin-bottom: 39px;
.img-card-1 {
width: 282px;
height: 179px;
background-size: cover;
background-image: url('../../assets/dashborad.png');
}
.img-card-2 {
width: 286px;
height: 179px;
margin-left: 76px;
background-size: cover;
background-image: url('../../assets/state.png');
}
.img-card-3 {
width: 286px;
height: 179px;
margin-left: 76px;
background-size: cover;
background-image: url('../../assets/chart.png');
}
}
}

View File

@@ -0,0 +1,4 @@
import { Result } from 'knowdesign';
import React from 'react';
export default () => <Result status="403" title="No License" subTitle="很抱歉,您的 Licence 无法使用" />;

View File

@@ -0,0 +1,420 @@
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Button, Form, Input, Select, message, Drawer, Space, Divider, Utils, Radio, AutoComplete, Alert } from 'knowdesign';
import api from '@src/api';
import { useParams } from 'react-router-dom';
import { UsersProps } from '../SecurityUsers';
// 字段对应后端存储值的枚举类型
export enum ACL_OPERATION {
Unknown,
Any,
All,
Read,
Write,
Create,
Delete,
Alter,
Describe,
ClusterAction,
DescribeConfigs,
AlterConfigs,
IdempotentWrite,
}
export enum ACL_PERMISSION_TYPE {
Unknown,
Any,
Deny,
Allow,
}
export enum ACL_PATTERN_TYPE {
Unknown,
Any,
Match,
Literal,
Prefixed,
}
export enum ACL_RESOURCE_TYPE {
Unknown,
Any,
Topic,
Group,
Cluster,
TransactionalId,
DelegationToken,
}
export type RESOURCE_MAP_KEYS = Exclude<keyof typeof ACL_RESOURCE_TYPE, 'Unknown' | 'Any' | 'DelegationToken'>;
// 资源类型和操作映射表
export const RESOURCE_TO_OPERATIONS_MAP: {
[P in RESOURCE_MAP_KEYS]: string[];
} = {
Cluster: ['Alter', 'AlterConfigs', 'ClusterAction', 'Create', 'Describe', 'DescribeConfigs', 'IdempotentWrite'],
Topic: ['Alter', 'AlterConfigs', 'Create', 'Delete', 'Describe', 'DescribeConfigs', 'Read', 'Write'],
Group: ['Delete', 'Describe', 'Read'],
TransactionalId: ['Write', 'Describe'],
};
// ACL 配置类型
const CONFIG_TYPE = [
{
label: '配置生产权限',
value: 'produce',
},
{
label: '配置消费权限',
value: 'consume',
},
{
label: '配置自定义权限',
value: 'custom',
},
];
// eslint-disable-next-line react/display-name
const AddDrawer = forwardRef((_, ref) => {
const { clusterId } = useParams<{
clusterId: string;
}>();
const [form] = Form.useForm();
const [visible, setVisible] = useState<boolean>(false);
const [kafkaUserOptions, setKafkaUserOptions] = useState<{ label: string; value: string }[]>([]);
const [confirmLoading, setConfirmLoading] = useState<boolean>(false);
const callback = useRef(() => {
return;
});
const [topicMetaData, setTopicMetaData] = React.useState([]);
// 获取 Topic 元信息
const getTopicMetaData = (newValue: any) => {
Utils.request(api.getTopicMetaData(+clusterId), {
method: 'GET',
params: { searchKeyword: newValue },
}).then((res: UsersProps[]) => {
const topics = (res || []).map((item: any) => {
return {
label: item.topicName,
value: item.topicName,
};
});
setTopicMetaData(topics);
});
};
// 获取 kafkaUser 列表
const getKafkaUserList = () => {
Utils.request(api.getKafkaUsers(clusterId), {
method: 'GET',
}).then((res: UsersProps[]) => {
setKafkaUserOptions(res.map(({ name }) => ({ label: name, value: name })));
});
};
// 提交表单
const onSubmit = () => {
form.validateFields().then((formData) => {
const submitData = [];
const { configType, principle, kafkaUser } = formData;
if (configType === 'custom') {
// 1. 自定义权限
const { resourceType, resourcePatternType, aclPermissionType, aclOperation, aclClientHost } = formData;
submitData.push({
clusterId,
kafkaUser: principle === 'all' ? '*' : kafkaUser,
resourceType,
resourcePatternType,
resourceName: '*',
aclPermissionType,
aclOperation,
aclClientHost,
});
} else {
// 2. 生产或者消费权限
// 1). 配置生产权限将赋予 User 对应 Topic 的 Create、Write 权限
// 2). 配置消费权限将赋予 User 对应 Topic的 Read 权限和 Group 的 Read 权限
const { topicPatternType, topicPrinciple, topicName } = formData;
submitData.push({
clusterId,
kafkaUser: principle === 'all' ? '*' : kafkaUser,
resourceType: ACL_RESOURCE_TYPE.Topic,
resourcePatternType: topicPatternType,
resourceName: topicPrinciple === 'all' ? '*' : topicName,
aclPermissionType: ACL_PERMISSION_TYPE.Allow,
aclOperation: configType === 'consume' ? ACL_OPERATION.Read : ACL_OPERATION.Create,
aclClientHost: '*',
});
// 消费权限
if (configType === 'consume') {
const { groupPatternType, groupPrinciple, groupName } = formData;
submitData.push({
clusterId,
kafkaUser: principle === 'all' ? '*' : kafkaUser,
resourceType: ACL_RESOURCE_TYPE.Group,
resourcePatternType: groupPatternType,
resourceName: groupPrinciple === 'all' ? '*' : groupName,
aclPermissionType: ACL_PERMISSION_TYPE.Allow,
aclOperation: ACL_OPERATION.Read,
aclClientHost: '*',
});
} else {
submitData.push({
clusterId,
kafkaUser: principle === 'all' ? '*' : kafkaUser,
resourceType: ACL_RESOURCE_TYPE.Topic,
resourcePatternType: topicPatternType,
resourceName: topicPrinciple === 'all' ? '*' : topicName,
aclPermissionType: ACL_PERMISSION_TYPE.Allow,
aclOperation: ACL_OPERATION.Write,
aclClientHost: '*',
});
}
}
setConfirmLoading(true);
Utils.request(api.addACL, {
method: 'POST',
data: submitData,
}).then(
() => {
// 执行回调,刷新列表数据
callback.current();
onClose();
message.success('成功新增 ACL');
},
() => setConfirmLoading(false)
);
});
};
// 展开抽屉
const onOpen = (status: boolean, cbk: () => void) => {
setVisible(status);
callback.current = cbk;
};
// 关闭抽屉
const onClose = () => {
setVisible(false);
setConfirmLoading(false);
form.resetFields();
};
useImperativeHandle(ref, () => ({
onOpen,
}));
useEffect(() => {
getKafkaUserList();
getTopicMetaData('');
}, []);
return (
<Drawer
className="acls-edit-drawer"
title="新增ACL"
width={480}
visible={visible}
maskClosable={false}
onClose={onClose}
extra={
<Space>
<Button size="small" onClick={onClose}>
</Button>
<Button type="primary" size="small" loading={confirmLoading} onClick={onSubmit}>
</Button>
<Divider type="vertical" />
</Space>
}
>
<Alert
className="drawer-alert-full-screen"
message="新增 ACL 必须在集群已开启 ACL 功能时才会生效"
type="info"
showIcon
style={{ marginBottom: 20 }}
/>
<Form form={form} layout="vertical">
<Form.Item
label="ACL用途"
name="configType"
rules={[{ required: true, message: 'ACL用途不能为空' }]}
initialValue={CONFIG_TYPE[0].value}
>
<Select options={CONFIG_TYPE} />
</Form.Item>
<Form.Item label="Principle" name="principle" rules={[{ required: true, message: 'Principle 不能为空' }]} initialValue="all">
<Radio.Group>
<Radio value="all">ALL</Radio>
<Radio value="special">Special</Radio>
</Radio.Group>
</Form.Item>
<Form.Item dependencies={['principle']} style={{ marginBottom: 0 }}>
{({ getFieldValue }) =>
getFieldValue('principle') === 'special' ? (
<Form.Item name="kafkaUser" rules={[{ required: true, message: 'Kafka User 不能为空' }]}>
<Select placeholder="请选择 Kafka User" options={kafkaUserOptions} />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item dependencies={['configType']} style={{ marginBottom: 0 }}>
{({ getFieldValue }) => {
const PatternTypeFormItems = (props: { type: string }) => {
const { type } = props;
const UpperCaseType = type[0].toUpperCase() + type.slice(1);
return (
<div className="form-item-group">
<Form.Item
label={`${UpperCaseType} Pattern Type`}
name={`${type}PatternType`}
rules={[{ required: true, message: `${UpperCaseType} Pattern Type 不能为空` }]}
initialValue={ACL_PATTERN_TYPE['Literal']}
>
<Radio.Group>
<Radio value={ACL_PATTERN_TYPE['Literal']}>Literal</Radio>
<Radio value={ACL_PATTERN_TYPE['Prefixed']}>Prefixed</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label={UpperCaseType}
name={`${type}Principle`}
rules={[{ required: true, message: `${UpperCaseType} 不能为空` }]}
initialValue="all"
>
<Radio.Group>
<Radio value="all">ALL</Radio>
<Radio value="special">Special</Radio>
</Radio.Group>
</Form.Item>
<Form.Item dependencies={[`${type}Principle`]} style={{ marginBottom: 0 }}>
{({ getFieldValue }) =>
getFieldValue(`${type}Principle`) === 'special' ? (
<Form.Item
name={`${type}Name`}
dependencies={[`${type}PatternType`]}
validateTrigger="onBlur"
rules={[
({ getFieldValue }) => ({
validator: (rule: any, value: string) => {
if (!value) {
return Promise.reject(`${UpperCaseType}Name 不能为空`);
}
if (type === 'topic' && getFieldValue(`${type}PatternType`) === ACL_PATTERN_TYPE['Literal']) {
return Utils.request(api.getTopicMetadata(clusterId as any, value)).then((res: any) => {
return res?.exist ? Promise.resolve() : Promise.reject('该 Topic 不存在');
});
}
return Promise.resolve();
},
}),
]}
>
<AutoComplete
filterOption={(value, option) => {
if (option?.value.includes(value)) {
return true;
}
return false;
}}
options={topicMetaData}
placeholder={`请输入 ${type}Name`}
/>
</Form.Item>
) : null
}
</Form.Item>
</div>
);
};
const CustomFormItems = () => {
return (
<>
<Form.Item
label="Permission Type"
name="aclPermissionType"
rules={[{ required: true, message: 'Permission Type 不能为空' }]}
initialValue={ACL_PERMISSION_TYPE['Allow']}
>
<Radio.Group>
<Radio value={ACL_PERMISSION_TYPE['Allow']}>Allow</Radio>
<Radio value={ACL_PERMISSION_TYPE['Deny']}>Deny</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label="Pattern Type"
name="resourcePatternType"
rules={[{ required: true, message: 'Pattern Type 不能为空' }]}
initialValue={ACL_PATTERN_TYPE['Literal']}
>
<Radio.Group>
<Radio value={ACL_PATTERN_TYPE['Literal']}>Literal</Radio>
<Radio value={ACL_PATTERN_TYPE['Prefixed']}>Prefixed</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label="Resource Type"
name="resourceType"
rules={[{ required: true, message: 'Resource Type 不能为空' }]}
initialValue={ACL_RESOURCE_TYPE['Cluster']}
>
<Select
placeholder="请选择 Resource Type"
options={Object.keys(RESOURCE_TO_OPERATIONS_MAP).map((type: RESOURCE_MAP_KEYS) => ({
label: type,
value: ACL_RESOURCE_TYPE[type],
}))}
/>
</Form.Item>
<Form.Item dependencies={['resourceType']} style={{ marginBottom: 0 }}>
{({ getFieldValue }) => {
form.resetFields(['aclOperation']);
return (
<Form.Item label="Operation" name="aclOperation" rules={[{ required: true, message: 'Operation 不能为空' }]}>
<Select
placeholder="请选择 Resource Type"
options={RESOURCE_TO_OPERATIONS_MAP[ACL_RESOURCE_TYPE[getFieldValue('resourceType')] as RESOURCE_MAP_KEYS].map(
(type) => ({
label: type,
value: ACL_OPERATION[type as keyof typeof ACL_OPERATION],
})
)}
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item label="Host" name="aclClientHost" initialValue="*">
<Input />
</Form.Item>
</>
);
};
const type = getFieldValue('configType');
if (type === 'produce') {
return <PatternTypeFormItems type="topic" />;
} else if (type === 'consume') {
return (
<>
<PatternTypeFormItems type="topic" />
<PatternTypeFormItems type="group" />
</>
);
} else if (type === 'custom') {
return <CustomFormItems />;
} else {
return null;
}
}}
</Form.Item>
</Form>
</Drawer>
);
});
export default AddDrawer;

View File

@@ -0,0 +1,30 @@
.security-acls-page {
.card-bar {
margin: 12px 0;
}
&-list {
width: 100%;
height: fit-content;
padding: 16px 24px;
background: #ffffff;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
// border-radius: 12px;
}
.operate-bar {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
}
.acls-edit-drawer {
.form-item-group {
padding: 16px 20px 0 20px;
margin-bottom: 16px;
background: #f8f9fa;
border-radius: 8px;
}
.dcloud-form-item-control-input {
min-height: 0;
}
}

View File

@@ -0,0 +1,263 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Form, Input, Select, Modal, message, ProTable, AppContainer, DKSBreadcrumb, Utils } from 'knowdesign';
import ACLsCardBar from '@src/components/CardBar/ACLsCardBar';
import api from '@src/api';
import { useParams } from 'react-router-dom';
import AddACLDrawer, {
ACL_OPERATION,
ACL_PERMISSION_TYPE,
ACL_PATTERN_TYPE,
ACL_RESOURCE_TYPE,
RESOURCE_TO_OPERATIONS_MAP,
RESOURCE_MAP_KEYS,
} from './EditDrawer';
import './index.less';
const { confirm } = Modal;
export type ACLsProps = {
kafkaUser: string;
resourceType: ACL_RESOURCE_TYPE;
resourceName: string;
resourcePatternType: ACL_PATTERN_TYPE;
aclPermissionType: ACL_PATTERN_TYPE;
aclOperation: ACL_OPERATION;
aclClientHost: string;
};
export const defaultPagination = {
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
};
const SecurityACLs = (): JSX.Element => {
const [global] = AppContainer.useGlobalValue();
const { clusterId } = useParams<{
clusterId: string;
}>();
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<ACLsProps[]>([]);
const [pagination, setPagination] = useState<any>(defaultPagination);
const [form] = Form.useForm();
const editDrawerRef = useRef(null);
const getACLs = (query = {}) => {
const formData = form.getFieldsValue();
const queryData = {
// 模糊查询
fuzzySearchDTOList: [] as { fieldName: string; fieldValue: string }[],
// 精确查询
preciseFilterDTOList: [] as { fieldName: string; fieldValueList: (string | number)[] }[],
};
Object.entries(formData)
.filter((i) => i[1])
.forEach(([fieldName, fieldValue]: [string, any]) => {
if (fieldName === 'resourceType') {
queryData.preciseFilterDTOList.push({
fieldName,
fieldValueList: fieldValue.map((type: string) => ACL_RESOURCE_TYPE[type as RESOURCE_MAP_KEYS]),
});
} else {
queryData.fuzzySearchDTOList.push({
fieldName,
fieldValue,
});
}
});
const queryParams = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
...queryData,
...query,
};
setLoading(true);
Utils.request(api.getACLs(clusterId), {
method: 'POST',
data: queryParams,
}).then(
(res: any) => {
const { pageNo, pageSize, total } = res.pagination;
const pages = Math.ceil(total / pageSize);
if (pageNo > pages && pages !== 0) {
getACLs({ pageNo: pages });
return false;
}
setPagination({
...pagination,
current: pageNo,
pageSize,
total,
});
setData(res.bizData);
setLoading(false);
return true;
},
() => setLoading(false)
);
};
const columns = () => {
const baseColumns = [
{
title: 'Principal',
dataIndex: 'kafkaUser',
},
{
title: 'Permission',
dataIndex: 'aclPermissionType',
render(type: number) {
return ACL_PERMISSION_TYPE[type];
},
},
{
title: 'Pattern Type',
dataIndex: 'resourcePatternType',
width: 180,
render(type: number) {
return ACL_PATTERN_TYPE[type];
},
},
{
title: 'Operations',
dataIndex: 'aclOperation',
render(type: number) {
return ACL_OPERATION[type];
},
},
{
title: 'Resource',
dataIndex: 'resourceType',
render(type: number, record: ACLsProps) {
return `${ACL_RESOURCE_TYPE[type]} ${record.resourceName}`;
},
},
{
title: 'Host',
dataIndex: 'aclClientHost',
},
{
title: '操作',
dataIndex: '',
width: 120,
render(record: ACLsProps) {
return (
<>
<Button type="link" size="small" style={{ paddingLeft: 0 }} onClick={() => onDelete(record)}>
</Button>
</>
);
},
},
];
return baseColumns;
};
const onDelete = (record: ACLsProps) => {
confirm({
title: '确定删除 ACL 吗?',
okText: '删除',
okType: 'primary',
centered: true,
okButtonProps: {
size: 'small',
danger: true,
},
cancelButtonProps: {
size: 'small',
},
onOk() {
return Utils.request(api.delACLs, {
method: 'DELETE',
data: { ...record, clusterId: Number(clusterId) },
}).then((_) => {
message.success('删除成功');
getACLs();
});
},
});
};
const onTableChange = (curPagination: any) => {
getACLs({ pageNo: curPagination.current, pageSize: curPagination.pageSize });
};
useEffect(() => {
getACLs();
}, []);
return (
<div className="security-acls-page">
<DKSBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'ACLs', aHref: `` },
]}
/>
<div className="card-bar">
<ACLsCardBar />
</div>
<div className="security-acls-page-list clustom-table-content">
<div className="operate-bar">
<Form form={form} layout="inline" onFinish={() => getACLs({ page: 1 })}>
<Form.Item name="kafkaUser">
<Input placeholder="请输入 Principal" />
</Form.Item>
<Form.Item name="resourceType">
<Select
placeholder="选择 ResourceType"
options={Object.keys(RESOURCE_TO_OPERATIONS_MAP).map((key) => ({ label: key, value: key }))}
mode="multiple"
maxTagCount="responsive"
allowClear
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item name="resourceName">
<Input placeholder="请输入 Resource" />
</Form.Item>
<Form.Item>
<Button type="primary" ghost htmlType="submit">
</Button>
</Form.Item>
</Form>
<Button
type="primary"
// icon={<PlusOutlined />}
onClick={() => editDrawerRef.current.onOpen(true, getACLs)}
>
ACL
</Button>
</div>
<ProTable
tableProps={{
showHeader: false,
loading,
rowKey: 'id',
dataSource: data,
paginationProps: pagination,
columns: columns() as any,
lineFillColor: true,
attrs: {
onChange: onTableChange,
scroll: { y: 'calc(100vh - 400px)' },
},
}}
/>
</div>
{/* 新增 ACL 抽屉 */}
<AddACLDrawer ref={editDrawerRef} />
</div>
);
};
export default SecurityACLs;

View File

@@ -0,0 +1,59 @@
.security-users-page {
&-list {
width: 100%;
height: calc(100vh - 90px);
padding: 16px 24px;
margin-top: 12px;
background: #ffffff;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
.operate-bar {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
}
}
.users-edit-drawer {
.dcloud-form-item {
margin-bottom: 14px;
&-label {
font-family: @font-family-bold;
font-size: 13px;
color: #495057;
letter-spacing: 0;
line-height: 18px !important;
label {
width: 100%;
}
.complex-label {
width: 100%;
display: flex;
justify-content: space-between;
.random-icon {
font-size: 18px;
height: 18px;
color: #adb5bc;
}
.random-icon:hover {
height: 18px;
text-align: center;
border-radius: 9px;
line-height: 20px;
color: #74788d;
background: rgba(33, 37, 41, 0.04);
}
.switch {
margin-left: 12px;
}
}
}
}
}

View File

@@ -0,0 +1,468 @@
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import {
Button,
Form,
Input,
Modal,
message,
ProTable,
Drawer,
Space,
Divider,
AppContainer,
DKSBreadcrumb,
Utils,
Checkbox,
Tooltip,
IconFont,
Alert,
} from 'knowdesign';
import { CloseOutlined, EyeInvisibleOutlined, EyeOutlined, LoadingOutlined } from '@ant-design/icons';
import './index.less';
import api from '@src/api';
import { useParams } from 'react-router-dom';
import { regKafkaPassword } from '@src/common/reg';
export const randomString = (len = 32, chars = 'abcdefghijklmnopqrstuvwxyz1234567890'): string => {
const maxPos = chars.length;
let str = '';
for (let i = 0; i < len; i++) {
str += chars.charAt(Math.floor(Math.random() * maxPos));
}
return str;
};
const { confirm } = Modal;
enum UsersOperate {
Add,
ChangePassword,
}
export type UsersProps = {
clusterId: number;
name: string;
authType: any;
authName: string;
createTime: string;
updateTime: string;
};
export const defaultPagination = {
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
};
const AES_KEY = 'KnowStreamingKM$';
const initialShowPassword = '********';
const AUTH_TYPES = [
{
label: 'SCRAM',
value: 1300,
},
{
label: 'GSSAPI',
value: 'gssapi',
disabled: true,
},
{
label: 'PLAIN',
value: 'plain',
disabled: true,
},
{
label: 'OAUTHBEARER',
value: 'oauthbearer',
disabled: true,
},
];
const PasswordContent = (props: { clusterId: string; name: string }) => {
const { clusterId, name } = props;
const [loading, setLoading] = useState(false);
const [pw, setPw] = useState(initialShowPassword);
const switchPwStatus = () => {
if (!loading) {
setLoading(true);
if (pw === initialShowPassword) {
Utils.request(api.getKafkaUserToken(clusterId, name)).then(
(res: any) => {
let token = res.token || '';
if (res?.decrypt) {
token = Utils.decryptAES(token, AES_KEY);
}
setPw(token);
setLoading(false);
},
() => setLoading(false)
);
} else {
setPw(initialShowPassword);
setLoading(false);
}
}
};
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title={pw} placement="bottom">
<div style={{ maxWidth: '80%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pw}</div>
</Tooltip>
<span style={{ marginLeft: 6 }} onClick={switchPwStatus}>
{loading ? <LoadingOutlined /> : pw === initialShowPassword ? <EyeInvisibleOutlined /> : <EyeOutlined />}
</span>
</div>
);
};
// 新增 KafkaUser / 修改 KafkaUser 密码
// eslint-disable-next-line react/display-name
const EditKafkaUser = forwardRef((_, ref) => {
const { clusterId } = useParams<{
clusterId: string;
}>();
const [type, setType] = useState<UsersOperate>(UsersOperate.Add);
const [form] = Form.useForm();
const [visible, setVisible] = useState<boolean>(false);
const [confirmLoading, setConfirmLoading] = useState<boolean>(false);
const callback = useRef(() => {
return;
});
// 提交表单
const onSubmit = () => {
form.validateFields().then((formData) => {
// 对密码进行加密
formData.token = Utils.encryptAES(formData.token, AES_KEY);
// 注意:目前 authType 字段固定传 SCRAM 认证方式的值,之后如果开通了多认证方式,这里需要做更改
formData.authType = 1300;
setConfirmLoading(true);
Utils.request(type === UsersOperate.Add ? api.kafkaUser : api.updateKafkaUserToken, {
method: type === UsersOperate.Add ? 'POST' : 'PUT',
data: Object.assign(formData, { clusterId: Number(clusterId) }),
}).then(
() => {
// 执行回调,刷新列表数据
callback.current();
onClose();
message.success(type === UsersOperate.Add ? '成功新增 KafkaUser' : '成功修改密码');
},
() => setConfirmLoading(false)
);
});
};
// 展开抽屉
const onOpen = (status: boolean, type: UsersOperate, cbk: () => void, detail: UsersProps) => {
detail && form.setFieldsValue({ kafkaUser: detail.name });
setType(type);
setVisible(status);
callback.current = cbk;
};
// 关闭抽屉
const onClose = () => {
setVisible(false);
setConfirmLoading(false);
form.resetFields();
};
useImperativeHandle(ref, () => ({
onOpen,
}));
return (
<Drawer
className="users-edit-drawer"
title={type === UsersOperate.Add ? '新增 KafkaUser' : '修改密码'}
width={480}
visible={visible}
maskClosable={false}
onClose={onClose}
extra={
<Space>
<Button size="small" onClick={onClose}>
</Button>
<Button type="primary" size="small" loading={confirmLoading} onClick={onSubmit}>
</Button>
<Divider type="vertical" />
</Space>
}
>
{type === UsersOperate.Add && (
<Alert
className="drawer-alert-full-screen"
message="新增 KafkaUser 必须在集群已开启 ACL 功能时才会生效"
type="info"
showIcon
style={{ marginBottom: 20 }}
/>
)}
<Form form={form} layout="vertical">
<Form.Item
label="KafkaUser"
name="kafkaUser"
rules={[
{ required: true, message: 'KafkaUser 不能为空' },
{ pattern: /^[a-zA-Z0-9]{3,128}$/, message: 'KafkaUser 支持大小写英文、数字,长度为 3~128 字符' },
]}
>
<Input placeholder="请输入 KakfaUser" disabled={type === UsersOperate.ChangePassword} />
</Form.Item>
{type === UsersOperate.Add && (
<Form.Item
label="认证方式"
name="authType"
rules={[{ required: true, message: '认证方式不能为空' }]}
initialValue={[AUTH_TYPES[0].value]}
>
<Checkbox.Group>
{AUTH_TYPES.map((type) => {
return type.disabled ? (
<Tooltip title="当前版本暂不支持">
<Checkbox value={type.value} disabled>
{type.label}
</Checkbox>
</Tooltip>
) : (
<Checkbox value={type.value}>{type.label}</Checkbox>
);
})}
</Checkbox.Group>
</Form.Item>
)}
<Form.Item
label={
<div className="complex-label">
<span>{type === UsersOperate.ChangePassword && 'New'} Password</span>
<span>
<Tooltip title={'生成随机内容'} placement="left">
<IconFont
type="icon-shengchengdaima"
className="random-icon"
onClick={() => {
const randomStr = randomString(
12,
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890_-!"#$%&\'()*+,./:;<=>?@[]^`{|}~'
);
form && form.setFieldsValue({ token: randomStr });
}}
/>
</Tooltip>
</span>
</div>
}
name="token"
rules={[
{ required: true, message: 'Password 不能为空' },
{
pattern: regKafkaPassword,
message: '密码支持中英文、数字、特殊字符 ! " # $ % & \' ( ) * + , - . / : ; < = > ? @ [ ] ^ _ ` { | } ~',
},
]}
>
<Input.Password placeholder="请输入密码" maxLength={128} />
</Form.Item>
</Form>
</Drawer>
);
});
const SecurityUsers = (): JSX.Element => {
const [global] = AppContainer.useGlobalValue();
const { clusterId } = useParams<{
clusterId: string;
}>();
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<UsersProps[]>([]);
const [pagination, setPagination] = useState<any>(defaultPagination);
const [form] = Form.useForm();
const editDrawerRef = useRef(null);
const getKafkaUserList = (query = {}) => {
const formData = form.getFieldsValue();
const queryParams = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
...formData,
...query,
};
setLoading(true);
Utils.request(api.getKafkaUsers(clusterId), {
method: 'POST',
data: queryParams,
}).then(
(res: any) => {
const { pageNo, pageSize, total } = res.pagination;
const pages = Math.ceil(total / pageSize);
if (pageNo > pages && pages !== 0) {
getKafkaUserList({ pageNo: pages });
return false;
}
setPagination({
...pagination,
current: pageNo,
pageSize,
total,
});
setData(res.bizData);
setLoading(false);
return true;
},
() => setLoading(false)
);
};
const columns = () => {
const baseColumns = [
{
title: 'KafkaUser',
dataIndex: 'name',
width: 300,
render(name: string) {
return (
<Tooltip title={name} placement="topLeft">
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</div>
</Tooltip>
);
},
},
{
title: '认证方式',
dataIndex: 'authName',
},
{
title: 'Password',
dataIndex: 'token',
width: 300,
render(_: string, record: UsersProps) {
return <PasswordContent clusterId={clusterId} name={record.name} />;
},
},
{
title: '操作',
dataIndex: '',
width: 240,
render(record: UsersProps) {
return (
<>
<Button
type="link"
size="small"
style={{ paddingLeft: 0 }}
onClick={() => editDrawerRef.current.onOpen(true, UsersOperate.ChangePassword, getKafkaUserList, record)}
>
</Button>
<Button type="link" size="small" onClick={() => onDelete(record)}>
</Button>
</>
);
},
},
];
return baseColumns;
};
const onDelete = (record: UsersProps) => {
confirm({
title: '确定删除该 KafkaUser 吗?',
okText: '删除',
okType: 'primary',
centered: true,
okButtonProps: {
size: 'small',
danger: true,
},
cancelButtonProps: {
size: 'small',
},
maskClosable: false,
onOk() {
return Utils.request(api.kafkaUser, {
method: 'DELETE',
data: {
clusterId,
kafkaUser: record.name,
},
}).then((_) => {
message.success('删除成功');
getKafkaUserList();
});
},
});
};
const onTableChange = (curPagination: any) => {
getKafkaUserList({ pageNo: curPagination.current, pageSize: curPagination.pageSize });
};
useEffect(() => {
// 获取配置列表
getKafkaUserList();
}, []);
return (
<div className="security-users-page">
<DKSBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Users', aHref: `` },
]}
/>
<div className="security-users-page-list">
<div className="operate-bar">
<Form form={form} layout="inline" onFinish={() => getKafkaUserList({ pageNo: 1 })}>
<Form.Item name="searchKeywords">
<Input placeholder="请输入 Kafka User" />
</Form.Item>
<Form.Item>
<Button type="primary" ghost htmlType="submit">
</Button>
</Form.Item>
</Form>
<Button
type="primary"
// icon={<PlusOutlined />}
onClick={() => editDrawerRef.current.onOpen(true, UsersOperate.Add, getKafkaUserList)}
>
KafkaUser
</Button>
</div>
<ProTable
tableProps={{
showHeader: false,
loading,
rowKey: 'name',
dataSource: data,
paginationProps: pagination,
columns: columns() as any,
lineFillColor: true,
attrs: {
onChange: onTableChange,
scroll: { y: 'calc(100vh - 270px)' },
},
}}
/>
</div>
<EditKafkaUser ref={editDrawerRef} />
</div>
);
};
export default SecurityUsers;

View File

@@ -0,0 +1,151 @@
import { Collapse, Divider, Spin, Utils } from 'knowdesign';
import * as React from 'react';
import API from '../../api';
import { Link, useParams } from 'react-router-dom';
import InfiniteScroll from 'react-infinite-scroll-component';
import moment from 'moment';
import { timeFormat } from '../../constants/common';
import { DownOutlined } from '@ant-design/icons';
import { renderToolTipValue } from './config';
const { Panel } = Collapse;
interface ILog {
clusterPhyId: number;
createTime: number;
operateTime: string;
resName: string;
resTypeCode: number;
resTypeName: string;
updateTime: number;
}
const ChangeLog = () => {
const { clusterId } = useParams<{ clusterId: string }>();
const [data, setData] = React.useState<ILog[]>([]);
const [loading, setLoading] = React.useState<boolean>(true);
const [pagination, setPagination] = React.useState({
pageNo: 0,
pageSize: 10,
total: 0,
});
const getChangeLog = () => {
const promise = Utils.request(API.getClusterChangeLog(+clusterId), {
params: {
pageNo: pagination.pageNo + 1,
pageSize: 10,
},
});
promise.then((res: any) => {
setData((cur) => cur.concat(res?.bizData));
setPagination(res?.pagination);
});
return promise;
};
React.useEffect(() => {
getChangeLog().then(
() => setLoading(false),
() => setLoading(false)
);
}, []);
const renderEmpty = () => {
return (
<>
<div className="empty-panel">
<div className="img" />
<div className="text"></div>
</div>
</>
);
};
const getHref = (item: any) => {
if (item.resTypeName.toLowerCase().includes('topic')) return `/cluster/${clusterId}/topic/list#topicName=${item.resName}`;
if (item.resTypeName.toLowerCase().includes('broker')) return `/cluster/${clusterId}/broker/list#brokerId=${item.resName}`;
return '';
};
return (
<>
<div className="change-log-panel">
<div className="title"></div>
{!loading && !data.length ? (
renderEmpty()
) : (
<div id="changelog-scroll-box">
<Spin spinning={loading} style={{ paddingLeft: '42%', marginTop: 100 }} />
<InfiniteScroll
dataLength={data.length}
next={getChangeLog as any}
hasMore={data.length < pagination.total}
loader={<Spin style={{ paddingLeft: '42%', paddingTop: 10 }} spinning={true} />}
endMessage={
!pagination.total ? (
''
) : (
<Divider className="load-completed-tip" plain>
{pagination.total}
</Divider>
)
}
scrollableTarget="changelog-scroll-box"
>
<Collapse defaultActiveKey={['log-0']} accordion>
{data.map((item, index) => {
return (
<Panel
header={
<>
<div className="header">
<div className="label">{renderToolTipValue(`[${item.resTypeName}] ${item.resName}`, 24)}</div>
<span className="icon">
<DownOutlined />
</span>
</div>
<div className="header-time">{moment(item.updateTime).format(timeFormat)}</div>
</>
}
key={`log-${index}`}
showArrow={false}
>
<div className="log-item">
<span></span>
<div className="value">
{getHref(item) ? (
<Link to={getHref(item)}>{renderToolTipValue(item.resName, 18)}</Link>
) : (
renderToolTipValue(item.resName, 18)
)}
</div>
</div>
<Divider />
<div className="log-item">
<span></span>
<span className="value">{moment(item.updateTime).format(timeFormat)}</span>
</div>
<Divider />
<div className="log-item">
<span></span>
<span className="value">{'修改配置'}</span>
</div>
<Divider />
<div className="log-item">
<span></span>
<span className="value">{item.resTypeName}</span>
</div>
</Panel>
);
})}
</Collapse>
</InfiniteScroll>
</div>
)}
</div>
</>
);
};
export default ChangeLog;

View File

@@ -0,0 +1,59 @@
/* eslint-disable react/display-name */
import { Drawer, Form, Spin, Table, Utils } from 'knowdesign';
import React, { useEffect, useState, forwardRef, useImperativeHandle } from 'react';
import { useIntl } from 'react-intl';
import { getDetailColumn } from './config';
import API from '../../api';
import { useParams } from 'react-router-dom';
const CheckDetail = forwardRef((props: any, ref): JSX.Element => {
const intl = useIntl();
const [form] = Form.useForm();
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const { clusterId } = useParams<{ clusterId: string }>();
useImperativeHandle(ref, () => ({
setVisible,
getHealthDetail,
}));
const getHealthDetail = () => {
setLoading(true);
return Utils.request(API.getResourceListHealthDetail(+clusterId)).then((res: any) => {
setData(res);
setLoading(false);
});
};
const onCancel = () => {
form.resetFields();
setVisible(false);
};
useEffect(() => {
if (visible) {
getHealthDetail();
}
}, [visible]);
return (
<Drawer
className="drawer-content"
onClose={onCancel}
maskClosable={false}
title="Cluster健康状态详情"
// title={intl.formatMessage({ id: 'check.detail' })}
visible={visible}
placement="right"
width={1080}
>
<Spin spinning={loading}>
<Table dataSource={data} columns={getDetailColumn(+clusterId)} pagination={false} />
</Spin>
</Drawer>
);
});
export default CheckDetail;

View File

@@ -0,0 +1,98 @@
import { getBasicChartConfig } from '@src/constants/chartConfig';
import moment from 'moment';
const DEFAULT_METRIC = 'MessagesIn';
// 图表 tooltip 展示的样式
const messagesInTooltipFormatter = (date: any, arr: any) => {
// 面积图只有一条线,这里直接取 arr 的第 0 项
const params = arr[0];
// MessageIn 的指标数据存放在 data 数组第 3 项
const metricsData = params.data[2];
const str = `<div style="margin: 3px 0;">
<div style="display:flex;align-items:center;">
<div style="margin-right:4px;width:8px;height:2px;background-color:${params.color};"></div>
<div style="flex:1;display:flex;justify-content:space-between;align-items:center;overflow: hidden;">
<span style="flex: 1;font-size:12px;color:#74788D;pointer-events:auto;margin-left:2px;line-height: 18px;font-family: HelveticaNeue;overflow: hidden; text-overflow: ellipsis; white-space: no-wrap;">
${params.seriesName}
</span>
<span style="font-size:12px;color:#212529;line-height:18px;font-family:HelveticaNeue-Medium;margin-left: 10px;">
${parseFloat(Number(params.value[1]).toFixed(3))}
<span style="font-family: PingFangSC-Regular;color: #495057;">${metricsData[DEFAULT_METRIC]?.unit || ''}</span>
</span>
</div>
</div>
</div>`;
return `<div style="margin: 0px 0 0; position: relative; z-index: 99;width: fit-content;">
<div style="padding: 8px 0;height: 100%;">
<div style="font-size:12px;padding: 0 12px;color:#212529;line-height:20px;font-family: HelveticaNeue;">
${date}
</div>
<div style="margin: 4px 0 0 0;padding: 0 12px;">
${str}
<div style="width: 100%; height: 1px; background: #EFF2F7;margin: 8px 0;"></div>
${metricsData
.map(({ key, value, unit }: { key: string; value: number; unit: string }) => {
if (key === DEFAULT_METRIC) return '';
return `
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size:12px;color:#74788D;pointer-events:auto;margin-left:2px;line-height: 18px;font-family: HelveticaNeue;margin-right: 10px;">
${key}
</span>
<span style="font-size:12px;color:#212529;pointer-events:auto;margin-left:2px;line-height: 18px;font-family: HelveticaNeue;">
${parseFloat(Number(value).toFixed(3))}
<span style="font-family: PingFangSC-Regular;color: #495057;">${unit}</span>
</span>
</div>
`;
})
.join('')}
</div>
</div>
</div>`;
};
export const getChartConfig = (props: any) => {
const { metricName, lineColor, isDefaultMetric = false } = props;
return {
option: getBasicChartConfig({
// TODO: time 轴图表联动有问题,先切换为 category
// xAxis: { type: 'time', boundaryGap: isDefaultMetric ? ['2%', '2%'] : ['5%', '5%'] },
title: { show: false },
legend: { show: false },
grid: { top: 24, bottom: 12 },
lineColor: [lineColor],
tooltip: isDefaultMetric
? {
formatter: function (params: any) {
let res = '';
if (params != null && params.length > 0) {
res += messagesInTooltipFormatter(moment(Number(params[0].axisValue)).format('YYYY-MM-DD HH:mm'), params);
}
return res;
},
}
: {},
}),
seriesCallback: (lineList: { name: string; data: [number, string | number][] }[]) => {
// 补充线条配置
return lineList.map((line) => {
return {
...line,
lineStyle: {
width: 1,
},
symbol: 'emptyCircle',
symbolSize: 4,
// 面积图样式
areaStyle: {
color: lineColor,
opacity: 0.06,
},
};
});
},
};
};

View File

@@ -0,0 +1,135 @@
.cluster-container-border {
background: #ffffff;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
border-radius: 12px;
}
.cluster-detail-container {
width: 100%;
&-header {
display: flex;
align-items: center;
height: 36px;
.refresh-icon-box {
display: flex;
justify-content: center;
align-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
cursor: pointer;
.refresh-icon {
font-size: 14px;
color: #74788d;
}
&:hover {
background: #21252904;
.refresh-icon {
color: #495057;
}
}
}
}
&-main {
.header-chart-container {
&-loading {
display: flex;
justify-content: center;
align-items: center;
}
width: 100%;
height: 244px;
margin-bottom: 12px;
.cluster-container-border();
}
.content {
display: flex;
width: 100%;
height: calc(100vh - 404px);
min-height: 526px;
.multiple-chart-container {
flex: 1;
height: 100%;
overflow: hidden;
.cluster-container-border();
> div {
width: 100%;
height: 100%;
padding: 16px;
overflow: hidden auto;
}
&-loading {
display: flex;
justify-content: center;
align-items: center;
}
.chart-box {
position: relative;
width: 100%;
height: 244px;
background: #f8f9fa;
border-radius: 8px;
.expand-icon-box {
position: absolute;
z-index: 1000;
top: 14px;
right: 16px;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 16px;
text-align: center;
border-radius: 50%;
transition: background-color 0.3s ease;
.expand-icon {
color: #adb5bc;
line-height: 24px;
}
&:hover {
background: rgba(33, 37, 41, 0.06);
.expand-icon {
color: #74788d;
}
}
}
}
}
.config-change-records-container {
width: 240px;
height: 100%;
margin-left: 12px;
.cluster-container-border();
}
}
}
.chart-box-title {
padding: 18px 0 0 20px;
font-family: @font-family-bold;
line-height: 16px;
.name {
font-size: 14px;
color: #212529;
}
.unit {
font-size: 12px;
color: #495057;
}
> span {
cursor: pointer;
}
}
}

View File

@@ -0,0 +1,452 @@
import { Col, Row, SingleChart, IconFont, Utils, Modal, Spin, Empty, AppContainer, Tooltip } from 'knowdesign';
import React, { useEffect, useRef, useState } from 'react';
import api from '@src/api';
import { getChartConfig } from './config';
import './index.less';
import { useParams } from 'react-router-dom';
import {
MetricDefaultChartDataType,
MetricChartDataType,
formatChartData,
supplementaryPoints,
} from '@src/components/DashboardDragChart/config';
import { MetricType } from '@src/api';
import { getDataNumberUnit, getUnit } from '@src/constants/chartConfig';
import SingleChartHeader, { KsHeaderOptions } from '@src/components/SingleChartHeader';
import { MAX_TIME_RANGE_WITH_SMALL_POINT_INTERVAL } from '@src/constants/common';
type ChartFilterOptions = Omit<KsHeaderOptions, 'gridNum'>;
interface MetricInfo {
type: number;
name: string;
desc: string;
set: boolean;
support: boolean;
}
interface MessagesInDefaultData {
aggType: string | null;
createTime: string | null;
updateTime: string | null;
timeStamp: number;
values: {
[metric: string]: string;
};
}
type MessagesInMetric = {
name: 'MessagesIn';
unit: string;
data: (readonly [number, number | string, { key: string; value: number; unit: string }[]])[];
};
const { EventBus } = Utils;
const busInstance = new EventBus();
// 图表颜色定义 & 计算
const CHART_LINE_COLORS = ['#556EE6', '#3991FF'];
const calculateChartColor = (i: number) => {
const isEvenRow = ((i / 2) | 0) % 2;
const isEvenCol = i % 2;
return CHART_LINE_COLORS[isEvenRow ^ isEvenCol];
};
const DEFAULT_METRIC = 'MessagesIn';
// 默认指标图表固定需要获取展示的指标项
const DEFUALT_METRIC_NEED_METRICS = [DEFAULT_METRIC, 'TotalLogSize', 'TotalProduceRequests', 'Topics', 'Partitions'];
const DetailChart = (props: { children: JSX.Element }): JSX.Element => {
const [global] = AppContainer.useGlobalValue();
const { clusterId } = useParams<{ clusterId: string }>();
const [metricList, setMetricList] = useState<MetricInfo[]>([]); // 指标列表
const [selectedMetricNames, setSelectedMetricNames] = useState<(string | number)[]>([]); // 默认选中的指标的列表
const [metricDataList, setMetricDataList] = useState<any>([]);
const [messagesInMetricData, setMessagesInMetricData] = useState<MessagesInMetric>({
name: 'MessagesIn',
unit: '',
data: [],
});
const [curHeaderOptions, setCurHeaderOptions] = useState<ChartFilterOptions>();
const [defaultChartLoading, setDefaultChartLoading] = useState<boolean>(true);
const [chartLoading, setChartLoading] = useState<boolean>(true);
const [showChartDetailModal, setShowChartDetailModal] = useState<boolean>(false);
const [chartDetail, setChartDetail] = useState<any>();
const curFetchingTimestamp = useRef({
messagesIn: 0,
other: 0,
});
// 筛选项变化或者点击刷新按钮
const ksHeaderChange = (ksOptions: KsHeaderOptions) => {
// 如果为相对时间,则当前时间减去 1 分钟,避免最近一分钟的数据还没采集到时前端多补一个点
if (ksOptions.isRelativeRangeTime) {
ksOptions.rangeTime = ksOptions.rangeTime.map((timestamp) => timestamp - 60 * 1000) as [number, number];
}
setCurHeaderOptions({
isRelativeRangeTime: ksOptions.isRelativeRangeTime,
isAutoReload: ksOptions.isAutoReload,
rangeTime: ksOptions.rangeTime,
});
};
// 获取指标列表
const getMetricList = () => {
Utils.request(api.getDashboardMetricList(clusterId, MetricType.Cluster)).then((res: MetricInfo[] | null) => {
if (!res) return;
const showMetrics = res.filter((metric) => metric.support);
const selectedMetrics = showMetrics.filter((metric) => metric.set).map((metric) => metric.name);
!selectedMetrics.includes(DEFAULT_METRIC) && selectedMetrics.push(DEFAULT_METRIC);
setMetricList(showMetrics);
setSelectedMetricNames(selectedMetrics);
});
};
// 更新指标
const updateMetricList = (metricsSet: { [name: string]: boolean }) => {
return Utils.request(api.getDashboardMetricList(clusterId, MetricType.Cluster), {
method: 'POST',
data: {
metricsSet,
},
});
};
// 指标选中项更新回调
const indicatorChangeCallback = (newMetricNames: (string | number)[]) => {
const updateMetrics: { [name: string]: boolean } = {};
// 需要选中的指标
newMetricNames.forEach((name) => !selectedMetricNames.includes(name) && (updateMetrics[name] = true));
// 取消选中的指标
selectedMetricNames.forEach((name) => !newMetricNames.includes(name) && (updateMetrics[name] = false));
const requestPromise = Object.keys(updateMetrics).length ? updateMetricList(updateMetrics) : Promise.resolve();
requestPromise.then(
() => getMetricList(),
() => getMetricList()
);
return requestPromise;
};
// 获取 metric 列表的图表数据
const getMetricData = () => {
if (!selectedMetricNames.length) return;
!curHeaderOptions.isAutoReload && setChartLoading(true);
const [startTime, endTime] = curHeaderOptions.rangeTime;
const curTimestamp = Date.now();
curFetchingTimestamp.current = {
...curFetchingTimestamp.current,
messagesIn: curTimestamp,
};
Utils.request(api.getClusterMetricDataList(), {
method: 'POST',
data: {
startTime,
endTime,
clusterPhyIds: [clusterId],
metricsNames: selectedMetricNames.filter((name) => name !== DEFAULT_METRIC),
},
}).then(
(res: MetricDefaultChartDataType[]) => {
// 如果当前请求不是最新请求,则不做任何操作
if (curFetchingTimestamp.current.messagesIn !== curTimestamp) {
return;
}
const supplementaryInterval = (endTime - startTime > MAX_TIME_RANGE_WITH_SMALL_POINT_INTERVAL ? 10 : 1) * 60 * 1000;
const formattedMetricData: MetricChartDataType[] = formatChartData(
res,
global.getMetricDefine || {},
MetricType.Cluster,
curHeaderOptions.rangeTime,
supplementaryInterval
);
formattedMetricData.forEach((data) => (data.metricLines[0].name = data.metricName));
setMetricDataList(formattedMetricData);
setChartLoading(false);
},
() => setChartLoading(false)
);
};
// 获取默认展示指标的图表数据
const getDefaultMetricData = () => {
!curHeaderOptions.isAutoReload && setDefaultChartLoading(true);
const curTimestamp = Date.now();
curFetchingTimestamp.current = {
...curFetchingTimestamp.current,
other: curTimestamp,
};
Utils.request(api.getClusterDefaultMetricData(), {
method: 'POST',
data: {
startTime: curHeaderOptions.rangeTime[0],
endTime: curHeaderOptions.rangeTime[1],
clusterPhyIds: [clusterId],
metricsNames: DEFUALT_METRIC_NEED_METRICS,
},
}).then(
(res: MessagesInDefaultData[]) => {
// 如果当前请求不是最新请求,则不做任何操作
if (curFetchingTimestamp.current.other !== curTimestamp) {
return;
}
// TODO: 这里直接将指标数据放到数组第三项中,之后可以尝试优化,优化需要注意 tooltipFormatter 函数也要修改
let maxValue = -1;
const result = res.map((item) => {
const { timeStamp, values } = item;
let parsedValue: string | number = Number(values.MessagesIn);
if (Number.isNaN(parsedValue)) {
parsedValue = values.MessagesIn;
} else {
if (maxValue < parsedValue) maxValue = parsedValue;
}
const valuesWithUnit = Object.entries(values).map(([key, value]) => {
let valueWithUnit = Number(value);
let unit = ((global.getMetricDefine && global.getMetricDefine(MetricType.Cluster, key)?.unit) || '') as string;
if (unit.toLowerCase().includes('byte')) {
const [unitName, unitSize]: [string, number] = getUnit(Number(value));
unit = unit.toLowerCase().replace('byte', unitName);
valueWithUnit /= unitSize;
}
const returnValue = {
key,
value: valueWithUnit,
unit,
};
return returnValue;
});
return [timeStamp, values.MessagesIn || '0', valuesWithUnit] as [number, number | string, typeof valuesWithUnit];
});
result.sort((a, b) => (a[0] as number) - (b[0] as number));
const line = {
name: 'MessagesIn' as const,
unit: global.getMetricDefine(MetricType.Cluster, 'MessagesIn')?.unit,
data: result as any,
};
if (maxValue > 0) {
const [unitName, unitSize]: [string, number] = getDataNumberUnit(maxValue);
line.unit = `${unitName}${line.unit}`;
result.forEach((point) => ((point[1] as number) /= unitSize));
}
// 补充缺少的图表点
const extraMetrics = result[0][2].map((info) => ({
...info,
value: 0,
}));
const supplementaryInterval =
(curHeaderOptions.rangeTime[1] - curHeaderOptions.rangeTime[0] > MAX_TIME_RANGE_WITH_SMALL_POINT_INTERVAL ? 10 : 1) * 60 * 1000;
supplementaryPoints([line], curHeaderOptions.rangeTime, supplementaryInterval, (point) => {
point.push(extraMetrics as any);
return point;
});
setMessagesInMetricData(line);
setDefaultChartLoading(false);
},
() => setDefaultChartLoading(false)
);
};
// 监听盒子宽度变化,重置图表宽度
const observeDashboardWidthChange = () => {
const targetNode = document.getElementsByClassName('dcd-two-columns-layout-sider-footer')[0];
targetNode && targetNode.addEventListener('click', () => busInstance.emit('chartResize'));
};
useEffect(() => {
getMetricData();
}, [selectedMetricNames]);
useEffect(() => {
if (curHeaderOptions && curHeaderOptions?.rangeTime.join(',') !== '0,0') {
getDefaultMetricData();
getMetricData();
}
}, [curHeaderOptions]);
useEffect(() => {
getMetricList();
setTimeout(() => observeDashboardWidthChange());
}, []);
return (
<div className="cluster-detail-container">
<SingleChartHeader
onChange={ksHeaderChange}
hideNodeScope={true}
hideGridSelect={true}
indicatorSelectModule={{
hide: false,
metricType: MetricType.Cluster,
tableData: metricList,
selectedRows: selectedMetricNames,
checkboxProps: (record: MetricInfo) => {
return record.name === DEFAULT_METRIC
? {
disabled: true,
}
: {};
},
submitCallback: indicatorChangeCallback,
}}
/>
<div className="cluster-detail-container-main">
{/* MessageIn 图表 */}
<div className={`header-chart-container ${!messagesInMetricData.data.length ? 'header-chart-container-loading' : ''}`}>
<Spin spinning={defaultChartLoading}>
{/* TODO: 暂时通过判断是否有图表数据来修复,有时间可以查找下宽度溢出的原因 */}
{messagesInMetricData.data.length ? (
<>
<div className="chart-box-title">
<Tooltip
placement="topLeft"
title={() => {
let content = '';
const metricDefine = global.getMetricDefine(MetricType.Cluster, messagesInMetricData.name);
if (metricDefine) {
content = metricDefine.desc;
}
return content;
}}
>
<span>
<span className="name">{messagesInMetricData.name}</span>
<span className="unit">{messagesInMetricData.unit}</span>
</span>
</Tooltip>
</div>
<SingleChart
chartKey="messagesIn"
chartTypeProp="line"
showHeader={false}
wrapStyle={{
width: 'auto',
height: 210,
}}
connectEventName="clusterChart"
eventBus={busInstance}
propChartData={[messagesInMetricData]}
{...getChartConfig({
// metricName: `${messagesInMetricData.name}{unit|${messagesInMetricData.unit}}`,
lineColor: CHART_LINE_COLORS[0],
isDefaultMetric: true,
})}
/>
</>
) : (
''
)}
</Spin>
</div>
<div className="content">
{/* 其余指标图表 */}
<div className="multiple-chart-container">
<div className={!metricDataList.length ? 'multiple-chart-container-loading' : ''}>
<Spin spinning={chartLoading}>
<Row gutter={[16, 16]}>
{metricDataList.length ? (
metricDataList.map((data: any, i: number) => {
const { metricName, metricUnit, metricLines } = data;
return (
<Col key={metricName} span={12}>
<div className="chart-box">
<div className="chart-box-title">
<Tooltip
placement="topLeft"
title={() => {
let content = '';
const metricDefine = global.getMetricDefine(MetricType.Cluster, metricName);
if (metricDefine) {
content = metricDefine.desc;
}
return content;
}}
>
<span>
<span className="name">{metricName}</span>
<span className="unit">{metricUnit}</span>
</span>
</Tooltip>
</div>
<div
className="expand-icon-box"
onClick={() => {
setChartDetail(data);
setShowChartDetailModal(true);
}}
>
<IconFont type="icon-chuangkoufangda" className="expand-icon" />
</div>
<SingleChart
chartKey={metricName}
showHeader={false}
chartTypeProp="line"
wrapStyle={{
width: 'auto',
height: 210,
}}
connectEventName="clusterChart"
eventBus={busInstance}
propChartData={metricLines}
{...getChartConfig({
metricName: `${metricName}{unit|${metricUnit}}`,
lineColor: calculateChartColor(i),
})}
/>
</div>
</Col>
);
})
) : chartLoading ? (
<></>
) : (
<Empty description="请先选择指标或刷新" style={{ width: '100%', height: '100%' }} />
)}
</Row>
</Spin>
</div>
</div>
{/* 历史配置变更记录内容 */}
<div className="config-change-records-container">{props.children}</div>
</div>
</div>
{/* 图表详情 */}
<Modal
width={1080}
visible={showChartDetailModal}
centered={true}
footer={null}
closable={false}
onCancel={() => setShowChartDetailModal(false)}
>
<div className="chart-detail-modal-container">
<div className="expand-icon-box" onClick={() => setShowChartDetailModal(false)}>
<IconFont type="icon-chuangkousuoxiao" className="expand-icon" />
</div>
{chartDetail && (
<SingleChart
chartTypeProp="line"
wrapStyle={{
width: 'auto',
height: 462,
}}
propChartData={chartDetail.metricLines}
{...getChartConfig({
metricName: `${chartDetail.metricName}{unit|${chartDetail.metricUnit}}`,
})}
/>
)}
</div>
</Modal>
</div>
);
};
export default DetailChart;

View File

@@ -0,0 +1,135 @@
/* eslint-disable react/display-name */
import { Button, Divider, Drawer, Form, message, ProTable, Table, Utils } from 'knowdesign';
import React, { useState } from 'react';
import { useIntl } from 'react-intl';
import { getHealthySettingColumn } from './config';
import API from '../../api';
import { useParams } from 'react-router-dom';
const HealthySetting = React.forwardRef((props: any, ref): JSX.Element => {
const intl = useIntl();
const [form] = Form.useForm();
const [visible, setVisible] = useState(false);
const [initialValues, setInitialValues] = useState({} as any);
const [data, setData] = React.useState([]);
const { clusterId } = useParams<{ clusterId: string }>();
React.useImperativeHandle(ref, () => ({
setVisible,
getHealthconfig,
}));
const getHealthconfig = () => {
return Utils.request(API.getClusterHealthyConfigs(+clusterId)).then((res: any) => {
const values = {} as any;
try {
res = res.map((item: any) => {
const itemValue = JSON.parse(item.value);
item.weight = itemValue?.weight;
item.configItemName =
item.configItem.indexOf('Group Re-Balance') > -1
? 'ReBalance'
: item.configItem.includes('副本未同步')
? 'UNDER_REPLICA'
: item.configItem;
values[`weight_${item.configItemName}`] = itemValue?.weight;
values[`value_${item.configItemName}`] = itemValue?.value;
values[`latestMinutes_${item.configItemName}`] = itemValue?.latestMinutes;
values[`detectedTimes_${item.configItemName}`] = itemValue?.detectedTimes;
return item;
});
} catch (err) {
//
}
const formItemsValue = {
...initialValues,
...values,
};
setInitialValues(formItemsValue);
form.setFieldsValue(formItemsValue);
setData(res);
});
};
const onCancel = () => {
form.resetFields();
setVisible(false);
};
const onSubmit = () => {
form.validateFields().then((res) => {
const params = [] as any;
data.map((item) => {
params.push({
clusterId: +clusterId,
value: JSON.stringify({
clusterPhyId: +clusterId,
detectedTimes: res[`detectedTimes_${item.configItemName}`],
latestMinutes: res[`latestMinutes_${item.configItemName}`],
weight: res[`weight_${item.configItemName}`],
value: item.configItemName === 'Controller' ? 1 : res[`value_${item.configItemName}`],
}),
valueGroup: item.configGroup,
valueName: item.configName,
});
});
Utils.put(API.putPlatformConfigs, params)
.then((res) => {
message.success('修改成功');
form.resetFields();
setVisible(false);
})
.catch((err) => {
message.error('操作失败' + err.message);
});
});
};
const onHandleValuesChange = (value: any, allValues: any) => {
//
};
return (
<>
<Drawer
className="drawer-content healthy-drawer-content"
onClose={onCancel}
maskClosable={false}
extra={
<div className="operate-wrap">
<Button size="small" style={{ marginRight: 8 }} onClick={onCancel}>
</Button>
<Button size="small" type="primary" onClick={onSubmit}>
</Button>
<Divider type="vertical" />
</div>
}
title={intl.formatMessage({ id: 'healthy.setting' })}
visible={visible}
placement="right"
width={1080}
>
<Form form={form} layout="vertical" onValuesChange={onHandleValuesChange}>
<ProTable
tableProps={{
rowKey: 'dimensionCode',
showHeader: false,
dataSource: data,
columns: getHealthySettingColumn(form, data, clusterId),
noPagination: true,
}}
/>
</Form>
</Drawer>
</>
);
});
export default HealthySetting;

View File

@@ -0,0 +1,253 @@
import { AppContainer, Divider, IconFont, Progress, Tooltip, Utils } from 'knowdesign';
import React, { useEffect, useState } from 'react';
import AccessClusters from '../MutliClusterPage/AccessCluster';
import './index.less';
import API from '../../api';
import HealthySetting from './HealthySetting';
import CheckDetail from './CheckDetail';
import { Link, useHistory, useParams } from 'react-router-dom';
import { getHealthClassName, getHealthProcessColor, getHealthState, getHealthText, renderToolTipValue } from './config';
import { ClustersPermissionMap } from '../CommonConfig';
const LeftSider = () => {
const [global] = AppContainer.useGlobalValue();
const history = useHistory();
const [kafkaVersion, setKafkaVersion] = useState({});
const [clusterInfo, setClusterInfo] = useState({} as any);
const [loading, setLoading] = React.useState(false);
const [clusterMetrics, setClusterMetrics] = useState({} as any);
const [brokerState, setBrokerState] = useState({} as any);
const detailDrawerRef: any = React.createRef();
const healthyDrawerRef: any = React.createRef();
const { clusterId } = useParams<{ clusterId: string }>();
const [visible, setVisible] = React.useState(false);
const getSupportKafkaVersion = () => {
Utils.request(API.supportKafkaVersion).then((res) => {
setKafkaVersion(res || {});
});
};
const getBrokerState = () => {
return Utils.request(API.getBrokersState(clusterId)).then((res) => {
setBrokerState(res);
});
};
const getPhyClusterMetrics = () => {
return Utils.post(
API.getPhyClusterMetrics(+clusterId),
[
'HealthScore',
'HealthCheckPassed',
'HealthCheckTotal',
'Topics',
'PartitionURP',
'PartitionNoLeader', // > 0 error
'PartitionMinISR_S', // > 0 error
'Groups',
'GroupDeads',
'Alive',
].concat(process.env.BUSINESS_VERSION ? ['LoadReBalanceEnable', 'LoadReBalanceNwIn', 'LoadReBalanceNwOut', 'LoadReBalanceDisk'] : [])
).then((res: any) => {
setClusterMetrics(res?.metrics || {});
});
};
const getPhyClusterInfo = () => {
setLoading(true);
Utils.request(API.getPhyClusterBasic(+clusterId))
.then((res: any) => {
let jmxProperties = null;
try {
jmxProperties = JSON.parse(res?.jmxProperties);
} catch (err) {
console.error(err);
}
// 转化值对应成表单值
if (jmxProperties?.openSSL) {
jmxProperties.security = 'Password';
}
if (jmxProperties) {
res = Object.assign({}, res || {}, jmxProperties);
}
setClusterInfo(res);
setLoading(false);
})
.catch((err) => {
setLoading(false);
});
};
useEffect(() => {
getBrokerState();
getPhyClusterMetrics();
getSupportKafkaVersion();
getPhyClusterInfo();
}, []);
const renderIcon = (type: string) => {
return (
<span className={`icon`}>
<IconFont type={type === 'green' ? 'icon-zhengchang' : type === 'warning' ? 'icon-yichang' : 'icon-warning'} />
</span>
);
};
return (
<>
<div className="left-sider">
<div className="state-card">
<Progress
type="circle"
status="active"
strokeWidth={4}
strokeColor={getHealthProcessColor(clusterMetrics?.HealthScore, clusterMetrics?.Alive)}
percent={clusterMetrics?.HealthScore ?? '-'}
format={() => (
<div className={`healthy-percent ${getHealthClassName(clusterMetrics?.HealthScore, clusterMetrics?.Alive)}`}>
{getHealthText(clusterMetrics?.HealthScore, clusterMetrics?.Alive)}
</div>
)}
width={75}
/>
<div className="healthy-state">
<div className="healthy-state-status">
<span>{getHealthState(clusterMetrics?.HealthScore, clusterMetrics?.Alive)}</span>
{/* 健康分设置 */}
{global.hasPermission && global.hasPermission(ClustersPermissionMap.CLUSTER_CHANGE_HEALTHY) ? (
<span
className="icon"
onClick={() => {
healthyDrawerRef.current.getHealthconfig().then(() => {
healthyDrawerRef.current.setVisible(true);
});
}}
>
<IconFont type="icon-shezhi" size={13} />
</span>
) : (
<></>
)}
</div>
<div>
<span className="healthy-state-num">
{clusterMetrics?.HealthCheckPassed}/{clusterMetrics?.HealthCheckTotal}
</span>
{/* 健康度详情 */}
<span
className="healthy-state-btn"
onClick={() => {
detailDrawerRef.current.setVisible(true);
}}
>
</span>
</div>
</div>
</div>
<Divider />
<div className="title">
<div className="name">{renderToolTipValue(clusterInfo?.name, 35)}</div>
{global.hasPermission && global.hasPermission(ClustersPermissionMap.CLUSTER_CHANGE_INFO) ? (
<div className="edit-icon-box" onClick={() => setVisible(true)}>
<IconFont className="edit-icon" type="icon-bianji2" />
</div>
) : (
<></>
)}
</div>
<div className="tag-panel">
<div className="tag default">{clusterInfo?.kafkaVersion ?? '-'}</div>
{clusterMetrics?.LoadReBalanceEnable !== undefined &&
[
['BytesIn', clusterMetrics?.LoadReBalanceEnable && clusterMetrics?.LoadReBalanceNwIn],
['BytesOut', clusterMetrics?.LoadReBalanceEnable && clusterMetrics?.LoadReBalanceNwOut],
['Disk', clusterMetrics?.LoadReBalanceEnable && clusterMetrics?.LoadReBalanceDisk],
].map(([name, isBalanced]) => {
return isBalanced ? (
<div className="tag balanced">{name} </div>
) : clusterMetrics?.LoadReBalanceEnable ? (
<div className="tag unbalanced">{name} </div>
) : (
<Tooltip
title={
<span>
{name}
<Link to={`/cluster/${clusterId}/cluster/balance`}></Link>
</span>
}
>
<div className="tag unbalanced">{name} </div>
</Tooltip>
);
})}
</div>
<div className="desc">{renderToolTipValue(clusterInfo?.description, 35)}</div>
<div className="card-panel">
<div className="card-item" onClick={() => history.push(`/cluster/${clusterId}/broker`)}>
<div className="title">Brokers总数</div>
<div className="count">
<span className="num">{brokerState?.brokerCount ?? '-'}</span>
<span className="unit"></span>
</div>
<div className="metric">
<span className="type">Controller</span>
{renderIcon(brokerState?.kafkaControllerAlive ? 'green' : 'red')}
</div>
<div className="metric">
<span className="type">Similar Config</span>
{renderIcon(brokerState?.configSimilar ? 'green' : 'warning')}
</div>
</div>
<div className="card-item" onClick={() => history.push(`/cluster/${clusterId}/topic`)}>
<div className="title">Topics总数</div>
<div className="count">
<span className="num">{clusterMetrics?.Topics ?? '-'}</span>
<span className="unit"></span>
</div>
<div className="metric">
<span className="type">No leader</span>
{renderIcon(clusterMetrics?.PartitionNoLeader === 0 ? 'green' : 'red')}
</div>
<div className="metric">
<span className="type">{'< Min ISR'}</span>
{renderIcon(clusterMetrics?.PartitionMinISR_S === 0 ? 'green' : 'red')}
</div>
<div className="metric">
<span className="type">URP</span>
{renderIcon(clusterMetrics?.PartitionURP === 0 ? 'green' : 'red')}
</div>
</div>
<div className="card-item" onClick={() => history.push(`/cluster/${clusterId}/consumers`)}>
<div className="title">ConsumerGroup总数</div>
<div className="count">
<span className="num">{clusterMetrics?.Groups ?? '-'}</span>
<span className="unit"></span>
</div>
<div className="metric">
<span className="type">Dead</span>
{renderIcon(clusterMetrics?.GroupDeads === 0 ? 'green' : 'red')}
</div>
</div>
</div>
</div>
<AccessClusters
visible={visible}
setVisible={setVisible}
title={'edit.cluster'}
infoLoading={loading}
afterSubmitSuccess={getPhyClusterInfo}
clusterInfo={clusterInfo}
kafkaVersion={Object.keys(kafkaVersion)}
/>
<HealthySetting ref={healthyDrawerRef} />
<CheckDetail ref={detailDrawerRef} />
</>
);
};
export default LeftSider;

View File

@@ -0,0 +1,334 @@
import moment from 'moment';
import React from 'react';
import { timeFormat } from '../../constants/common';
import TagsWithHide from '../../components/TagsWithHide/index';
import { Form, IconFont, InputNumber, Tooltip } from 'knowdesign';
import { Link } from 'react-router-dom';
import { systemKey } from '../../constants/menu';
const statusTxtEmojiMap = {
success: {
emoji: '👍',
txt: '优异',
},
normal: {
emoji: '😊',
txt: '正常',
},
exception: {
emoji: '👻',
txt: '异常',
},
};
export const dimensionMap = {
'-1': {
label: 'Unknown',
href: ``,
},
0: {
label: 'Cluster',
href: ``,
},
1: {
label: 'Broker',
href: `/broker`,
},
2: {
label: 'Topic',
href: `/topic`,
},
3: {
label: 'ConsumerGroup',
href: `/consumers`,
},
} as any;
export const getHealthState = (value: number, down: number) => {
if (value === undefined) return '-';
const progressStatus = +down <= 0 ? 'exception' : value >= 90 ? 'success' : 'normal';
return (
<span>
{statusTxtEmojiMap[progressStatus].emoji}&nbsp;{statusTxtEmojiMap[progressStatus].txt}
</span>
);
};
export const getHealthText = (value: number, down: number) => {
return +down <= 0 ? 'Down' : value ? value.toFixed(0) : '-';
};
export const getHealthProcessColor = (value: number, down: number) => {
return +down <= 0 ? '#FF7066' : +value < 90 ? '#556EE6' : '#00C0A2';
};
export const getHealthClassName = (value: number, down: number) => {
return +down <= 0 ? 'down' : value === undefined ? 'no-info' : +value < 90 ? 'less-90' : '';
};
export const renderToolTipValue = (value: string, num: number) => {
return (
<>
{value?.length > num ? (
<>
<Tooltip placement="topLeft" title={value}>
{value ?? '-'}
</Tooltip>
</>
) : (
value ?? '-'
)}
</>
);
};
export const getDetailColumn = (clusterId: number) => [
{
title: '检查模块',
dataIndex: 'dimension',
// eslint-disable-next-line react/display-name
render: (text: number) => {
if (text === 0 || text === -1) return dimensionMap[text]?.label;
return <Link to={`/${systemKey}/${clusterId}${dimensionMap[text]?.href}`}>{dimensionMap[text]?.label}</Link>;
},
},
{
title: '检查项',
dataIndex: 'checkConfig',
render(config: any, record: any) {
const valueGroup = JSON.parse(config.value);
if (record.configItem === 'Controller') {
return '集群 Controller 数等于 1';
} else if (record.configItem === 'RequestQueueSize') {
return `Broker-RequestQueueSize 小于 ${valueGroup.value}`;
} else if (record.configItem === 'NoLeader') {
return `Topic 无 Leader 数小于 ${valueGroup.value}`;
} else if (record.configItem === 'NetworkProcessorAvgIdlePercent') {
return `Broker-NetworkProcessorAvgIdlePercent 的 idle 大于 ${valueGroup.value}%`;
} else if (record.configItem === 'UnderReplicaTooLong') {
return `Topic 小于 ${parseFloat(((valueGroup.detectedTimes / valueGroup.latestMinutes) * 100).toFixed(2))}% 周期处于未同步状态`;
} else if (record.configItem === 'Group Re-Balance') {
return `Consumer Group 小于 ${parseFloat(
((valueGroup.detectedTimes / valueGroup.latestMinutes) * 100).toFixed(2)
)}% 周期处于 Re-balance 状态`;
}
return <></>;
},
},
{
title: '权重',
dataIndex: 'weightPercent',
width: 80,
render(value: number) {
return `${value}%`;
},
},
{
title: '得分',
dataIndex: 'score',
width: 60,
},
{
title: '检查时间',
width: 190,
dataIndex: 'updateTime',
render: (text: string) => {
return moment(text).format(timeFormat);
},
},
{
title: '检查结果',
dataIndex: 'passed',
width: 280,
// eslint-disable-next-line react/display-name
render: (passed: boolean, record: any) => {
if (passed) {
return (
<>
<IconFont type="icon-zhengchang" />
<span style={{ marginLeft: 4 }}></span>
</>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', width: '240px' }}>
<IconFont type="icon-yichang" />
<div style={{ marginLeft: 4, marginRight: 6, flexShrink: 0 }}></div>
<TagsWithHide list={record.notPassedResNameList || []} expandTagContent="更多" />
</div>
);
},
},
];
export const getHealthySettingColumn = (form: any, data: any, clusterId: string) =>
[
{
title: '检查模块',
dataIndex: 'dimensionCode',
// eslint-disable-next-line react/display-name
render: (text: number) => {
if (text === 0 || text === -1) return dimensionMap[text]?.label;
return <Link to={`/${systemKey}/${clusterId}${dimensionMap[text]?.href}`}>{dimensionMap[text]?.label}</Link>;
},
},
{
title: '检查项',
dataIndex: 'configItem',
width: 200,
needTooltip: true,
},
{
title: '检查项描述',
dataIndex: 'configDesc',
width: 240,
needToolTip: true,
},
{
title: '权重',
dataIndex: 'weight',
// width: 180,
// eslint-disable-next-line react/display-name
render: (text: number, record: any) => {
return (
<>
<Form.Item
name={`weight_${record.configItemName}`}
label=""
rules={[
{
required: true,
validator: async (rule: any, value: string) => {
const otherWeightCongigName: string[] = [];
let totalPercent = 0;
data.map((item: any) => {
if (item.configItemName !== record.configItemName) {
otherWeightCongigName.push(`weight_${item.configItemName}`);
totalPercent += form.getFieldValue(`weight_${item.configItemName}`) ?? 0;
}
});
if (!value) {
return Promise.reject('请输入权重');
}
if (+value < 0) {
return Promise.reject('最小为0');
}
if (+value + totalPercent !== 100) {
return Promise.reject('总和应为100%');
}
form.setFields(otherWeightCongigName.map((i) => ({ name: i, errors: [] })));
return Promise.resolve('');
},
},
]}
>
<InputNumber
size="small"
min={0}
max={100}
formatter={(value) => `${value}%`}
parser={(value: any) => value.replace('%', '')}
/>
</Form.Item>
</>
);
},
},
{
title: '检查规则',
// width: 350,
dataIndex: 'passed',
// eslint-disable-next-line react/display-name
render: (text: any, record: any) => {
const getFormItem = (params: { type?: string; percent?: boolean; attrs?: any; validator?: any }) => {
const { validator, percent, type = 'value', attrs = { min: 0 } } = params;
return (
<Form.Item
name={`${type}_${record.configItemName}`}
label=""
rules={
validator
? [
{
required: true,
validator: validator,
},
]
: [
{
required: true,
message: '请输入',
},
]
}
>
{percent ? (
<InputNumber
size="small"
min={0}
max={100}
style={{ width: 86 }}
formatter={(value) => `${value}%`}
parser={(value: any) => value.replace('%', '')}
/>
) : (
<InputNumber style={{ width: 86 }} size="small" {...attrs} />
)}
</Form.Item>
);
};
if (record.configItemName === 'Controller') {
return <div className="table-form-item"> 1 </div>;
}
if (record.configItemName === 'RequestQueueSize' || record.configItemName === 'NoLeader') {
return (
<div className="table-form-item">
<span className="left-text"></span>
{getFormItem({ attrs: { min: 0, max: 99998 } })}
<span className="right-text"></span>
</div>
);
}
if (record.configItemName === 'NetworkProcessorAvgIdlePercent') {
return (
<div className="table-form-item">
<span className="left-text"></span>
{getFormItem({ percent: true })}
<span className="right-text"></span>
</div>
);
}
if (record.configItemName === 'UnderReplicaTooLong' || record.configItemName === 'ReBalance') {
return (
<div className="table-form-item">
{getFormItem({ type: 'latestMinutes', attrs: { min: 1, max: 10080 } })}
<span className="right-text left-text"></span>
{getFormItem({
type: 'detectedTimes',
attrs: { min: 1, max: 10080 },
validator: async (rule: any, value: string) => {
const latestMinutesValue = form.getFieldValue(`latestMinutes_${record.configItemName}`);
if (!value) {
return Promise.reject('请输入');
}
if (+value < 1) {
return Promise.reject('最小为1');
}
if (+value > +latestMinutesValue) {
return Promise.reject('值不能大于周期');
}
return Promise.resolve('');
},
})}
<span className="right-text"></span>
</div>
);
}
return <></>;
},
},
] as any;

View File

@@ -0,0 +1,413 @@
.single-cluster-detail {
width: 100%;
padding-bottom: 10px;
.cluster-detail {
display: flex;
width: 100%;
margin-top: 12px;
.left-sider {
width: 240px;
background: #fff;
// height: calc(100vh - 128px);
background: #fff;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
border-radius: 12px;
padding: 24px 16px;
.title {
font-family: @font-family-bold;
font-size: 18px;
color: #212529;
letter-spacing: 0;
text-align: justify;
line-height: 20px;
display: flex;
.name {
max-width: 188px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
.edit-icon-box {
position: relative;
width: 20px;
cursor: pointer;
.edit-icon {
position: absolute;
bottom: 2px;
margin-left: 4px;
font-size: 16px;
color: #74788d;
}
}
}
.tag-panel {
display: flex;
flex-flow: row wrap;
font-size: 10px;
letter-spacing: 0;
line-height: 12px;
margin-top: 10px;
.tag {
height: 18px;
margin-right: 4px;
margin-bottom: 4px;
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
line-height: 16px;
&.default {
background: #ececf6;
color: #495057;
}
&.balanced {
background: rgba(85, 110, 230, 0.1);
color: #556ee6;
}
&.unbalanced {
background: rgba(255, 136, 0, 0.1);
color: #f58342;
}
}
}
.desc {
font-size: 11px;
color: #74788d;
text-align: left;
line-height: 16px;
margin: 12px 0px 16px;
max-width: 208px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
.card-panel {
.card-item {
width: 208px;
background: #f8f9fe;
border-radius: 8px;
padding: 16px;
margin-bottom: 8px;
cursor: pointer;
.title {
font-size: 14px;
color: #212529;
letter-spacing: 0;
text-align: justify;
line-height: 22px;
margin-bottom: 4px;
}
.count {
margin-bottom: 12px;
.num {
font-family: DIDIFD-Regular;
font-size: 40px;
color: #212529;
line-height: 36px;
}
.unit {
font-size: 16px;
color: #212529;
line-height: 20px;
}
}
.metric {
font-size: 11px;
color: #74788d;
letter-spacing: 0;
text-align: justify;
line-height: 16px;
display: flex;
justify-content: space-between;
margin-top: 4px;
.icon {
width: 12px;
height: 12px;
&.green {
color: #34c38f;
}
&.warning {
color: #ffe4c6;
}
}
}
}
}
.state-card {
display: flex;
width: 100%;
.healthy-percent {
font-family: DIDIFD-Regular;
font-size: 38px;
color: #00c0a2;
text-align: center;
&.less-90 {
color: #556ee6;
}
&.no-info {
color: #e9e7e7;
}
&.down {
font-family: @font-family-bold;
font-size: 22px;
color: #ff7066;
text-align: center;
line-height: 30px;
}
}
}
.healthy-state {
margin-left: 14px;
margin-top: 8px;
&-status {
font-size: 13px;
color: #495057;
letter-spacing: 0;
line-height: 20px;
margin-bottom: 13px;
.icon {
margin-left: 4px;
font-size: 14px;
cursor: pointer;
}
}
&-btn {
font-size: 10px;
color: #495057;
line-height: 15px;
background: #ececf6;
border-radius: 4px;
padding: 0px 6px;
margin-left: 8px;
cursor: pointer;
}
}
.dcloud-divider-horizontal {
margin: 16px 4px;
padding: 0px 20px;
}
}
.chart-panel {
flex: 1;
margin-left: 12px;
margin-right: 10px;
}
.change-log-panel {
height: 100%;
padding: 12px 16px;
.title {
font-family: @font-family-bold;
font-size: 14px;
color: #212529;
letter-spacing: 0.5px;
line-height: 22px;
margin-bottom: 12px;
}
#changelog-scroll-box {
height: calc(100% - 34px);
overflow: hidden auto;
}
.dcloud-collapse {
border: none;
background-color: #fff;
.dcloud-collapse-header {
background: #fff;
height: 40px;
display: block;
padding: 0px 0px 8px !important;
.header {
justify-content: space-between;
width: 100%;
height: 20px;
display: flex;
.label {
font-size: 13px;
color: #212529;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
width: 9px;
height: 5px;
color: #74788d;
.anticon {
font-size: 9px;
}
}
}
.header-time {
height: 20px;
font-size: 10px;
color: #74788d;
text-align: left;
line-height: 20px;
}
}
.dcloud-collapse-header:hover {
.label {
color: #556ee6;
font-family: @font-family-bold;
font-size: 13px;
line-height: 20px;
}
}
.dcloud-collapse-content {
border-top: none;
}
.dcloud-collapse-item {
border: none;
margin-bottom: 8px;
}
.dcloud-collapse-item-active {
.dcloud-collapse-header {
.header {
.anticon {
transform: rotate(180deg);
}
}
}
}
}
.dcloud-collapse-content > .dcloud-collapse-content-box {
padding: 7px 11px;
background: #f8f9fa;
border-radius: 8px;
.log-item {
height: 28px;
line-height: 28px;
display: flex;
justify-content: space-between;
font-size: 10px;
color: #74788d;
.value {
color: #212529;
max-width: 124px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.dcloud-divider-horizontal {
margin: 0;
}
}
.page-panel {
margin-top: 24px;
text-align: center;
.dcloud-pagination-simple-pager {
font-size: 13px;
color: #495057;
text-align: center;
line-height: 22px;
input {
width: 48px;
height: 24px;
background: #ececf6;
border-radius: 6px;
}
}
.dcloud-pagination-simple {
.anticon {
font-size: 8px;
}
}
}
.empty-panel {
margin-top: 96px;
text-align: center;
.img {
width: 51px;
height: 34px;
margin-left: 80px;
margin-bottom: 7px;
background-size: cover;
background-image: url('../../assets/empty.png');
}
.text {
font-size: 10px;
color: #919aac;
line-height: 20px;
}
}
}
}
}
.healthy-drawer-content {
.table-form-item {
display: flex;
justify-content: flex-end;
align-items: flex-start;
.left-text,
.right-text {
line-height: 27px;
}
.left-text {
margin-right: 4px;
}
.right-text {
margin-left: 4px;
}
}
.dcloud-form-item {
margin-bottom: 0px !important;
}
}

View File

@@ -0,0 +1,35 @@
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
import React from 'react';
import TourGuide, { ClusterDetailSteps } from '@src/components/TourGuide';
import './index.less';
import LeftSider from './LeftSider';
import ChartPanel from './DetailChart';
import ChangeLog from './ChangeLog';
const SingleClusterDetail = (): JSX.Element => {
return (
<>
<TourGuide guide={ClusterDetailSteps} run={true} />
<div className="single-cluster-detail">
<div className="breadcrumb">
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: '集群详情', aHref: '' },
]}
/>
</div>
<div className="cluster-detail">
<LeftSider />
<div className="chart-panel">
<ChartPanel>
<ChangeLog />
</ChartPanel>
</div>
</div>
</div>
</>
);
};
export default SingleClusterDetail;

View File

@@ -0,0 +1,418 @@
/* eslint-disable no-case-declarations */
import { DownloadOutlined } from '@ant-design/icons';
import { AppContainer, Divider, IconFont, message, Tooltip, Utils } from 'knowdesign';
import * as React from 'react';
import moment from 'moment';
import { timeFormat } from '../../constants/common';
import ConfigForm from './component/ConfigForm';
import TestResult from './component/Result';
import { getFormConfig, getTableColumns, startFromMap } from './config';
import './index.less';
import API from '../../api';
import { useParams } from 'react-router-dom';
interface IPartition {
consumedOffset: number;
logEndOffset: number;
partitionId: number;
recordCount: number;
recordSizeUnitB: number;
}
const ConsumeClientTest = () => {
const formRef: any = React.createRef();
const [global] = AppContainer.useGlobalValue();
const [configInfo, setConfigInfo] = React.useState({});
const [topicMetaData, setTopicMetaData] = React.useState([]);
const [partitionProcess, setPartitionProcess] = React.useState([]);
const partitionProcessRef = React.useRef<any[]>([]);
const [tableData, setTableData] = React.useState([]);
const [tableDataRes, setTableDataRes] = React.useState([]);
const recordCountCur = React.useRef(0);
const recordSizeCur = React.useRef(0);
const [isStop, setIsStop] = React.useState(true);
const { clusterId } = useParams<{ clusterId: string }>();
const isStopStatus = React.useRef(true);
const curPartitionList = React.useRef<IPartition[]>([]);
const lastPartitionList = React.useRef<IPartition[]>([]);
const [topicName, setTopicName] = React.useState<string>('');
const [partitionLists, setPartitionLists] = React.useState([]);
const [consumerGroupList, setConsumerGroupList] = React.useState([]);
React.useEffect(() => {
Utils.request(API.getTopicMetaData(+clusterId))
.then((res: any) => {
const topics = (res || []).map((item: any) => {
return {
label: item.topicName,
value: item.topicName,
};
});
setTopicMetaData(topics);
})
.catch((err) => {
message.error(err);
});
return () => {
stop();
};
}, []);
React.useEffect(() => {
if (topicName) {
Utils.request(API.getConsumerGroup(topicName, +clusterId))
.then((res: any) => {
const consumers = (res || []).map((item: any) => {
return {
label: item.groupName,
value: item.groupName,
};
});
setConsumerGroupList(consumers);
})
.catch((err) => {
message.error(err);
});
Utils.request(API.getTopicsMetaData(topicName, +clusterId))
.then((res: any) => {
const partitionLists = (res?.partitionIdList || []).map((item: any) => {
return {
label: item,
value: item,
};
});
setPartitionLists(partitionLists);
})
.catch((err) => {
message.error(err);
});
}
}, [topicName]);
React.useEffect(() => {
// TODO: 临时修改tableData 长度为 0 时,直接退出
if (tableData.length !== 0) {
const res = tableData.map((item, index) => {
//Partition,offset,Timestamp,key,value,Header
return {
...item,
Partition: item.Partition,
offset: item.offset,
Timestamp: item.Timestamp,
key: index + '_' + item.offset,
keydata: item.key,
};
});
setTableDataRes(res);
}
}, [tableData]);
const onHandleValuesChange = (value: any, allValues: any) => {
Object.keys(value).forEach((key) => {
let changeValue = null;
switch (key) {
case 'topic':
AppContainer.eventBus.emit('ConsumeTopicChange', value[key]);
setTopicName(value[key]);
break;
case 'start':
changeValue = value[key][1];
setConfigInfo({
...configInfo,
needStartFromDate: changeValue === 'a specific date',
needConsumerGroup: changeValue === 'a consumer group',
needOffset: changeValue === 'latest x offsets' || changeValue === 'an offset',
needOffsetMax: changeValue === 'latest x offsets',
needPartitionList: changeValue === 'an offset',
});
break;
case 'until':
changeValue = value[key];
setConfigInfo({
...configInfo,
needMsgNum: changeValue === 'number of messages' || changeValue === 'number of messages per partition',
needMsgSize: changeValue === 'max size per partition' || changeValue === 'max size',
needUntilDate: changeValue === 'timestamp',
});
break;
case 'filter':
changeValue = value[key];
setConfigInfo({
...configInfo,
needFilterKeyValue: changeValue === 1 || changeValue === 2,
needFilterSize: changeValue === 3 || changeValue === 4 || changeValue === 5,
});
break;
}
});
};
const loopUntilTask = (values: any, res: any) => {
const { until, untilDate, untilMsgNum, unitMsgSize } = values;
const currentTime = new Date().getTime();
const partitionConsumedList: IPartition[] = res.partitionConsumedList || [];
lastPartitionList.current = res.partitionConsumedList || [];
const _partitionList = curPartitionList.current.length ? Array.from(curPartitionList.current) : Array.from(partitionConsumedList);
for (const item of partitionConsumedList) {
const index = _partitionList.findIndex((row) => row.partitionId === item.partitionId);
if (index < 0) {
_partitionList.push({
...item,
}); // 不存在则加入
} else {
// 存在则累加
_partitionList[index].recordCount = _partitionList[index].recordCount + item.recordCount;
_partitionList[index].recordSizeUnitB = _partitionList[index].recordSizeUnitB + item.recordSizeUnitB;
_partitionList.splice(index, 1, item);
}
}
const processList = _partitionList.map((row) => ({
label: `P${row.partitionId}`,
totalNumber: row.logEndOffset,
currentNumber: row.consumedOffset,
key: row.partitionId,
}));
setPartitionProcess(processList as any); // 用于进度条渲染
partitionProcessRef.current = processList;
curPartitionList.current = _partitionList;
switch (until) {
case 'timestamp':
setIsStop(currentTime >= untilDate);
isStopStatus.current = currentTime >= untilDate;
break;
case 'number of messages':
setIsStop(+recordCountCur.current >= untilMsgNum);
isStopStatus.current = +recordCountCur.current >= untilMsgNum;
break;
case 'number of messages per partition': // 所有分区都达到了设定值
// 过滤出消费数量不足设定值的partition
const filtersPartition = _partitionList.filter((item: any) => item.recordCount < untilMsgNum);
curPartitionList.current = filtersPartition; // 用作下一次请求的入参
setIsStop(filtersPartition.length < 1);
isStopStatus.current = filtersPartition.length < 1;
break;
case 'max size':
setIsStop(+recordSizeCur.current >= unitMsgSize);
isStopStatus.current = +recordSizeCur.current >= unitMsgSize;
break;
case 'max size per partition':
// 过滤出消费size不足设定值的partition
const filters = partitionConsumedList.filter((item: any) => item.recordSizeUnitB < unitMsgSize);
setIsStop(filters.length < 1);
isStopStatus.current = filters.length < 1;
curPartitionList.current = filters;
break;
}
};
const runClientConsumer = (values: any, params: any = {}, isFirst = false) => {
if (isStopStatus.current && !isFirst) return;
const offsetLists = [
{
offset: values?.offset,
partitionId: values?.partitionId,
},
];
const _params = {
clusterId,
maxDurationUnitMs: 8000, // 前端超时时间为10s这里接口超时设置8s
clientProperties: {},
maxRecords: 10,
topicName: values.topic,
recordOperate: isFirst,
filter: {
filterCompareKey: values.filterKey,
filterCompareSizeUnitB: values.filterSize,
filterCompareValue: values.filterValue,
filterType: values.filter,
},
startFrom: {
startFromType: isFirst ? startFromMap[values.start[1]].type : 3,
consumerGroup: startFromMap[values.start[1]].type === 4 ? values.consumerGroup : undefined,
latestMinusX: startFromMap[values.start[1]].type === 5 ? values.offset : undefined,
offsetList: isFirst
? startFromMap[values.start[1]].type === 3
? offsetLists
: undefined
: lastPartitionList.current?.map((item) => ({
offset: item.consumedOffset,
partitionId: item.partitionId,
})),
timestampUnitMs:
values.start[1] === 'a specific date'
? startFromMap[values.start[1]].getDate(values.startDate)
: startFromMap[values.start[1]].getDate && startFromMap[values.start[1]].getDate(),
},
...params,
};
if (!isStopStatus.current) {
Utils.post(API.postClientConsumer, _params)
.then((res: any) => {
// 累计
recordCountCur.current = recordCountCur.current + res.totalRecordCount;
recordSizeCur.current = recordSizeCur.current + res.totalRecordSizeUnitB;
setTableData(res.recordList || []); // TODO:这里累加是不是需要按partition进行累加
loopUntilTask(values, res);
if (!isStopStatus.current) {
runClientConsumer(values, {
partitionConsumedList: curPartitionList.current,
});
}
})
.catch((error) => {
setIsStop(true);
isStopStatus.current = true;
});
}
};
const initValues = () => {
curPartitionList.current = [];
lastPartitionList.current = [];
setPartitionProcess([]);
partitionProcessRef.current = [];
// setRecordCount(0);
recordCountCur.current = 0;
// setRecordSize(0);
recordSizeCur.current = 0;
};
const clearForm = () => {
initValues();
setTableData([]);
setIsStop(true);
isStopStatus.current = true;
setConfigInfo({});
AppContainer.eventBus.emit('ConsumeTopicChange', '');
formRef.current.resetFields();
};
const run = async () => {
const values = await formRef.current.validateFields();
initValues();
setIsStop(false);
isStopStatus.current = false;
setTableDataRes([]);
runClientConsumer(values, {}, true);
};
const stop = () => {
setIsStop(true);
isStopStatus.current = true;
};
const download = () => {
let str = `Partition,offset,Timestamp,key,value,Header,\n`;
const tableRes: any[] = tableData.map((item, index) => {
return {
Partition: item.partitionId,
offset: item.offset,
Timestamp: moment(item.timestampUnitMs).format(timeFormat),
key: item.key || '-',
value: item.value,
Header: item.headerList?.join('、'),
};
});
// 增加\t为了不让表格显示科学计数法或者其他格式
for (let i = 0; i < tableRes.length; i++) {
// for(let item in tableRes[i]){
// str += `${tableRes[i][item] + '\t'}${i < (Object.keys(tableRes[i]).length-1) ? ',' : ''}`;
// }
const keyArrs = Object.keys(tableRes[i]);
for (let j = 0; j < keyArrs.length; j++) {
const item = keyArrs[j];
str += `${tableRes[i][item] + '\t'},`;
}
str += '\n';
}
let uri = 'data:text/csv;charset=utf-8,\ufeff' + encodeURIComponent(str);
var link = document.createElement('a');
link.href = uri;
link.download = `consume-client-test${new Date().getTime()}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const TableHeaderInfo = (
<div className="consume-table-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<span className="title"></span>
</div>
<div>
<span className="info">
<span className="num">{recordCountCur.current}</span>
</span>
<span className="info" style={{ marginLeft: 10 }}>
<span className="num">{Utils.getSizeAndUnit(recordSizeCur.current).value}</span>{' '}
{Utils.getSizeAndUnit(recordSizeCur.current, 'B').unit}
</span>
<Divider type="vertical" />
<span className="info info-download" onClick={download}>
<DownloadOutlined style={{ marginRight: 6 }} size={12} color="#74788D" />
{/* <IconFont type="icon-xiazai" /> */}
<span></span>
</span>
</div>
</div>
);
const processTitle = () => {
return (
<>
<span className="">
offset{' '}
<Tooltip title="每个partition消费的offset信息">
<IconFont type="icon-zhushi" />
</Tooltip>
</span>
</>
);
};
return (
<>
<div className="client-test-panel">
<ConfigForm
title="消费配置"
formConfig={getFormConfig(topicMetaData, configInfo, partitionLists, consumerGroupList)}
formData={{}}
formRef={formRef}
onHandleValuesChange={onHandleValuesChange}
clearForm={clearForm}
submit={run}
running={!isStop}
stop={stop}
/>
<TestResult
showProcessList={true}
processTitle={processTitle()}
processList={partitionProcess}
tableTitle={TableHeaderInfo}
tableProps={{
columns: getTableColumns({ isShowControl: global.isShowControl }),
dataSource: tableDataRes,
lineFillColor: true,
rowKey: 'offset',
showHeader: false,
attrs: {
id: 'timestampUnitMs',
},
}}
showCardList={false}
/>
</div>
</>
);
};
export default ConsumeClientTest;

View File

@@ -0,0 +1,59 @@
import { Button, XForm } from 'knowdesign';
import { IFormItem } from 'knowdesign/lib/extend/x-form';
import * as React from 'react';
import './style/form.less';
import { useIntl } from 'react-intl';
const prefixTesting = 'config-form-panel-consume';
interface IProps {
formConfig: IFormItem[];
formData: any;
formRef: any;
onHandleValuesChange?: any;
title: string;
customContent?: React.ReactNode;
customForm?: React.ReactNode;
clearForm: any;
submit: any;
stop: any;
running?: boolean;
}
const ConfigForm = (props: IProps): JSX.Element => {
const { formConfig, formRef, formData, onHandleValuesChange, title, clearForm, submit, stop, running, customContent, customForm } = props;
const intl = useIntl();
const onSubmit = () => {
running ? stop() : submit();
};
return (
<>
<div className={prefixTesting}>
<div className={`${prefixTesting}-title`}>{title}</div>
<div className={`${prefixTesting}-content`}>
<div>{customContent}</div>
{customForm ? (
customForm
) : (
<XForm
onHandleValuesChange={onHandleValuesChange}
formData={formData}
formMap={formConfig}
wrappedComponentRef={formRef}
layout={'vertical'}
/>
)}
</div>
<div className={`${prefixTesting}-footer-btn`}>
<Button loading={running} onClick={clearForm}>
{intl.formatMessage({ id: 'test.client.clear' })}
</Button>
<Button onClick={onSubmit} type="primary">
{intl.formatMessage({ id: running ? 'test.client.stop' : 'test.client.run' })}
</Button>
</div>
</div>
</>
);
};
export default ConfigForm;

View File

@@ -0,0 +1,114 @@
import { Progress, Table, TableProps, Tooltip, ProTable } from 'knowdesign';
import * as React from 'react';
import { useIntl } from 'react-intl';
import './style/result.less';
interface ICard {
label: string;
value: number;
key: string;
unit?: string;
}
interface ITableCol {
partition: number;
offset: number;
time?: string;
timestamp: string;
key?: number;
value?: number;
header?: string;
}
interface IProcess {
label: string;
totalNumber: number;
currentNumber: number;
key: string;
}
interface IProps {
cardList?: ICard[];
showCardList: boolean;
processList?: IProcess[];
processTitle?: React.ReactNode | string;
showProcessList: boolean;
tableTitle?: React.ReactNode;
tableProps: any;
}
const getValueWithUnit = (value: number) => {
if (value === undefined || value === null) return '-';
if (value < 10000) {
return Number.isInteger(value) ? value : value.toFixed(2) + '';
}
// 9999万
if (value < 99990000) {
return (value / 10000).toFixed(2) + '万';
}
if (value < 999999990000) {
return (value / 10000 / 10000).toFixed(2) + '亿';
}
return (value / (10000 * 10000 * 1000)).toFixed(2) + '千亿';
};
const TestResult = (props: IProps): JSX.Element => {
const intl = useIntl();
const { cardList, showCardList, processList, showProcessList, processTitle, tableTitle, tableProps } = props;
return (
<>
<div className="test-result-panel">
<div className="page-title">{intl.formatMessage({ id: 'test.result' })}</div>
{showCardList && (
<div className="card-panel">
{cardList?.map((item, index) => (
<div className="card-item" key={`card-${index}`}>
<h4 className="label">{item.label}</h4>
<div className="value">
{item.value ?? '-'}
<span className="unit">{item.unit}</span>
</div>
</div>
))}
</div>
)}
{showProcessList && processList.length > 0 && (
<div className="process-panel">
{
<>
<div className="title">{processTitle}</div>
{processList?.map((item, index) => (
<div className="process-item" key={`process-${index}`}>
<Tooltip title={`${item.currentNumber}/${item.totalNumber}`}>
<div className="content">
<span className="label">{item.label}</span>
<span className="info">{`${getValueWithUnit(item.currentNumber)}/${getValueWithUnit(item.totalNumber)}`}</span>
</div>
<Progress
className="value"
percent={item.totalNumber ? +((item.currentNumber / item.totalNumber) * 100) : 0}
showInfo={false}
trailColor={'#ECECF1'}
strokeColor={'#556EE6'}
/>
</Tooltip>
</div>
))}
</>
}
</div>
)}
<div className="table-panel">
{tableTitle ? <div className="table-title">{tableTitle}</div> : null}
<ProTable tableProps={tableProps} />
</div>
</div>
</>
);
};
export default TestResult;

View File

@@ -0,0 +1,264 @@
/* eslint-disable react/display-name */
import { LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Tabs } from 'knowdesign';
import _ from 'lodash';
import * as React from 'react';
import { useIntl } from 'react-intl';
import './style/tabs.less';
interface ITab {
label: string;
tabpane: any;
key: string;
closable?: boolean;
}
interface IPagation {
pageSize: number;
total: number;
pageNo: number;
}
interface IProps {
title?: string;
initial: ITab;
}
const { TabPane } = Tabs;
const TaskTabs = React.forwardRef((props: IProps, ref): JSX.Element => {
const intl = useIntl();
const { initial } = props;
const [tabs, setTabs] = React.useState([{ ...initial }]);
const [activeKey, setActiveKey] = React.useState('tab-1');
const [pagination, setPagination] = React.useState<IPagation>({
pageSize: 10,
total: 1,
pageNo: 1,
});
const onClickArrow = _.throttle((type = '') => {
const navWrap = document.querySelector('#tabs-list .dcloud-tabs-nav-wrap') as HTMLElement;
const navList = document.querySelector('#tabs-list .dcloud-tabs-nav-list') as HTMLElement;
const navTab = document.querySelector('#tabs-list .dcloud-tabs-tab') as HTMLElement;
const navWrapWidth = Number(navWrap.offsetWidth);
const navListWidth = Number(navList.offsetWidth);
const tagWidth = navTab ? Number(navTab.offsetWidth) + 12 : 139 + 12;
const translateLen = Number((navList as any).style.transform.split('(')[1].split(',')[0].replace('px', ''));
const navListScrollLeft = Number(translateLen * -1) + (pagination.pageNo === pagination.total ? 180 : 0);
const navListScrollRight = Number(navListWidth - navWrapWidth - navListScrollLeft);
const tabVW = tagWidth * pagination.pageSize;
if (type === 'left') {
if (navListScrollLeft <= 0) {
return;
}
if (navListScrollLeft > tabVW) {
navList.style.transform = `translate(${translateLen + tabVW}px, 0)`;
} else {
navList.style.transform = `translate(0, 0)`;
}
}
if (type === 'right') {
if (navListScrollRight <= 0) {
return;
}
if (navListScrollRight >= tabVW) {
navList.style.transform = `translate(${translateLen - tabVW}px, 0)`;
} else {
navList.style.transform = `translate(${navWrapWidth - navListWidth - 80}px, 0)`;
}
}
setPaginationAfterClick(type);
}, 300);
const setPaginationAfterClick = (type: string) => {
setPagination({
...pagination,
pageNo: type === 'left' ? pagination.pageNo - 1 : pagination.pageNo + 1,
});
};
const countPagination = () => {
const navWrap = document.querySelector('#tabs-list .dcloud-tabs-nav-wrap') as HTMLElement;
const navList = document.querySelector('#tabs-list .dcloud-tabs-nav-list') as HTMLElement;
const navWrapWidth = Number(navWrap?.offsetWidth);
const navListWidth = Number(navList?.offsetWidth);
const tabNum = (navWrapWidth / (139 + 12)) | 0; // 每页可展示的数量
const index = tabs.findIndex((tab) => tab.key === activeKey); // 当前tab所在位置
if (navListWidth >= navWrapWidth) {
setPagination({
pageNo: Math.ceil((index + 1) / tabNum),
pageSize: tabNum,
total: Math.ceil(tabs.length / tabNum),
});
} else {
setPagination({
pageNo: 1,
pageSize: 10,
total: 1,
});
navList.style.transform = `translate(0, 0)`;
}
};
React.useEffect(() => {
countPagination();
const resize = _.throttle(() => {
countPagination();
}, 300);
const navWrap = document.querySelector('#tabs-list .dcloud-tabs-nav-wrap') as HTMLElement;
const onMouseOverOrOut = _.throttle((event, type) => {
if (
!event.target?.className ||
typeof event.target?.className !== 'string' ||
!event.target.className.includes('dcloud-tabs-tab') ||
event.target.className.includes('dcloud-tabs-tab-active')
) {
return;
}
if (event.target?.firstChild?.className === 'dcloud-tabs-tab-btn') {
const keys = event.target.firstChild.id?.split('-') || [];
const key = `tab-${keys[keys.length - 1]}`;
let _tabs = [].concat(tabs);
_tabs = _tabs.map((tab) => {
return {
...tab,
closable: tab.key === key ? type === 'over' : tab.key === activeKey,
};
});
setTabs([..._tabs]);
}
}, 300);
const onMouseOver = (event: any) => onMouseOverOrOut(event, 'over');
const onMouseOut = (event: any) => onMouseOverOrOut(event, 'out');
window.addEventListener('resize', resize);
navWrap.addEventListener('mouseover', onMouseOver);
navWrap.addEventListener('mouseout', onMouseOut);
return () => {
navWrap.removeEventListener('mouseover', onMouseOver);
navWrap.removeEventListener('mouseout', onMouseOut);
window.removeEventListener('resize', resize);
};
}, [tabs, activeKey]);
const setTabsTitle = (title: string) => {
const _tabs = [].concat(tabs);
const index = _tabs.findIndex((item) => item.key === activeKey);
if (index > -1) {
_tabs[index].label = `${title}`;
setTabs(_tabs);
}
};
React.useImperativeHandle(ref, () => ({
setTabsTitle,
}));
const addTask = async () => {
const _tabs = Array.from(tabs).map((tab) => ({
...tab,
closable: false,
}));
const lastKey = _tabs[_tabs.length - 1].key?.split('-')[1];
await setTabs([..._tabs, { ...initial, key: `tab-${+lastKey + 1}`, closable: true }]);
setActiveKey(`tab-${+lastKey + 1}`);
};
const removeTask = async (targetKey: string) => {
let _tabs = Array.from(tabs).filter((tab) => tab.key !== targetKey);
_tabs = _tabs.map((row, index) => ({
...row,
closable: _tabs.length !== 1 && index === _tabs.length - 1,
}));
await setTabs([..._tabs]);
// 设置最后一个tab为当前tab
setActiveKey(_tabs[_tabs.length - 1].key);
};
const onTabChange = async (key: string) => {
const _tabs = [].concat(tabs).map((tab) => ({
...tab,
closable: tab.key === key,
}));
await setTabs([..._tabs]);
setActiveKey(key);
};
const onTabScroll = ({ direction }: { direction: string }) => {
};
const onEdit = (targetKey: string, action: string) => {
if (action === 'remove') {
removeTask(targetKey);
}
};
const renderTabOpsSlot = () => {
return {
left: (
<Button className="add-task" onClick={addTask} type="dashed" icon={<PlusOutlined color="#74788D" />}>
{intl.formatMessage({ id: 'add.task' })}
</Button>
),
right: null as any,
};
};
return (
<>
<div className="tabs-panel">
<Tabs
tabBarExtraContent={renderTabOpsSlot()}
animated={false}
onEdit={onEdit}
hideAdd={true}
type="editable-card"
id="tabs-list"
defaultActiveKey="tab-1"
activeKey={activeKey}
onChange={onTabChange}
onTabScroll={onTabScroll}
>
{tabs.map((row) => (
<TabPane tab={row.label} key={row.key} closable={row.closable}>
{row.tabpane}
</TabPane>
))}
</Tabs>
{pagination.total > 1 && (
<div className="tab-nav-right">
<LeftOutlined
className={`tab-nav-right-icon ${pagination.pageNo <= 1 ? 'disabled' : ''}`}
onClick={() => onClickArrow('left')}
/>
<span className="tab-nav-right-text">{pagination.pageNo}</span>
<span className="tab-nav-right-text-divider">/</span>
<span className="tab-nav-right-text">{pagination.total}</span>
<RightOutlined
className={`tab-nav-right-icon ${pagination.pageNo >= pagination.total ? 'disabled' : ''}`}
onClick={() => onClickArrow('right')}
/>
</div>
)}
</div>
</>
);
});
export default TaskTabs;

View File

@@ -0,0 +1,37 @@
.config-form-panel-consume {
width: 280px;
min-width: 280px;
margin-right: 12px;
padding: 16px 24px 60px;
background: #FFFFFF;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
border-radius: 12px 12px 0 0;
position: relative;
.dcloud-row {
display: block;
}
&-title {
font-family: @font-family-bold;
font-size: 18px;
color: #212529;
letter-spacing: 0;
text-align: justify;
line-height: 20px;
padding-bottom: 16px;
}
&-footer-btn {
position: absolute;
bottom: 10px;
height: 48px;
line-height: 48px;
width: 240px;
text-align: right;
border-top: 1px solid #EBEDEF;
padding-top: 5px;
button+button {
margin-left: 8px;
}
}
}

View File

@@ -0,0 +1,157 @@
.test-result-panel {
flex: 1;
height: calc(100vh - 180px);
padding: 16px 24px;
background: #FFFFFF;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
border-radius: 12px 12px 0 0;
display: flex;
flex-direction: column;
height: calc(100vh - 156px);
.page-title {
font-family: @font-family-bold;
font-size: 18px;
color: #212529;
letter-spacing: 0;
text-align: justify;
line-height: 20px;
margin-bottom: 16px;
}
.card-panel {
background: #f8f9fe;
border-radius: 8px;
margin-bottom: 16px;
padding: 12px 24px;
display: flex;
justify-content: space-between;
.card-item {
height: 56px;
.label {
font-size: 14px;
color: #495057;
letter-spacing: 0;
text-align: justify;
line-height: 20px;
}
.value {
font-family: DIDIFD-Medium;
font-size: 32px;
color: #212529;
line-height: 36px;
}
.unit {
font-size: 16px;
color: #74788D;
letter-spacing: 0;
text-align: justify;
line-height: 14px;
padding-left: 4px;
}
}
}
.process-panel {
margin-bottom: 32px;
max-height: 300px;
overflow-y: auto;
.title {
font-family: @font-family-bold;
font-size: 14px;
color: #495057;
letter-spacing: 0;
margin-bottom: 12px;
}
.process-item:not(:last-child) {
margin-right: 55px;
}
.process-item {
width: 140px;
height: 25px;
display: inline-block;
.content {
display: flex;
justify-content: space-between;
line-height: 16px;
.label {
font-size: 12px;
color: #74788D;
letter-spacing: 0;
}
.info {
font-size: 10px;
color: #ADB5BC;
letter-spacing: 0;
}
}
.dcloud-progress-inner {
border-radius: 12px;
}
.dcloud-progress-status-success .dcloud-progress-bg {
background-color: none;
}
}
}
.table-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
.table-title {
display: flex;
justify-content: space-between;
margin-bottom: 13px;
.consume-table-header{
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.title{
font-family: @font-family-bold;
font-size: 14px;
color: #495057;
font-weight: 500;
}
.info {
font-size: 12px;
color: #74788D;
letter-spacing: 0;
text-align: justify;
line-height: 16px;
}
.num {
font-size: 14px;
color: #212529;
letter-spacing: 0;
text-align: justify;
line-height: 20px;
}
.info-download{
cursor: pointer;
}
}
.pro-table-container {
flex: 1;
overflow: auto;
}
}
}

View File

@@ -0,0 +1,159 @@
.tabs-panel {
margin: 10px 0 0;
position: relative;
.dcloud-tabs-nav {
height: 50px;
background: rgba(33, 37, 41, 0.04);
border-radius: 8px;
line-height: 50px;
padding: 0px 100px 0 12px;
margin-bottom: 12px;
.dcloud-tabs-tab {
font-size: 15px;
}
}
.add-task {
margin-right: 12px;
}
.dcloud-btn-dashed {
border-color: #74788D;
background: none;
border-radius: 6px;
border-style: dashed;
font-size: 12px;
color: #74788D;
letter-spacing: 0;
height: 30px;
padding: 6px 8px;
}
.dcloud-btn-dashed:hover,
.dcloud-btn-dashed:focus,
.dcloud-btn-dashed:active {
color: #556EE6;
border-color: #556EE6;
background: none;
}
.dcloud-btn>.anticon+span,
.dcloud-btn>span+.anticon {
margin-left: 4px;
}
.dcloud-tabs-card.dcloud-tabs-top>.dcloud-tabs-nav .dcloud-tabs-tab+.dcloud-tabs-tab,
.dcloud-tabs-card.dcloud-tabs-bottom>.dcloud-tabs-nav .dcloud-tabs-tab+.dcloud-tabs-tab,
.dcloud-tabs-card.dcloud-tabs-top>div>.dcloud-tabs-nav .dcloud-tabs-tab+.dcloud-tabs-tab,
.dcloud-tabs-card.dcloud-tabs-bottom>div>.dcloud-tabs-nav .dcloud-tabs-tab+.dcloud-tabs-tab {
margin-left: 12px;
}
.dcloud-tabs-card.dcloud-tabs-top>.dcloud-tabs-nav .dcloud-tabs-tab,
.dcloud-tabs-card.dcloud-tabs-top>div>.dcloud-tabs-nav .dcloud-tabs-tab {
padding: 0px 10px;
border-radius: 6px;
background: #d7d7e2;
height: 30px;
margin-top: 10px;
border: none;
transition: none;
width: 139px;
justify-content: space-between;
.dcloud-tabs-tab-btn {
outline: none;
transition: none;
font-size: 12px;
color: #74788D;
letter-spacing: 0;
width: 120px;
text-overflow: ellipsis;
overflow: hidden;
}
}
.dcloud-tabs-card.dcloud-tabs-top>.dcloud-tabs-nav .dcloud-tabs-tab-active,
.dcloud-tabs-card.dcloud-tabs-top>div>.dcloud-tabs-nav .dcloud-tabs-tab-active {
background: #556EE6;
opacity: 1;
.dcloud-tabs-tab-btn {
color: white;
}
.dcloud-tabs-tab-remove {
color: white;
}
}
.dcloud-tabs>.dcloud-tabs-nav .dcloud-tabs-nav-operations,
.dcloud-tabs>div>.dcloud-tabs-nav .dcloud-tabs-nav-operations {
display: none;
}
.dcloud-tabs-extra-content {
.tab-nav-right {
text-align: center;
padding-left: 14px;
height: 50px;
line-height: 50px;
&-icon {
color: #495057;
font-size: 7px;
&.disabled {
opacity: 0.5;
}
}
&-text {
font-size: 11px;
color: #495057;
text-align: center;
line-height: 16px;
padding: 0px 6px;
&-divider {
line-height: 22px;
}
}
}
}
.tab-nav-right {
position: absolute;
top: 0px;
right: 0px;
background: none;
background-image: linear-gradient(90deg, rgba(226, 226, 235, 0.00) 0%, #E2E2EB 29%);
height: 50px;
line-height: 50px;
padding: 0px 20px;
border-radius: 8px;
&-icon {
color: #495057;
font-size: 7px;
&.disabled {
opacity: 0.5;
}
}
&-text {
font-size: 11px;
color: #495057;
text-align: center;
line-height: 16px;
padding: 0px 6px;
&-divider {
line-height: 22px;
}
}
}
}

View File

@@ -0,0 +1,430 @@
import { FormItemType, IFormItem } from 'knowdesign/lib/extend/x-form';
import moment from 'moment';
import React from 'react';
import { timeFormat } from '../../constants/common';
import { Select } from 'knowdesign';
import { ControlStatusMap } from '../CommonRoute';
const { Option } = Select;
export const cardList = [
{ label: 'Records Average', value: 1, key: 'string', unit: 'B' },
{ label: 'Fetch Requests', value: 1, key: 'string', unit: 'B' },
{ label: 'Fetch Size Average', value: 1, key: 'string', unit: 'B' },
{ label: 'Fetch Letency Average', value: 1, key: 'string', unit: 'B' },
];
export const filterList = [
{
label: 'none',
value: 0,
},
{
label: 'contains',
value: 1,
},
{
label: 'does not contains',
value: 2,
},
{
label: 'equals',
value: 3,
},
{
label: 'Above Size',
value: 4,
},
{
label: 'Under Size',
value: 5,
},
];
export const untilList = [
{
label: 'forever',
value: 'forever',
},
{
label: 'number of messages',
value: 'number of messages',
},
{
label: 'number of messages per partition',
value: 'number of messages per partition',
},
{
label: 'max size',
value: 'max size',
},
{
label: 'max size per partition',
value: 'max size per partition',
},
{
label: 'timestamp',
value: 'timestamp',
},
];
/**
* LATEST(0, "最新位置开始消费"): nowlatest
* EARLIEST(1, "最旧位置开始消费"): the beginning
* PRECISE_TIMESTAMP(2, "指定时间开始消费"): last hour、today、yesterday、a specific date
* PRECISE_OFFSET(3, "指定位置开始消费"),
* CONSUMER_GROUP(4, "指定消费组进行消费"),
* LATEST_MINUS_X_OFFSET(5, "近X条数据开始消费"),
*/
export const startFromMap = {
'an offset': {
type: 3,
},
'nowlatest': {
type: 0,
},
today: {
type: 2,
getDate: () => new Date(new Date().setHours(0, 0, 0, 0)).getTime(),
},
'last hour': {
type: 2,
getDate: () => new Date().getTime() - 60 * 60 * 1000,
},
yesterday: {
type: 2,
getDate: () => new Date().getTime() - 24 * 60 * 60 * 1000,
},
'a specific date': {
type: 2,
getDate: (date: any) => new Date(date).getTime(),
},
'the beginning': {
type: 1,
},
'a consumer group': {
type: 4,
},
'latest x offsets': {
type: 5,
},
} as any;
const startFromOptions = [
{
value: 'Time Base',
label: 'Time Base',
children: [
{
value: 'nowlatest',
label: 'nowlatest',
},
{
value: 'last hour',
label: 'last hour',
},
{
value: 'today',
label: 'today',
},
{
value: 'yesterday',
label: 'yesterday',
},
{
value: 'the beginning',
label: 'the beginning',
},
{
value: 'a specific date',
label: 'a specific date',
},
],
},
{
value: 'Offset based',
label: 'Offset based',
children: [
{
value: 'a consumer group',
label: 'a consumer group',
},
{
value: 'latest x offsets',
label: 'latest x offsets',
},
{
value: 'an offset',
label: 'an offset',
},
],
},
];
export const getFormConfig = (topicMetaData: any, info = {} as any, partitionLists: any, consumerGroupList: any) => {
return [
{
key: 'topic',
label: 'Topic',
type: FormItemType.select,
attrs: {
placeholder: '请选择 Topic',
showSearch: true,
filterOption: (input: string, option: any) => option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0,
},
rules: [{ required: true, message: '请选择Topic' }],
options: topicMetaData,
},
{
key: 'start',
label: 'Start From',
attrs: {
placeholder: '请选择',
},
type: FormItemType.cascader,
options: startFromOptions,
rules: [
{
required: true,
message: '请选择Start From',
},
],
},
{
key: 'startDate',
label: 'Date',
type: FormItemType.datePicker,
invisible: !info?.needStartFromDate,
attrs: {
showTime: true,
},
rules: [
{
required: info?.needStartFromDate,
message: '请选择Date',
},
],
},
{
key: 'consumerGroup',
label: 'Consumer Group',
type: FormItemType.select,
options: consumerGroupList || [],
invisible: !info?.needConsumerGroup,
rules: [
{
required: info?.needConsumerGroup,
message: '请选择Consumer Group',
},
],
},
{
key: 'partitionId',
label: 'Partition',
type: FormItemType.select,
options: partitionLists || [],
colSpan: 12,
invisible: !info?.needPartitionList,
rules: [
{
required: info?.needPartitionList,
message: '请选择Partition',
},
],
},
{
key: 'offset',
label: 'Offset',
type: FormItemType.inputNumber,
colSpan: 12,
attrs: {
min: 1,
max: info?.needOffsetMax ? 99999 : Math.pow(2, 53) - 1,
},
invisible: !info?.needOffset,
rules: [
{
required: info?.needOffset,
message: '请输入Offset',
},
],
},
{
key: 'until',
label: 'Until',
type: FormItemType.select,
options: untilList,
rules: [
{
required: true,
message: '请选择Until',
},
],
},
{
key: 'untilMsgNum',
label: 'Message Number',
type: FormItemType.inputNumber,
attrs: {
min: 1,
},
invisible: !info?.needMsgNum,
rules: [
{
required: info?.needMsgNum,
message: '请输入Message Number',
},
],
},
{
key: 'unitMsgSize',
label: 'Message Size',
type: FormItemType.inputNumber,
attrs: {
min: 1,
},
invisible: !info?.needMsgSize,
rules: [
{
required: info?.needMsgSize,
message: '请输入Message Size',
},
],
},
{
key: 'untilDate',
label: 'Date',
type: FormItemType.datePicker,
invisible: !info?.needUntilDate,
attrs: {
showTime: true,
},
rules: [
{
required: info?.needUntilDate,
message: '请选择Date',
},
],
},
{
key: 'filter',
label: 'Filter',
type: FormItemType.select,
options: filterList,
rules: [
{
required: true,
message: '请选择Filter',
},
],
},
{
key: 'filterKey',
label: 'Key',
type: FormItemType.input,
invisible: !info?.needFilterKeyValue,
rules: [
{
required: info?.needFilterKeyValue,
message: '请输入Key',
},
],
},
{
key: 'filterValue',
label: 'Value',
type: FormItemType.input,
invisible: !info?.needFilterKeyValue,
rules: [
{
required: info?.needFilterKeyValue,
message: '请输入Value',
},
],
},
{
key: 'filterSize',
label: 'Size',
type: FormItemType.inputNumber,
attrs: {
min: 1,
},
invisible: !info?.needFilterSize,
rules: [
{
required: info?.needFilterSize,
message: '请输入Size',
},
],
},
] as unknown as IFormItem[];
};
export const getTableColumns = (props: any) => {
const { isShowControl } = props;
const columns = [
{
title: 'Partition',
dataIndex: 'partitionId',
width: 100,
},
{
title: 'Offset',
dataIndex: 'offset',
width: 140,
},
{
title: 'Timestamp',
dataIndex: 'timestampUnitMs',
width: 180,
render: (text: number) => {
return text ? moment(text).format(timeFormat) : '-';
},
},
{
title: 'Key',
dataIndex: 'keydata',
render: (text: string) => {
return text || '-';
},
width: 180,
needTooltip: true,
lineClampTwo: true,
},
{
title: 'Value',
dataIndex: 'value',
width: 180,
needTooltip: true,
lineClampTwo: true,
},
];
// eslint-disable-next-line no-constant-condition
if (isShowControl && isShowControl(ControlStatusMap.TESTING_CONSUMER_HEADER)) {
columns.push({
title: 'Header',
dataIndex: 'headerList',
// render: (text: any) => (text ? text?.join('、') : '-'),
render: (text: any) => {
if (text && Array.isArray(text)) {
const newText = text.map((item: any) => {
try {
return JSON.stringify(item);
} catch (error) {
return item;
}
});
return newText?.join('、') || '-';
}
return text || '-';
},
width: 180,
needTooltip: true,
lineClampTwo: true,
});
}
return columns;
};

View File

@@ -0,0 +1,5 @@
.client-test-panel {
display: flex;
// padding: 12px 20px 0px 20px;
// height: calc(100vh - 68px)
}

View File

@@ -0,0 +1,42 @@
import { AppContainer } from 'knowdesign';
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
import * as React from 'react';
import { useParams } from 'react-router-dom';
import TaskTabs from './component/TaskTabs';
import ConsumeClientTest from './Consume';
import './index.less';
const Consume = () => {
const initial = {
label: '消费',
key: 'tab-1',
closable: false,
tabpane: <ConsumeClientTest />,
};
const ref: any = React.useRef();
const { clusterId } = useParams<{ clusterId: string }>();
const [global] = AppContainer.useGlobalValue();
React.useEffect(() => {
AppContainer.eventBus.on('ConsumeTopicChange', (args: string) => {
ref.current && ref.current.setTabsTitle && ref.current.setTabsTitle(`消费 ${args}`);
});
}, []);
return (
<>
<div className="breadcrumb">
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Consume', aHref: '' },
]}
/>
</div>
<TaskTabs initial={initial} ref={ref} />
</>
);
};
export default Consume;

View File

@@ -0,0 +1,245 @@
import { AppContainer, Form, message, Tabs, Utils } from 'knowdesign';
import * as React from 'react';
import ConfigForm from './component/ConfigFrom';
import TestResult from '../TestingConsumer/component/Result';
import API from '../../api';
import { getFormConfig, getTableColumns, tabsConfig } from './config';
import './index.less';
import { useParams } from 'react-router-dom';
const { TabPane } = Tabs;
const ProduceClientTest = () => {
const [global] = AppContainer.useGlobalValue();
const [form] = Form.useForm();
const customFormRef: any = React.createRef();
const [configInfo, setConfigInfo] = React.useState({});
const [activeKey, setActiveKey] = React.useState(tabsConfig[0].name);
const [tableData, setTableData] = React.useState([]);
const [topicMetaData, setTopicMetaData] = React.useState([]);
const [running, setRunning] = React.useState(false);
const [isKeyOn, setIsKeyOn] = React.useState(true);
const { clusterId } = useParams<{ clusterId: string }>();
const currentInterval = React.useRef(null);
let currentMsgNum = 0;
let startTime = 0;
React.useEffect(() => {
Utils.request(API.getTopicMetaData(+clusterId))
.then((res: any) => {
const filterRes = res.filter((item: any) => item.type !== 1);
const topics = (filterRes || []).map((item: any) => {
return {
label: item.topicName,
value: item.topicName,
partitionIdList: item.partitionIdList,
};
});
setTopicMetaData(topics);
})
.catch((err) => {
message.error(err);
});
return () => {
clearInterval();
};
}, []);
const runProduceClient = (values: any, isFirst = false) => {
// 记录每一次请求发送的条数
currentMsgNum = currentMsgNum ? currentMsgNum + values.chunks : values.chunks;
const params = {
clientProperties: {
acks: values.acks,
'compression.type': values.compressionType,
},
clusterId,
partitionIdList: values.frocePartition,
recordCount: values.chunks,
recordHeader: values.recordHeader,
recordKey: isKeyOn ? values.key || '' : undefined,
recordOperate: isFirst,
recordValue: values.value,
topicName: values.topicName,
};
Utils.post(API.postClientProducer, params)
.then((res: any) => {
if (isFirst) {
startTime = new Date().getTime();
}
setTableData(res || []);
if (values.producerMode === 'timed') {
if (values.interval && !currentInterval.current) {
// const randomValue = Math.random() < 0.5 ? -1 : 1;
// const random = +values.jitter ? +values.interval + randomValue * (Math.random() * +values.jitter) : values.interval;
const random = values.interval;
currentInterval.current = window.setInterval(() => {
runProduceClient(values);
}, random);
}
// 第一次接口返回到现在的时间大于运行时间 当前发送条数大于总条数停止请求
const currentTime = new Date().getTime();
// if (currentTime - startTime >= +values.elapsed || currentMsgNum >= values.messageProduced) {
if (currentTime - startTime >= +values.elapsed) {
clearInterval();
setRunning(false);
}
} else {
// manual 方式
setRunning(false);
}
})
.catch((err) => {
// manual 方式
if (values.producerMode !== 'timed') {
setRunning(false);
}
});
};
const run = async () => {
form
.validateFields()
.then((values) => {
values.elapsed = values.elapsed * 60 * 1000;
const data = customFormRef.current.getTableData();
values.recordHeader = {};
for (const item of data) {
values.recordHeader[item.key] = item.value;
}
// 点击按钮重新定时器清空
clearInterval();
setRunning(true);
runProduceClient(values, true);
})
.catch((error) => {
const { values } = error;
if (!values.chunks || (values.producerMode === 'timed' && (!values.interval || !values.elapsed))) {
setActiveKey('Flow');
}
if (!values.topicName || !values.value) {
setActiveKey('Data');
}
});
};
const clearInterval = () => {
currentInterval.current && window.clearInterval(currentInterval.current);
currentInterval.current = null;
};
const stop = () => {
currentInterval.current && window.clearInterval(currentInterval.current);
setRunning(false);
};
const onHandleValuesChange = (value: any, allValues: any) => {
Object.keys(value).forEach((key) => {
let changeValue: any = null;
let partitionIdList = [];
switch (key) {
case 'producerMode':
changeValue = value[key];
setConfigInfo({
...configInfo,
needTimeOption: changeValue === 'timed',
});
break;
case 'interval':
changeValue = value[key];
setConfigInfo({
...configInfo,
maxJitter: +value[key] - 1,
});
break;
case 'topicName':
changeValue = value[key] as string;
AppContainer.eventBus.emit('ProduceTopicChange', value[key]);
partitionIdList = topicMetaData.find((item) => item.label === changeValue)?.partitionIdList || [];
partitionIdList = partitionIdList.map((item: number) => ({
label: item,
value: item,
}));
setConfigInfo({
...configInfo,
partitionIdList,
});
break;
}
});
};
const clearForm = () => {
setConfigInfo({});
clearInterval();
setTableData([]);
form.resetFields();
AppContainer.eventBus.emit('ProduceTopicChange', '');
setIsKeyOn(true);
customFormRef.current.resetTable();
};
const onTabChange = (key: string) => {
setActiveKey(key);
};
const onKeySwitchChange = (checked: boolean) => {
setIsKeyOn(checked);
};
const TabsContent = (
<div className="form-tabs">
<Tabs defaultActiveKey={tabsConfig[0].name} activeKey={activeKey} onChange={onTabChange}>
{tabsConfig.map(({ name, control }) =>
!control || (global.isShowControl && global.isShowControl(control)) ? <TabPane tab={name} key={name} /> : <></>
)}
</Tabs>
</div>
);
return (
<>
<div className="client-test-panel">
<ConfigForm
title="生产配置"
customContent={TabsContent}
customFormRef={customFormRef}
activeKey={activeKey}
formConfig={getFormConfig({
topicMetaData,
activeKey,
configInfo,
form,
onKeySwitchChange,
isKeyOn,
isShowControl: global.isShowControl,
})}
formData={{}}
form={form}
onHandleValuesChange={onHandleValuesChange}
clearForm={clearForm}
submit={run}
running={running}
stop={stop}
/>
<TestResult
showProcessList={false}
tableProps={{
scroll: { y: 600 },
columns: getTableColumns(),
dataSource: tableData,
pagination: false,
}}
showCardList={false}
/>
</div>
</>
);
};
export default ProduceClientTest;

View File

@@ -0,0 +1,124 @@
import { Button, Col, Form, Row } from 'knowdesign';
import { FormItemType, handleFormItem, IFormItem, renderFormItem } from 'knowdesign/lib/extend/x-form';
import * as React from 'react';
import './style/form.less';
import EditTable from './EditTable';
import { useIntl } from 'react-intl';
const prefixTesting = 'config-form-panel';
interface IProps {
formConfig: IFormItem[];
formData: any;
form: any;
customFormRef: any;
onHandleValuesChange?: any;
title: string;
customContent?: React.ReactNode;
customForm?: React.ReactNode;
clearForm: any;
submit: any;
stop: any;
activeKey: string;
running?: boolean;
}
export const renderFormContent = ({ formMap, formData, layout, formLayout, formItemColSpan = 24 }: any) => {
return (
<Row gutter={10}>
{formMap.map((formItem: IFormItem) => {
const { initialValue = undefined, valuePropName } = handleFormItem(formItem, formData);
if (formItem.type === FormItemType.text)
return (
<Col style={{ display: formItem.invisible ? 'none' : '' }} key={formItem.key} span={formItem.colSpan || formItemColSpan}>
{layout === 'vertical' ? (
<>
<span className="dcloud-form-item-label" style={{ padding: '0 0 7px', display: 'block' }}>
{formItem.label}
</span>
{(formItem as any).customFormItem ? (
<span style={{ fontSize: '14px', padding: '0 0 16px', display: 'block' }}>{(formItem as any).customFormItem}</span>
) : null}
</>
) : (
<Row style={{ padding: '6px 0 10px' }}>
<Col span={formLayout?.labelCol.span || 4} style={{ textAlign: 'right' }}>
<span className="dcloud-form-item-label" style={{ padding: '0 10px 0 0', display: 'inline-block' }}>
{formItem.label}:
</span>
</Col>
<Col span={formLayout?.wrapperCol.span || 20}>
<span>{(formItem as any).customFormItem}</span>
</Col>
</Row>
)}
</Col>
);
return (
<Col style={{ display: formItem.invisible ? 'none' : '' }} key={formItem.key} span={formItem.colSpan || formItemColSpan}>
<Form.Item
name={formItem.key}
key={formItem.key}
label={formItem.label}
rules={formItem.rules || [{ required: false, message: '' }]}
initialValue={initialValue}
valuePropName={valuePropName}
{...formItem.formAttrs}
>
{renderFormItem(formItem)}
</Form.Item>
</Col>
);
})}
</Row>
);
};
const ConfigForm = (props: IProps): JSX.Element => {
const {
formConfig,
activeKey,
customFormRef,
form,
formData,
onHandleValuesChange,
title,
clearForm,
submit,
stop,
running,
customContent,
} = props;
const intl = useIntl();
const onSubmit = () => {
running ? stop() : submit();
};
return (
<>
<div className={prefixTesting}>
<div className={`${prefixTesting}-title`}>{title}</div>
<div>{customContent}</div>
<div className={`${prefixTesting}-content`}>
<div style={{ display: activeKey === 'Header' ? '' : 'none' }}>
<EditTable ref={customFormRef} />
</div>
<Form form={form} layout={'vertical'} onValuesChange={onHandleValuesChange}>
{renderFormContent({ formMap: formConfig, formData, layout: 'vertical' })}
</Form>
</div>
<div className={`${prefixTesting}-footer-btn`}>
<Button loading={running} onClick={clearForm}>
{intl.formatMessage({ id: 'test.client.clear' })}
</Button>
<Button onClick={onSubmit} type="primary">
{intl.formatMessage({ id: running ? 'test.client.stop' : 'test.client.run' })}
</Button>
</div>
</div>
</>
);
};
export default ConfigForm;

View File

@@ -0,0 +1,40 @@
import { PicLeftOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { Button, Input, Tooltip } from 'knowdesign';
import * as React from 'react';
interface IProps {
value?: string;
onChange?: any;
tooltip?: string;
}
const CustomTextArea = (props: IProps) => {
const { tooltip, value, onChange } = props;
const genRandomContent = () => {
onChange && onChange(value.trim());
};
const onInputChange = ({ target: { value } }: { target: { value: string } }) => {
onChange && onChange(value.trim());
};
return (
<>
<div>
<span>
<Tooltip title={tooltip}>
<QuestionCircleOutlined />
</Tooltip>
</span>
<span>
<Tooltip title={'生成随机内容'}>
<PicLeftOutlined onClick={genRandomContent} />
</Tooltip>
</span>
</div>
<Input.TextArea value={value} onChange={onInputChange} />
</>
);
};
export default CustomTextArea;

View File

@@ -0,0 +1,217 @@
/* eslint-disable react/display-name */
import React, { useState } from 'react';
import { Table, Input, InputNumber, Popconfirm, Form, Typography, Button, message, IconFont } from 'knowdesign';
import './style/edit-table.less';
import { CheckOutlined, CloseOutlined, PlusSquareOutlined } from '@ant-design/icons';
const EditableCell = ({ editing, dataIndex, title, inputType, placeholder, record, index, children, ...restProps }: any) => {
const inputNode =
inputType === 'number' ? (
<InputNumber style={{ width: '130px' }} autoComplete="off" placeholder={placeholder} />
) : (
<Input autoComplete="off" placeholder={placeholder} />
);
return (
<td {...restProps}>
{editing ? (
<Form.Item
name={dataIndex}
style={{
margin: 0,
}}
rules={[
{
required: true,
message: `请输入!`,
},
]}
>
{inputNode}
</Form.Item>
) : (
children
)}
</td>
);
};
const EditTable = React.forwardRef((props: any, ref: any) => {
const { colCustomConfigs } = props;
const [form] = Form.useForm();
const [data, setData] = useState([]);
const [editingKey, setEditingKey] = useState(0);
const isEditing = (record: any) => record.editKey === editingKey;
const getTableData = () => {
return data;
};
const resetTable = () => {
setData([]);
};
React.useImperativeHandle(ref, () => ({
getTableData,
resetTable,
}));
const edit = (record: any) => {
form.setFieldsValue({
key: '',
value: '',
...record,
});
setEditingKey(record.editKey);
};
const deleteRow = (record: any) => {
const _data = data.filter((item) => item.editKey !== record.editKey);
setData(_data);
};
const cancel = (record: any) => {
if (record.key === '') {
const _data = data.filter((item) => item.editKey !== record.editKey);
setData(_data);
}
setEditingKey(0);
};
const add = () => {
if (editingKey !== 0) {
message.error('请先保存当前编辑项');
return;
}
form.resetFields();
const editKey = data.length ? data[data.length - 1].editKey + 1 : 1;
setData([...data, { key: '', value: '', editKey }]);
setEditingKey(editKey);
};
const save = async (editKey: number) => {
try {
const row = await form.validateFields();
const newData = [...data];
const index = newData.findIndex((item) => editKey === item.editKey);
if (index > -1) {
const item = newData[index];
newData.splice(index, 1, { ...item, ...row });
setData(newData);
setEditingKey(0);
} else {
newData.push(row);
setData(newData);
setEditingKey(0);
}
} catch (errInfo) {
console.log('Validate Failed:', errInfo);
}
};
const columns = [
{
title: 'key',
dataIndex: 'key',
width: 40,
editable: true,
},
{
title: 'value',
dataIndex: 'value',
width: 40,
editable: true,
},
{
title: '',
dataIndex: 'operation',
width: 20,
className: 'no-padding',
render: (text: any, record: any) => {
const editable = isEditing(record);
return editable ? (
<span>
<Typography.Link
onClick={() => save(record.editKey)}
style={{
marginRight: 8,
}}
>
<CheckOutlined />
</Typography.Link>
{/* <Popconfirm title="确认取消?" onConfirm={() => cancel(record)}> */}
<a>
<CloseOutlined onClick={() => cancel(record)} />
</a>
{/* </Popconfirm> */}
</span>
) : (
<>
<Typography.Link
className="custom-typography"
disabled={editingKey !== 0}
style={{
marginRight: 8,
}}
onClick={() => edit(record)}
>
<IconFont type={'icon-bianji1'} size={11} />
</Typography.Link>
<Typography.Link className="custom-typography" disabled={editingKey !== 0} onClick={() => deleteRow(record)}>
<IconFont type={'icon-shanchu1'} size={11} />
</Typography.Link>
</>
);
},
},
];
const mergedColumns = columns.map((col, index) => {
if (!col.editable) {
return col;
}
return {
...col,
onCell: (record: any) =>
Object.assign(
{
record,
inputType: 'text',
dataIndex: col.dataIndex,
title: col.title,
editing: isEditing(record),
},
colCustomConfigs?.[index]
),
title: colCustomConfigs?.[index]?.title || col.title,
};
});
return (
<div className="edit-table-form">
<Form form={form} component={false}>
<Table
components={{
body: {
cell: EditableCell,
},
}}
// scroll={{
// x: true,
// }}
dataSource={data}
columns={mergedColumns}
rowClassName="editable-row"
pagination={false}
/>
</Form>
<div className="add-btn">
<Button type="link" onClick={add}>
<PlusSquareOutlined />
</Button>
</div>
</div>
);
});
export default EditTable;

View File

@@ -0,0 +1,42 @@
.edit-table-form {
.dcloud-table-thead>tr>th:not(:last-child):not(.dcloud-table-selection-column):not(.dcloud-table-row-expand-icon-cell):not([colspan])::before {
width: 0px;
}
table {
table-layout: fixed !important;
}
.dcloud-table .dcloud-table-content .dcloud-table-cell {
padding: 4px 12px;
height: 36px;
font-family: HelveticaNeue-Bold;
font-size: 13px;
color: #495057;
letter-spacing: 0;
.dcloud-form-item-control-input {
min-height: 27px;
}
.dcloud-input {
padding: 4px 12px;
height: 27px;
line-height: 27px;
background: rgba(33, 37, 41, 0.04);
border-radius: 6px;
}
.custom-typography {
color: #74788D;
}
}
.add-btn {
height: 36px;
line-height: 36px;
border-bottom: 1px solid #EFF2F7;
}
.no-padding {
padding: 0px !important;
}
}

View File

@@ -0,0 +1,57 @@
.config-form-panel {
width: 280px;
min-width: 280px;
min-height: 260px;
margin-right: 12px;
padding: 16px 24px 60px;
background: #FFFFFF;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.01), 0 3px 6px 3px rgba(0, 0, 0, 0.01), 0 2px 6px 0 rgba(0, 0, 0, 0.03);
border-radius: 12px 12px 0 0;
position: relative;
height: calc(100vh - 156px);
.dcloud-radio-wrapper.dcloud-radio-wrapper-checked {
.dcloud-radio-inner {
background-color: #526ecc;
}
}
.dcloud-radio-inner {
border-color: #ADB5BD;
}
.dcloud-radio-inner::after {
background-color: #fff;
}
&-title {
font-family: @font-family-bold;
font-size: 18px;
color: #212529;
letter-spacing: 0;
text-align: justify;
line-height: 20px;
padding-bottom: 16px;
}
&-content{
overflow-y: auto;
height: calc(100vh - 326px);
min-height: 90px;
}
&-footer-btn {
position: absolute;
bottom: 10px;
height: 48px;
line-height: 48px;
width: 240px;
text-align: right;
border-top: 1px solid #EBEDEF;
padding-top: 5px;
button+button {
margin-left: 8px;
}
}
}

View File

@@ -0,0 +1,396 @@
import { QuestionCircleOutlined } from '@ant-design/icons';
import { IconFont, Switch, Tooltip } from 'knowdesign';
import { FormItemType, IFormItem } from 'knowdesign/lib/extend/x-form';
import moment from 'moment';
import React from 'react';
import { timeFormat } from '../../constants/common';
import { getRandomStr } from '../../lib/utils';
import { ControlStatusMap } from '../CommonRoute';
export const filterList = [
{
label: 'none',
value: 'none',
},
{
label: 'contains',
value: 'contains',
},
{
label: 'does not contains',
value: 'does not contains',
},
{
label: 'equals',
value: 'equals',
},
{
label: 'Above Size',
value: 'aboveSize',
},
{
label: 'Under Size',
value: 'underSize',
},
];
export const untilList = [
{
label: 'Forever',
value: 'forever',
},
{
label: 'number of messages',
value: 'number of messages',
},
{
label: 'number of messages per partition',
value: 'number of messages per partition',
},
{
label: 'max size',
value: 'max size',
},
{
label: 'max size per partition',
value: 'max size per partition',
},
{
label: 'timestamp',
value: 'timestamp',
},
];
export const tabsConfig = [
{ name: 'Data', control: '' },
{ name: 'Flow', control: '' },
{ name: 'Header', control: ControlStatusMap.TESTING_PRODUCER_HEADER },
{ name: 'Options', control: '' },
];
const getComplexLabel = (key: string, label: string, form: any, onSwitchChange?: any, needSwitch = false) => (
<div className="complex-label">
<span>
<span>{label}</span>
<Tooltip title="暂支持string格式">
<QuestionCircleOutlined size={12} style={{ marginLeft: 2 }} />
</Tooltip>
:
{needSwitch ? (
<span>
<Switch onClick={onSwitchChange} size="small" defaultChecked className="switch" />
</span>
) : null}
</span>
<span>
<Tooltip title={'生成随机内容'}>
<IconFont
type="icon-shengchengdaima"
className="random-icon"
onClick={() => {
const randomStr = getRandomStr(key === 'key' ? 30 : 128);
form && form.setFieldsValue({ [key]: randomStr });
}}
/>
</Tooltip>
</span>
</div>
);
export const getFormConfig = (params: any) => {
const { topicMetaData, activeKey: type, configInfo: info, form, onKeySwitchChange, isKeyOn, isShowControl } = params;
const formConfig = [
{
key: 'topicName',
label: 'Topic',
type: FormItemType.select,
invisible: type !== 'Data',
rules: [{ required: true, message: '请选择Topic' }],
options: topicMetaData,
attrs: {
showSearch: true,
},
},
{
key: 'key',
label: getComplexLabel('key', 'Key', form, onKeySwitchChange, true),
type: FormItemType.textArea,
invisible: type !== 'Data',
rules: [
{
required: false,
message: '请输入Key',
},
],
attrs: {
disabled: !isKeyOn,
rows: 5,
placeholder: '暂支持string类型',
},
},
{
key: 'value',
label: getComplexLabel('value', 'Value', form),
type: FormItemType.textArea,
invisible: type !== 'Data',
rules: [
{
required: true,
message: '请输入Value',
},
],
attrs: {
rows: 5,
placeholder: '暂支持string类型',
},
},
{
key: 'chunks',
label: '单次发送消息数',
type: FormItemType.inputNumber,
invisible: type !== 'Flow',
defaultValue: 1,
rules: [{ required: true, message: '请输入' }],
attrs: {
min: 0,
style: { width: 232 },
},
},
{
key: 'producerMode',
label: '生产模式',
type: FormItemType.radioGroup,
invisible: type !== 'Flow',
defaultValue: 'manual',
options: [
{
label: '手动',
value: 'manual',
},
{
label: '周期',
value: 'timed',
},
],
rules: [
{
required: true,
message: '请选择生产模式',
},
],
},
// {
// key: 'timeOptions',
// label: 'Timer options',
// type: FormItemType.text,
// invisible: type !== 'Flow' || !info?.needTimeOption,
// customFormItem: null,
// rules: [
// {
// required: false,
// message: '请选择Producer Mode',
// },
// ],
// },
{
key: 'elapsed',
label: '运行总时间min',
type: FormItemType.inputNumber,
invisible: type !== 'Flow' || !info?.needTimeOption,
rules: [
{
required: info?.needTimeOption,
message: '请输入',
},
],
attrs: {
min: 0,
max: 300,
style: { width: '100%' },
},
},
{
key: 'interval',
label: '时间间隔ms',
type: FormItemType.inputNumber,
invisible: type !== 'Flow' || !info?.needTimeOption,
rules: [
{
required: info?.needTimeOption,
message: '请输入',
},
],
attrs: {
min: 300, // 300ms间隔保证请求不太频繁
style: { width: '100%' },
},
},
// {
// key: 'jitter',
// label: 'Max jitterms',
// type: FormItemType.inputNumber,
// invisible: type !== 'Flow' || !info?.needTimeOption,
// rules: [
// {
// required: info?.needTimeOption,
// message: '请输入Max jitter',
// },
// ],
// attrs: {
// max: info.maxJitter || 0,
// size: 'small',
// style: { width: 216 },
// },
// formAttrs: {
// className: 'inner-item',
// },
// },
// {
// key: 'lifecycle',
// label: (
// <>
// Lifecycle options
// <Tooltip title="Shutdown the producer automatically">
// <QuestionCircleOutlined style={{ marginLeft: 8 }} />
// </Tooltip>
// </>
// ),
// type: FormItemType.text,
// invisible: type !== 'Flow' || !info?.needTimeOption,
// customFormItem: null,
// rules: [
// {
// required: false,
// message: '',
// },
// ],
// },
// {
// key: 'messageProduced',
// label: 'Number of message produced',
// type: FormItemType.inputNumber,
// invisible: type !== 'Flow' || !info?.needTimeOption,
// rules: [
// {
// required: info?.needTimeOption,
// message: '请输入Number of message produced',
// },
// ],
// attrs: {
// min: 0,
// size: 'small',
// style: { width: 216 },
// },
// formAttrs: {
// className: 'inner-item',
// },
// },
{
key: 'frocePartition',
label: 'Froce Partition',
invisible: type !== 'Options',
type: FormItemType.select,
attrs: {
mode: 'multiple',
},
options: info.partitionIdList || [],
rules: [{ required: false, message: '请选择' }],
},
{
key: 'compressionType',
label: 'Compression Type',
type: FormItemType.radioGroup,
defaultValue: 'none',
invisible: type !== 'Options',
options: (() => {
const options = [
{
label: 'none',
value: 'none',
},
{
label: 'gzip',
value: 'gzip',
},
{
label: 'snappy',
value: 'snappy',
},
{
label: 'lz4',
value: 'lz4',
},
];
if (isShowControl && isShowControl(ControlStatusMap.TESTING_PRODUCER_COMPRESSION_TYPE_ZSTD)) {
options.push({
label: 'zstd',
value: 'zstd',
});
}
return options;
})(),
rules: [
{
required: false,
message: '请选择Compression Type',
},
],
},
{
key: 'acks',
label: 'Acks',
type: FormItemType.radioGroup,
defaultValue: '0',
invisible: type !== 'Options',
options: [
{
label: 'none',
value: '0',
},
{
label: 'leader',
value: '1',
},
{
label: 'all',
value: 'all',
},
],
rules: [
{
required: false,
message: '请选择Acks',
},
],
},
] as unknown as IFormItem[];
return formConfig;
};
export const getTableColumns = () => {
return [
{
title: 'Partition',
dataIndex: 'partitionId',
},
{
title: 'offset',
dataIndex: 'offset',
},
{
title: 'Timestamp',
dataIndex: 'timestampUnitMs',
render: (text: number) => {
return moment(text).format(timeFormat);
},
},
{
title: 'time',
dataIndex: 'costTimeUnitMs',
width: 60,
},
];
};

View File

@@ -0,0 +1,102 @@
.client-test-panel {
display: flex;
.form-tabs {
background: transparent;
.dcloud-tabs-nav {
height: 32px;
background: none;
line-height: 32px;
}
.dcloud-tabs-top>.dcloud-tabs-nav,
.dcloud-tabs-bottom>.dcloud-tabs-nav,
.dcloud-tabs-top>div>.dcloud-tabs-nav,
.dcloud-tabs-bottom>div>.dcloud-tabs-nav {
margin-bottom: 18px;
padding: 0px;
}
.dcloud-tabs-tab+.dcloud-tabs-tab {
margin: 0 0 0 16px;
}
.dcloud-tabs-top>.dcloud-tabs-nav::before,
.dcloud-tabs-bottom>.dcloud-tabs-nav::before,
.dcloud-tabs-top>div>.dcloud-tabs-nav::before,
.dcloud-tabs-bottom>div>.dcloud-tabs-nav::before {
border: none;
}
}
.dcloud-form-item {
margin-bottom: 14px;
&-label {
font-family: @font-family-bold;
font-size: 13px;
color: #495057;
letter-spacing: 0;
line-height: 18px !important;
.complex-label {
width: 228px;
display: flex;
justify-content: space-between;
.random-icon {
font-size: 18px;
height: 18px;
color: #ADB5BC;
}
.random-icon:hover {
height: 18px;
text-align: center;
border-radius: 9px;
line-height: 20px;
color: #74788D;
background: rgba(33, 37, 41, 0.04);
}
.switch {
margin-left: 12px;
}
}
}
.dcloud-form-item-control-input {
textarea {
height: 110px;
border-radius: 8px;
}
}
}
.dcloud-form-item.inner-item {
margin-left: 16px;
margin-bottom: 7px;
.dcloud-form-item-label {
padding: 0 0 4px;
>label {
font-size: 13px;
color: #74788D;
letter-spacing: 0;
}
}
}
.dcloud-radio-group{
display: flex;
flex-wrap: wrap;
.dcloud-radio-wrapper{
width: 33.33%;
margin: 0;
}
}
}

View File

@@ -0,0 +1,42 @@
import { AppContainer } from 'knowdesign';
import * as React from 'react';
import ProduceClientTest from './Produce';
import './index.less';
import TaskTabs from '../TestingConsumer/component/TaskTabs';
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
import { useParams } from 'react-router-dom';
const Produce = () => {
const initial = {
label: '生产',
key: 'tab-1',
closable: false,
tabpane: <ProduceClientTest />,
};
const { clusterId } = useParams<{ clusterId: string }>();
const [global] = AppContainer.useGlobalValue();
const ref: any = React.useRef();
React.useEffect(() => {
AppContainer.eventBus.on('ProduceTopicChange', (args: string) => {
ref.current && ref.current.setTabsTitle && ref.current.setTabsTitle(`生产 ${args}`);
});
}, []);
return (
<>
<div className="breadcrumb">
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Produce', aHref: '' },
]}
/>
</div>
<TaskTabs initial={initial} ref={ref} />
</>
);
};
export default Produce;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { MetricType } from '@src/api';
import TopicHealthCheck from '@src/components/CardBar/TopicHealthCheck';
import DashboardDragChart from '@src/components/DashboardDragChart';
import { AppContainer } from 'knowdesign';
import DBreadcrumb from 'knowdesign/lib/extend/d-breadcrumb';
const TopicDashboard = () => {
const [global] = AppContainer.useGlobalValue();
return (
<>
<div className="breadcrumb" style={{ marginBottom: '10px' }}>
<DBreadcrumb
breadcrumbs={[
{ label: '多集群管理', aHref: '/' },
{ label: global?.clusterInfo?.name, aHref: `/cluster/${global?.clusterInfo?.id}` },
{ label: 'Topic', aHref: `` },
]}
/>
</div>
<TopicHealthCheck />
<DashboardDragChart type={MetricType.Topic} />
</>
);
};
export default TopicDashboard;

View File

@@ -0,0 +1,97 @@
import React, { useState, useEffect } from 'react';
import { ProTable, Utils } from 'knowdesign';
import Api from '@src/api';
import { useParams } from 'react-router-dom';
// import { dealTableRequestParams } from '../../constants/common';
import { getTopicACLsColmns } from './config';
const { request } = Utils;
const TopicACLs = (props: any) => {
const { hashData } = props;
const urlParams = useParams<any>(); // 获取地址栏参数
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [pagination, setPagination] = useState<any>({
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
showTotal: (total: number) => `${total} 条目`,
// locale: {
// items_per_page: '条',
// },
// selectComponentClass: CustomSelect,
});
// 默认排序
const defaultSorter = {
sortField: 'kafkaUser',
sortType: 'asc',
};
// 请求接口获取数据
const genData = async ({ pageNo, pageSize, filters = null, sorter = null }: any) => {
if (urlParams?.clusterId === undefined || hashData?.topicName === undefined) return;
// filters = filters || filteredInfo;
setLoading(true);
const params = {
searchKeywords: props.searchKeywords ? props.searchKeywords.slice(0, 128) : '',
pageNo,
pageSize,
...defaultSorter,
};
request(Api.getTopicACLsList(hashData?.topicName, urlParams?.clusterId), { params })
.then((res: any) => {
setPagination({
current: res.pagination?.pageNo,
pageSize: res.pagination?.pageSize,
total: res.pagination?.total,
});
setData(res?.bizData || []);
setLoading(false);
})
.catch((err) => {
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
// setPagination(pagination);
// const asc = sorter?.order && sorter?.order === 'ascend' ? true : false;
// const sortColumn = sorter.field && toLine(sorter.field);
genData({ pageNo: pagination.current, pageSize: pagination.pageSize });
};
useEffect(() => {
props.positionType === 'ACLs' &&
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, [props.searchKeywords, props.positionType]);
return (
<ProTable
showQueryForm={false}
tableProps={{
showHeader: false,
rowKey: 'path',
loading: loading,
columns: getTopicACLsColmns(),
dataSource: data,
paginationProps: { ...pagination },
attrs: {
// className: 'frameless-table', // 纯无边框表格类名
bordered: false,
onChange: onTableChange,
},
}}
/>
);
};
export default TopicACLs;

View File

@@ -0,0 +1,462 @@
import React, { useCallback } from 'react';
import { useEffect, useState } from 'react';
import { AppContainer, Button, Empty, IconFont, List, Popover, ProTable, Radio, Spin, Utils } from 'knowdesign';
import { CloseOutlined } from '@ant-design/icons';
import api, { MetricType } from '@src/api';
import { useParams } from 'react-router-dom';
import TagsWithHide from '@src/components/TagsWithHide';
import SwitchTab from '@src/components/SwitchTab';
interface PropsType {
hashData: any;
// searchKeywords: string;
}
interface PartitionsSummary {
brokerCount: number;
deadBrokerCount: number;
liveBrokerCount: number;
noLeaderPartitionCount: number;
partitionCount: number;
underReplicatedPartitionCount: number;
}
const PARTITION_DETAIL_METRICS = ['LogEndOffset', 'LogStartOffset', 'Messages', 'LogSize'] as const;
interface PartitionDetail {
partitionId: number;
topicName: string;
leaderBrokerId: number;
assignReplicas: number[];
inSyncReplicas: number[];
latestMetrics: {
clusterPhyId: number;
metrics: {
[K in typeof PARTITION_DETAIL_METRICS[number]]: number;
};
timestamp: number;
};
}
interface BrokersDetail {
live: number;
dead: number;
total: number;
partitionCount: number;
noLeaderPartitionIdList: number[];
underReplicatedPartitionIdList: number[];
brokerPartitionStateList: brokerPartitionState[];
}
interface brokerPartitionState {
alive: boolean;
brokerId: number;
bytesInOneMinuteRate: number;
bytesOutOneMinuteRate: number;
host: string;
replicaList: brokerReplica[];
}
interface brokerReplica {
leaderBrokerId: number;
partitionId: number;
topicName: string;
isLeaderReplace: boolean;
inSync: boolean;
}
function getTranformedBytes(bytes: number) {
const unit = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let isUp = true,
outBytes = bytes;
if (typeof outBytes !== 'number') {
outBytes = Number(outBytes);
if (isNaN(outBytes)) return [outBytes, unit[i]];
}
while (isUp) {
if (outBytes / 1024 >= 1) {
outBytes /= 1024;
i++;
} else {
isUp = false;
}
}
return [outBytes.toFixed(2), unit[i]];
}
const RenderEmpty = (props: { message: string }) => {
const { message } = props;
return (
<>
<div className="empty-panel">
<div className="img" />
<div className="text">{message}</div>
</div>
</>
);
};
const PartitionPopoverContent = (props: {
clusterId: string;
hashData: any;
record: brokerReplica;
brokerId: brokerPartitionState['brokerId'];
close: () => void;
}) => {
const [global] = AppContainer.useGlobalValue();
const { clusterId, hashData, record, brokerId, close } = props;
const [loading, setLoading] = useState<boolean>(true);
const [partitionInfo, setPartitionInfo] = useState<{ label: string; value: string | number }[]>([]);
// 获取单个 Partition 详情
const getDetail = () => {
const { partitionId, leaderBrokerId, inSync } = record;
Utils.request(api.getPartitionMetricInfo(clusterId, hashData.topicName, brokerId, partitionId), {
method: 'POST',
data: PARTITION_DETAIL_METRICS,
}).then((res: any) => {
const type = MetricType.Replication;
const metricsData = res?.metrics || {};
const partitionInfo = [
{ label: 'LeaderBroker', value: leaderBrokerId },
{
label: 'BeginningOffset',
value: `${metricsData.LogStartOffset === undefined ? '-' : metricsData.LogStartOffset} ${global.getMetricDefine(type, 'LogStartOffset')?.unit || ''
}`,
},
{
label: 'EndOffset',
value: `${metricsData.LogEndOffset === undefined ? '-' : metricsData.LogEndOffset} ${global.getMetricDefine(type, 'LogEndOffset')?.unit || ''
}`,
},
{
label: 'MsgNum',
value: `${metricsData.Messages === undefined ? '-' : metricsData.Messages} ${global.getMetricDefine(type, 'Messages')?.unit || ''
}`,
},
{
label: 'LogSize',
value: `${metricsData.LogSize === undefined ? '-' : Utils.formatAssignSize(metricsData.LogSize, 'MB')} MB`,
},
{ label: '是否同步', value: inSync ? '是' : '否' },
];
setPartitionInfo(partitionInfo);
setLoading(false);
});
};
useEffect(() => {
getDetail();
}, []);
return (
<Spin spinning={loading}>
<div className="header">
<div className="title"></div>
<Button
type="text"
icon={<CloseOutlined className="close-icon" />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
close();
}}
/>
</div>
<div className="main">
<List
itemLayout="vertical"
size="small"
dataSource={partitionInfo}
renderItem={(item) => (
<List.Item key={item.label} extra={item.value}>
{item.label}
</List.Item>
)}
/>
</div>
</Spin>
);
};
const PartitionSummary = (props: { clusterId: string; topicName: string }) => {
const { clusterId, topicName } = props;
const [partitionsSummary, setPartitionsSummary] = useState<PartitionsSummary>(null);
useEffect(() => {
// 获取统计信息
Utils.request(api.getTopicPartitionsSummary(clusterId, topicName)).then((res: PartitionsSummary) => {
setPartitionsSummary(res);
});
}, []);
return (
<>
<div className="info-box">
<div className="info-detail info-detail-title">
<span className="desc">Brokers </span>
<span className="num">{partitionsSummary?.brokerCount}</span>
</div>
<div className="info-detail">
<span className="desc">live</span>
<span className="num">{partitionsSummary?.liveBrokerCount}</span>
</div>
<div className="info-detail">
<span className="desc">down</span>
<span className="num">{partitionsSummary?.deadBrokerCount}</span>
</div>
</div>
<div className="info-box">
<div className="info-detail info-detail-title">
<span className="desc">Partition </span>
<span className="num">{partitionsSummary?.partitionCount}</span>
</div>
<div className="info-detail">
<span className="desc">No Leader</span>
<span className="num">{partitionsSummary?.noLeaderPartitionCount}</span>
</div>
<div className="info-detail">
<span className="desc">URP</span>
<span className="num">{partitionsSummary?.underReplicatedPartitionCount}</span>
</div>
</div>
</>
);
};
const PartitionCard = (props: { clusterId: string; hashData: any }) => {
const { clusterId, hashData } = props;
const [brokersDetail, setBrokersDetail] = useState<BrokersDetail>();
const [hoverPartitionId, setHoverPartitionId] = useState<brokerReplica['partitionId']>(-1);
const [clickPartition, setClickPartition] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const closePartitionDetail = useCallback(() => setClickPartition(''), []);
useEffect(() => {
Utils.request(api.getTopicBrokersList(clusterId, hashData.topicName)).then(
(res: any) => {
setBrokersDetail(res);
setLoading(false);
},
() => setLoading(false)
);
}, []);
return (
<Spin spinning={loading}>
<div className="broker-container">
{brokersDetail?.brokerPartitionStateList?.length ? (
brokersDetail?.brokerPartitionStateList.map((partitionState) => {
return (
<div className="broker-container-box" key={partitionState.brokerId}>
<div className="broker-container-box-header">
<div className="header-info">
<div className="label">Broker</div>
<div className="value">{partitionState.brokerId}</div>
</div>
<div className="header-info">
<div className="label">Host ID</div>
<div className="value">{partitionState.host || '-'}</div>
</div>
{['BytesIn', 'BytesOut'].map((type) => {
return (
<div className="header-info" key={type}>
<div className="label">{type}</div>
<div className="value">
{getTranformedBytes(
type === 'BytesIn' ? partitionState.bytesInOneMinuteRate : partitionState.bytesOutOneMinuteRate
).map((val, i) => {
return i ? <span className="unit">{val}/s</span> : <span>{val}</span>;
})}
</div>
</div>
);
})}
</div>
<div className="broker-container-box-detail">
{partitionState.alive ? (
partitionState?.replicaList?.length ? (
<div className="partition-list">
{partitionState?.replicaList?.map((partition) => {
return (
<div
key={partition.partitionId}
className={`partition-list-item partition-list-item-${partition.isLeaderReplace ? 'leader' : partition.inSync ? 'isr' : 'osr'
} ${partition.partitionId === hoverPartitionId ? 'partition-active' : ''}`}
onMouseEnter={() => setHoverPartitionId(partition.partitionId)}
onMouseLeave={() => setHoverPartitionId(-1)}
onClick={() => setClickPartition(`${partitionState.brokerId}&${partition.partitionId}`)}
>
<Popover
visible={clickPartition === `${partitionState.brokerId}&${partition.partitionId}`}
onVisibleChange={(v) => !v && closePartitionDetail()}
overlayClassName="broker-partition-popover"
content={
<PartitionPopoverContent
clusterId={clusterId}
hashData={hashData}
record={partition}
brokerId={partitionState.brokerId}
close={closePartitionDetail}
/>
}
destroyTooltipOnHide={true}
trigger="click"
placement="rightTop"
>
{partition.partitionId}
</Popover>
</div>
);
})}
</div>
) : (
<RenderEmpty message="暂无数据" />
)
) : (
<RenderEmpty message="暂无数据" />
)}
</div>
</div>
);
})
) : loading ? (
<></>
) : (
<Empty style={{ margin: '0 auto', marginTop: 100 }} />
)}
</div>
</Spin>
);
};
const PartitionTable = (props: { clusterId: string; hashData: any }) => {
const { clusterId, hashData } = props;
const [loading, setLoading] = useState<boolean>(true);
const [partitionsDetail, setPartitionsDetail] = useState<PartitionDetail[]>([]);
const columns = [
{
title: 'Partition ID',
dataIndex: 'partitionId',
},
{
title: 'StartOffset',
dataIndex: ['latestMetrics', 'metrics', 'LogStartOffset'],
},
{
title: 'EndOffset',
dataIndex: ['latestMetrics', 'metrics', 'LogEndOffset'],
},
{
title: 'MsgNum',
dataIndex: ['latestMetrics', 'metrics', 'Messages'],
},
{
title: 'Leader Broker',
dataIndex: 'leaderBrokerId',
},
{
title: 'LogSize(MB)',
dataIndex: ['latestMetrics', 'metrics', 'LogSize'],
render: (size: number | undefined) => (size === undefined ? '-' : Utils.formatAssignSize(size, 'MB')),
},
{
title: 'AR',
dataIndex: 'assignReplicas',
width: 180,
render: (arr: PartitionDetail['assignReplicas']) => <TagsWithHide list={arr} expandTagContent={(len) => `共有${len}`} />,
},
{
title: 'ISR',
dataIndex: 'inSyncReplicas',
width: 180,
render: (arr: PartitionDetail['inSyncReplicas']) => <TagsWithHide list={arr} expandTagContent={(len) => `共有${len}`} />,
},
];
useEffect(() => {
Utils.request(api.getTopicPartitionsDetail(clusterId, hashData.topicName), {
method: 'POST',
data: PARTITION_DETAIL_METRICS,
}).then((res: PartitionDetail[]) => {
setPartitionsDetail(res);
setLoading(false);
});
}, []);
return (
<ProTable
tableProps={{
loading,
rowKey: 'partitionId',
columns: columns as any,
dataSource: partitionsDetail,
showHeader: false,
}}
/>
);
};
export default (props: PropsType) => {
const { clusterId } = useParams<{ clusterId: string }>();
const { hashData } = props;
const [showMode, setShowMode] = useState<string>('card');
return (
<>
<div className="brokers-tab-container">
<div className="overview">
<div className="left">
<PartitionSummary clusterId={clusterId} topicName={hashData.topicName} />
</div>
<div className="cases-box">
{showMode === 'card' && (
<div className="broker-cases">
<div className="case case-leader">
<div className="icon"></div>
<div className="desc">Leader</div>
</div>
<div className="case case-isr">
<div className="icon"></div>
<div className="desc">ISR</div>
</div>
<div className="case case-osr">
<div className="icon"></div>
<div className="desc">OSR</div>
</div>
</div>
)}
<SwitchTab
defaultKey="card"
onChange={(key) => {
setShowMode(key);
}}
>
<SwitchTab.TabItem key="card">
<div style={{ width: 34, height: 23 }}>
<IconFont type="icon-tubiao" />
</div>
</SwitchTab.TabItem>
<SwitchTab.TabItem key="table">
<div style={{ width: 34, height: 23 }}>
<IconFont type="icon-biaoge" />
</div>
</SwitchTab.TabItem>
</SwitchTab>
</div>
</div>
{showMode === 'card' ? (
<PartitionCard clusterId={clusterId} hashData={hashData} />
) : (
<PartitionTable clusterId={clusterId} hashData={hashData} />
)}
</div>
</>
);
};

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react';
import { AppContainer, Checkbox, ProTable, Utils } from 'knowdesign';
import { useParams } from 'react-router-dom';
import Api from '@src/api';
import { getTopicConfigurationColmns } from './config';
import { ConfigurationEdit } from './ConfigurationEdit';
import { ClustersPermissionMap } from '../CommonConfig';
const { request } = Utils;
const BrokerConfiguration = (props: any) => {
const { hashData } = props;
const urlParams = useParams<any>(); // 获取地址栏参数
const [global] = AppContainer.useGlobalValue();
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
// const [filterType, setFilterType] = useState<number>(0); // 多选框的筛选结果 filterType
const [checkedBoxList, setCheckedBoxList] = useState<string[]>([]); // 多选框的选中的列表
const [pagination, setPagination] = useState<any>({
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
showTotal: (total: number) => `${total} 条目`,
});
const [editVisible, setEditVisible] = useState(false);
const [record, setRecord] = useState(null); // 获取当前点击行的数据;
const [readOnlyVisible, setReadOnlyVisible] = useState(null);
const [readOnlyRecord, setReadOnlyRecord] = useState(null);
// 请求接口获取数据
const genData = async ({ pageNo, pageSize }: any) => {
if (urlParams?.clusterId === undefined || hashData?.topicName === undefined) return;
setLoading(true);
// const params = dealTableRequestParams({
// searchKeywords: props.searchKeywords ? props.searchKeywords : '',
// pageNo,
// pageSize,
// });
const params = {
searchKeywords: props.searchKeywords ? props.searchKeywords.slice(0, 128) : undefined,
pageNo,
pageSize,
preciseFilterDTOList:
checkedBoxList.length > 0
? checkedBoxList.map((item) => {
return {
fieldName: item,
fieldValueList: [item === 'readOnly' ? false : true],
include: true,
};
})
: undefined,
};
request(Api.getTopicConfigs(hashData?.topicName, urlParams?.clusterId), { data: params, method: 'POST' })
.then((res: any) => {
setPagination({
current: res.pagination?.pageNo,
pageSize: res.pagination?.pageSize,
total: res.pagination?.total,
});
setData(res?.bizData || []);
})
.finally(() => {
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
// setPagination(pagination);
// const asc = sorter?.order && sorter?.order === 'ascend' ? true : false;
// const sortColumn = sorter.field && toLine(sorter.field);
genData({ pageNo: pagination.current, pageSize: pagination.pageSize });
};
// 多选配置
const checkedBoxOptions = [
{ label: 'Hide read-only', value: 'readOnly' },
{ label: 'Show Overrides Only', value: 'override' },
];
const checkedBoxChange = (e: any) => {
// 通过checked转换filterType
// const newfilterType =
// e.includes('readOnly') && e.includes('override')
// ? 0
// : e.includes('readOnly') && !e.includes('override')
// ? 1
// : !e.includes('readOnly') && e.includes('override')
// ? 2
// : 3;
// setFilterType(newfilterType);
setCheckedBoxList(e);
// 调用接口
};
const setEditOp = (record: any) => {
setEditVisible(true);
setRecord(record);
};
useEffect(() => {
props.positionType === 'Configuration' &&
genData({
pageNo: 1,
pageSize: pagination.pageSize,
// sorter: defaultSorter
});
}, [props.searchKeywords, checkedBoxList]);
return (
<>
<div className={'detail-header-cases'} style={{ padding: '0 0 12px' }}>
<Checkbox.Group options={checkedBoxOptions} value={checkedBoxList} onChange={checkedBoxChange} />
</div>
<ProTable
showQueryForm={false}
tableProps={{
showHeader: false,
rowKey: 'path',
loading: loading,
columns: getTopicConfigurationColmns({
setEditOp,
readOnlyRecord,
readOnlyVisible,
allowEdit: global.hasPermission && global.hasPermission(ClustersPermissionMap.TOPIC_CHANGE_CONFIG),
}),
dataSource: data,
paginationProps: { ...pagination },
attrs: {
// className: 'frameless-table', // 纯无边框表格类名
bordered: false,
onChange: onTableChange,
onRow: (record: any) => {
if (!!record?.readOnly) {
return {
onMouseEnter: () => {
setReadOnlyVisible(true);
setReadOnlyRecord(record);
},
onMouseLeave: () => {
setReadOnlyVisible(false);
setReadOnlyRecord(null);
},
};
}
return {};
},
},
}}
/>
<ConfigurationEdit record={record} hashData={hashData} visible={editVisible} setVisible={setEditVisible} genData={genData} />
</>
);
};
export default BrokerConfiguration;

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { Drawer, Form, Input, Space, Button, Utils, Row, Col, IconFont, Divider, message } from 'knowdesign';
import { useParams } from 'react-router-dom';
import Api from '@src/api';
export const ConfigurationEdit = (props: any) => {
const urlParams = useParams<any>();
const [form] = Form.useForm();
const onClose = () => {
props.setVisible(false);
};
const onOk = () => {
form.validateFields().then((res: any) => {
const data = {
changedProps: {
[props.record?.name]: res.newValue,
// value: res.newValue,
},
clusterId: Number(urlParams.clusterId),
topicName: props.hashData?.topicName,
};
Utils.put(Api.getTopicEditConfig(), data)
.then((res: any) => {
message.success('编辑配置成功');
props.setVisible(false);
props.genData({ pageNo: 1, pageSize: 10 });
})
.catch((err: any) => { });
});
};
return (
<Drawer
title={
<Space size={0}>
<Button className="drawer-title-left-button" type="text" size="small" icon={<IconFont type="icon-fanhui1" />} onClick={onClose} />
<Divider type="vertical" />
<span style={{ paddingLeft: '5px' }}></span>
</Space>
}
width={580}
visible={props.visible}
onClose={() => props.setVisible(false)}
maskClosable={false}
extra={
<Space>
<Button size="small" onClick={onClose}>
</Button>
<Button size="small" type="primary" onClick={onOk}>
</Button>
<div
style={{
width: '1px',
height: '17px',
background: 'rgba(0, 0, 0, 0.13)',
margin: '0 16px 0 10px',
}}
></div>
</Space>
}
>
<Row gutter={[12, 12]} className="desc-row">
<Col span={3} className="label-col">
:
</Col>
<Col span={21} className="value-col">
{props.record?.name || '-'}
</Col>
<Col span={3} className="label-col">
:
</Col>
<Col span={21} className="value-col">
{props.record?.documentation || '-'}
</Col>
</Row>
<Form form={form} layout={'vertical'} initialValues={props.record}>
<Form.Item name="defaultValue" label="Kafka默认配置">
<Input disabled />
</Form.Item>
<Form.Item name="value" label="当前配置">
<Input disabled />
</Form.Item>
<Form.Item name="newValue" label="新配置" rules={[{ required: true, message: '请输入新的配置值!!!' }]}>
<Input />
</Form.Item>
{/* <Form.Item name="applyAll" valuePropName="checked">
<Checkbox>应用到全部Broker</Checkbox>
</Form.Item> */}
</Form>
</Drawer>
);
};

View File

@@ -0,0 +1,135 @@
import React, { useState, useEffect } from 'react';
import { ProTable, Utils } from 'knowdesign';
import Api from '@src/api';
const { request } = Utils;
const getColmns = (solveClick: any) => {
const columns = [
{
title: 'ConsumerGroup',
dataIndex: 'ConsumerGroup',
key: 'ConsumerGroup',
},
{
title: '关联KafkaUser',
dataIndex: 'kafkaUser',
key: 'kafkaUser',
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
},
{
title: 'Max Lag',
dataIndex: 'maxLag',
key: 'maxLag',
},
{
title: 'Member数',
dataIndex: 'member',
key: 'member',
},
{
title: '操作',
dataIndex: 'option',
key: 'option',
// eslint-disable-next-line react/display-name
render: (_t: any, r: any) => {
return <a onClick={() => solveClick(r)}></a>;
},
},
];
return columns;
};
const TopicGroup = (props: any) => {
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [pagination, setPagination] = useState<any>({
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100', '200', '500'],
showTotal: (total: number) => `${total} 条目`,
// locale: {
// items_per_page: '条',
// },
// selectComponentClass: CustomSelect,
});
const solveClick = (record: any) => {};
// 请求接口获取数据
const genData = async ({ pageNo, pageSize, filters = null, sorter = null }: any) => {
// if (clusterId === undefined) return;
// filters = filters || filteredInfo;
setLoading(true);
// const params = dealTableRequestParams({ searchKeywords, pageNo, pageSize, sorter, filters, isPhyId: true });
const params = {
filterKey: 'string',
filterPartitionId: 1,
filterValue: 'string',
maxRecords: 100,
pullTimeoutUnitMs: 10000,
truncate: true,
};
request(Api.getTopicMessagesList('你好', 2), { params })
.then((res: any) => {
setPagination({
current: res.pagination?.pageNo,
pageSize: res.pagination?.pageSize,
total: res.pagination?.total,
});
setData(res?.bizData || []);
setLoading(false);
})
.catch((err) => {
setPagination({
current: 1,
pageSize: 10,
total: 20,
});
setLoading(false);
});
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
setPagination(pagination);
// const asc = sorter?.order && sorter?.order === 'ascend' ? true : false;
// const sortColumn = sorter.field && toLine(sorter.field);
// genData({ pageNo: pagination.current, pageSize: pagination.pageSize, filters, asc, sortColumn, queryTerm: searchResult, ...allParams });
};
// useEffect(() => {
// genData({
// pageNo: 1,
// pageSize: pagination.pageSize,
// // sorter: defaultSorter
// });
// }, [props]);
return (
<ProTable
showQueryForm={false}
tableProps={{
showHeader: false,
rowKey: 'path',
loading: loading,
columns: getColmns(solveClick),
dataSource: data,
paginationProps: { ...pagination },
attrs: {
// className: 'frameless-table', // 纯无边框表格类名
bordered: false,
onChange: onTableChange,
},
}}
/>
);
};
export default TopicGroup;

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
import { Alert, Button, Checkbox, Form, IconFont, Input, ProTable, Select, Tooltip, Utils } from 'knowdesign';
import Api from '@src/api';
import { useParams, useHistory } from 'react-router-dom';
import { getTopicMessagesColmns } from './config';
const { request } = Utils;
const defaultParams: any = {
truncate: true,
maxRecords: 100,
pullTimeoutUnitMs: 8000,
// filterPartitionId: 1,
};
const defaultpaPagination = {
current: 1,
pageSize: 10,
position: 'bottomRight',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
};
const TopicMessages = (props: any) => {
const { hashData } = props;
const urlParams = useParams<any>(); // 获取地址栏参数
const history = useHistory();
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [params, setParams] = useState(defaultParams);
const [partitionIdList, setPartitionIdList] = useState([]);
const [pagination, setPagination] = useState<any>(defaultpaPagination);
const [form] = Form.useForm();
// 默认排序
const defaultSorter = {
sortField: 'timestampUnitMs',
sortType: 'desc',
};
// 请求接口获取数据
const genData = async () => {
if (urlParams?.clusterId === undefined || hashData?.topicName === undefined) return;
setLoading(true);
request(Api.getTopicMessagesMetadata(hashData?.topicName, urlParams?.clusterId)).then((res: any) => {
// console.log(res, 'metadata');
const newPartitionIdList = res?.partitionIdList.map((item: any) => {
return {
label: item,
value: item,
};
});
setPartitionIdList(newPartitionIdList || []);
});
request(Api.getTopicMessagesList(hashData?.topicName, urlParams?.clusterId), { data: { ...params, ...defaultSorter }, method: 'POST' })
.then((res: any) => {
// setPagination({
// current: res.pagination?.pageNo,
// pageSize: res.pagination?.pageSize,
// total: res.pagination?.total,
// });
setData(res || []);
setLoading(false);
})
.catch((err) => {
setLoading(false);
});
};
// 查询
const onFinish = (formData: any) => {
setParams({ ...params, ...formData, filterKey: formData?.filterKey ? formData?.filterKey : undefined });
};
// 截断
const checkBoxChange = (e: any) => {
setParams({ ...params, truncate: e.target.checked });
};
// 刷新
const refreshClick = () => {
// genData();
// form.resetFields();
setPagination(defaultpaPagination);
genData();
};
// 跳转Consume
const jumpConsume = () => {
history.push(`/cluster/${urlParams?.clusterId}/testing/consumer`);
};
const onTableChange = (pagination: any, filters: any, sorter: any) => {
setPagination(pagination);
// const asc = sorter?.order && sorter?.order === 'ascend' ? true : false;
// const sortColumn = sorter.field && toLine(sorter.field);
// genData({ pageNo: pagination.current, pageSize: pagination.pageSize, filters, asc, sortColumn, queryTerm: searchResult, ...allParams });
};
useEffect(() => {
props.positionType === 'Messages' && genData();
}, [props, params]);
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span
style={{ display: 'inline-block', padding: '0 10px', marginRight: '10px', borderRight: '1px solid #ccc', fontSize: '13px' }}
onClick={refreshClick}
>
<i className="iconfont icon-shuaxin1" style={{ fontSize: '13px', cursor: 'pointer' }} />
</span>
<span style={{ fontSize: '13px' }}>
<Checkbox checked={params.truncate} onChange={checkBoxChange}>
</Checkbox>
</span>
<Tooltip title={'截断数据后只展示前1024字符的数据'}>
<IconFont style={{ fontSize: '14px' }} type="icon-zhushi" />
</Tooltip>
</div>
<div className="messages-query">
<Form form={form} layout="inline" onFinish={onFinish}>
<Form.Item name="filterPartitionId">
<Select
options={partitionIdList}
size="small"
style={{ width: '140px' }}
className={'detail-table-select'}
placeholder="请选择分区"
/>
</Form.Item>
<Form.Item name="filterKey">
<Input size="small" style={{ width: '140px' }} className={'detail-table-input'} placeholder="请输入key" />
</Form.Item>
<Form.Item name="filterValue">
<Input size="small" style={{ width: '140px' }} className={'detail-table-input'} placeholder="请输入value" />
</Form.Item>
<Form.Item>
<Button size="small" type="primary" ghost htmlType="submit">
</Button>
</Form.Item>
</Form>
</div>
</div>
<div>
<Alert
style={{ margin: '12px 0 4px', padding: '7px 12px', background: '#FFF9E6' }}
message={
<div>
Topic最近的100条messagesmessages<a onClick={jumpConsume}>Produce&Consume</a>
</div>
}
type="warning"
closable
/>
</div>
<ProTable
showQueryForm={false}
tableProps={{
showHeader: false,
rowKey: 'path',
loading: loading,
columns: getTopicMessagesColmns(),
dataSource: data,
paginationProps: pagination,
// noPagination: true,
attrs: {
// className: 'frameless-table', // 纯无边框表格类名
bordered: false,
onChange: onTableChange,
scroll: { x: 'max-content' },
},
}}
/>
</>
);
};
export default TopicMessages;

Some files were not shown because too many files have changed in this diff Show More