mirror of https://github.com/openspug/spug
Merge c1cb8e525d
into 0e7b5ec77e
commit
7f73b45eec
|
@ -17,4 +17,6 @@ urlpatterns = [
|
||||||
path('import/region/', get_regions),
|
path('import/region/', get_regions),
|
||||||
path('parse/', post_parse),
|
path('parse/', post_parse),
|
||||||
path('valid/', batch_valid),
|
path('valid/', batch_valid),
|
||||||
|
path('processes/', get_processes),
|
||||||
|
path('ports/', get_ports),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,20 +1,22 @@
|
||||||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||||
# Copyright: (c) <spug.dev@gmail.com>
|
# Copyright: (c) <spug.dev@gmail.com>
|
||||||
# Released under the AGPL-3.0 License.
|
# Released under the AGPL-3.0 License.
|
||||||
from django_redis import get_redis_connection
|
|
||||||
from libs.helper import make_ali_request, make_tencent_request
|
|
||||||
from libs.ssh import SSH, AuthenticationException
|
|
||||||
from libs.utils import AttrDict, human_datetime
|
|
||||||
from libs.validators import ip_validator
|
|
||||||
from apps.host.models import HostExtend
|
|
||||||
from apps.setting.utils import AppSetting
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from concurrent import futures
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from concurrent import futures
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
|
||||||
|
from apps.host.models import HostExtend
|
||||||
|
from apps.setting.utils import AppSetting
|
||||||
|
from libs.helper import make_ali_request, make_tencent_request
|
||||||
|
from libs.ssh import SSH, AuthenticationException
|
||||||
|
from libs.utils import AttrDict, human_datetime
|
||||||
|
from libs.validators import ip_validator
|
||||||
|
|
||||||
|
|
||||||
def check_os_type(os_name):
|
def check_os_type(os_name):
|
||||||
|
@ -201,7 +203,7 @@ def fetch_host_extend(ssh):
|
||||||
code, out = ssh.exec_command_raw('hostname -I')
|
code, out = ssh.exec_command_raw('hostname -I')
|
||||||
if code == 0:
|
if code == 0:
|
||||||
for ip in out.strip().split():
|
for ip in out.strip().split():
|
||||||
if len(ip) > 15: # ignore ipv6
|
if len(ip) > 15: # ignore ipv6
|
||||||
continue
|
continue
|
||||||
if ipaddress.ip_address(ip).is_global:
|
if ipaddress.ip_address(ip).is_global:
|
||||||
if len(public_ip_address) < 10:
|
if len(public_ip_address) < 10:
|
||||||
|
@ -301,3 +303,51 @@ def _get_ssh(kwargs, pkey=None, private_key=None, public_key=None, password=None
|
||||||
ssh.add_public_key(public_key)
|
ssh.add_public_key(public_key)
|
||||||
return _get_ssh(kwargs, private_key)
|
return _get_ssh(kwargs, private_key)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_host_process(host, private_key=None, public_key=None, password=None, ssh=None):
|
||||||
|
if not ssh:
|
||||||
|
kwargs = host.to_dict(selects=('hostname', 'port', 'username'))
|
||||||
|
with _get_ssh(kwargs, host.pkey, private_key, public_key, password) as ssh:
|
||||||
|
return _sync_host_process(host, ssh=ssh)
|
||||||
|
process_list = fetch_host_processes(ssh)
|
||||||
|
return process_list
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_host_processes(ssh):
|
||||||
|
command = '''ps_info=$(ps -e -o pid=,comm=,ppid=,user=,%cpu=,%mem=,rss,uid= --no-headers); echo -n '['; while read -r line; do read pid name ppid username cpu_usage memory_usage memory uid <<<$(echo $line); if [ -e \"/proc/${pid}/cmdline\" ]; then command=$(tr '\\0' ' ' <\"/proc/${pid}/cmdline\" | awk '{$1=$1};1'| sed 's/\\\\/\\\\\\\\/g' | sed 's/\"/\\\\\"/g' 2>/dev/null); start_time=$(stat -c %Y \"/proc/${pid}\" 2>/dev/null); echo \"{\\\"name\\\":\\\"${name}\\\",\\\"pid\\\":${pid},\\\"ppid\\\":${ppid},\\\"username\\\":\\\"${username}\\\",\\\"uid\\\":${uid},\\\"start_time\\\":${start_time},\\\"cpu_usage\\\":\\\"${cpu_usage}\\\",\\\"memory_usage\\\":\\\"${memory_usage}\\\",\\\"memory\\\":\\\"${memory}\\\",\\\"command\\\":\\\"${command}\\\"},\"; fi; done <<<\"$ps_info\" | sed '$s/,$/]/';'''
|
||||||
|
code, out = ssh.exec_command(command)
|
||||||
|
if code == 0:
|
||||||
|
try:
|
||||||
|
_j = json.loads(out.strip())
|
||||||
|
return _j
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
print(out)
|
||||||
|
elif code != 0:
|
||||||
|
print(code, out)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_host_ports(host, private_key=None, public_key=None, password=None, ssh=None):
|
||||||
|
if not ssh:
|
||||||
|
kwargs = host.to_dict(selects=('hostname', 'port', 'username'))
|
||||||
|
with _get_ssh(kwargs, host.pkey, private_key, public_key, password) as ssh:
|
||||||
|
return _sync_host_ports(host, ssh=ssh)
|
||||||
|
ports_list = fetch_host_ports(ssh)
|
||||||
|
return ports_list
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_host_ports(ssh):
|
||||||
|
command = '''netstat -nltp | awk 'NR>2 {cmd=\"netstat -n | grep -c \\\"\"$4\"\\\"\"; cmd | getline conn_count; close(cmd); printf \"{\\\"protocol\\\":\\\"%s\\\",\\\"listen\\\":\\\"%s\\\",\\\"pid\\\":\\\"%s\\\",\\\"connections\\\":\\\"%s\\\"},\", $1, $4, $7, conn_count}' | sed 's/,$/]/' | awk 'BEGIN {printf\"[\"} {print}' '''
|
||||||
|
code, out = ssh.exec_command(command)
|
||||||
|
if code == 0:
|
||||||
|
try:
|
||||||
|
_j = json.loads(out.strip())
|
||||||
|
return _j
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
print(out)
|
||||||
|
elif code != 0:
|
||||||
|
print(code, out)
|
||||||
|
return []
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.db.models import F
|
||||||
from django.http.response import HttpResponseBadRequest
|
from django.http.response import HttpResponseBadRequest
|
||||||
from libs import json_response, JsonParser, Argument, AttrDict, auth
|
from libs import json_response, JsonParser, Argument, AttrDict, auth
|
||||||
from apps.setting.utils import AppSetting
|
from apps.setting.utils import AppSetting
|
||||||
from apps.account.utils import get_host_perms
|
from apps.account.utils import get_host_perms, has_host_perm
|
||||||
from apps.host.models import Host, Group
|
from apps.host.models import Host, Group
|
||||||
from apps.host.utils import batch_sync_host, _sync_host_extend
|
from apps.host.utils import batch_sync_host, _sync_host_extend
|
||||||
from apps.exec.models import ExecTemplate
|
from apps.exec.models import ExecTemplate
|
||||||
|
@ -230,3 +230,45 @@ def _do_host_verify(form):
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
raise Exception('连接主机超时,请检查网络')
|
raise Exception('连接主机超时,请检查网络')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@auth('host.host.view')
|
||||||
|
def get_processes(request):
|
||||||
|
form, error = JsonParser(
|
||||||
|
Argument('host_id', type=int, help='参数错误'),
|
||||||
|
).parse(request.body)
|
||||||
|
if error is None:
|
||||||
|
if not has_host_perm(request.user, form.host_id):
|
||||||
|
return json_response(error='无权访问主机,请联系管理员')
|
||||||
|
private_key, public_key = AppSetting.get_ssh_key()
|
||||||
|
host = Host.objects.filter(id=form.host_id).first()
|
||||||
|
if host.is_verified:
|
||||||
|
try:
|
||||||
|
result = _sync_host_process(host=host, private_key=private_key)
|
||||||
|
except socket.timeout:
|
||||||
|
return json_response(error='连接主机超时,请检查网络')
|
||||||
|
return json_response(result)
|
||||||
|
else:
|
||||||
|
return json_response(error='该主机未验证,请先验证')
|
||||||
|
return json_response(error=error)
|
||||||
|
|
||||||
|
|
||||||
|
@auth('host.host.view')
|
||||||
|
def get_ports(request):
|
||||||
|
form, error = JsonParser(
|
||||||
|
Argument('host_id', type=int, help='参数错误'),
|
||||||
|
).parse(request.body)
|
||||||
|
if error is None:
|
||||||
|
if not has_host_perm(request.user, form.host_id):
|
||||||
|
return json_response(error='无权访问主机,请联系管理员')
|
||||||
|
private_key, public_key = AppSetting.get_ssh_key()
|
||||||
|
host = Host.objects.filter(id=form.host_id).first()
|
||||||
|
if host.is_verified:
|
||||||
|
try:
|
||||||
|
result = _sync_host_ports(host=host, private_key=private_key)
|
||||||
|
except socket.timeout:
|
||||||
|
return json_response(error='连接主机超时,请检查网络')
|
||||||
|
return json_response(result)
|
||||||
|
else:
|
||||||
|
return json_response(error='该主机未验证,请先验证')
|
||||||
|
return json_response(error=error)
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { Drawer, Descriptions, List, Button, Input, Select, DatePicker, Tag, message } from 'antd';
|
import {Drawer, Descriptions, List, Button, Input, Select, DatePicker, Tag, message, Tabs} from 'antd';
|
||||||
import { EditOutlined, SaveOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons';
|
import { EditOutlined, SaveOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
import { AuthButton } from 'components';
|
import { AuthButton } from 'components';
|
||||||
import { http } from 'libs';
|
import { http } from 'libs';
|
||||||
|
@ -13,7 +13,8 @@ import store from './store';
|
||||||
import lds from 'lodash';
|
import lds from 'lodash';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import styles from './index.module.less';
|
import styles from './index.module.less';
|
||||||
|
import ProcessesTable from "./Processes";
|
||||||
|
import PortsTable from "./Ports";
|
||||||
export default observer(function () {
|
export default observer(function () {
|
||||||
const [edit, setEdit] = useState(false);
|
const [edit, setEdit] = useState(false);
|
||||||
const [host, setHost] = useState(store.record);
|
const [host, setHost] = useState(store.record);
|
||||||
|
@ -110,7 +111,7 @@ export default observer(function () {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
width={550}
|
width={1500}
|
||||||
title={host.name}
|
title={host.name}
|
||||||
placement="right"
|
placement="right"
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
|
@ -118,9 +119,10 @@ export default observer(function () {
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
size="small"
|
size="small"
|
||||||
labelStyle={{width: 150}}
|
// labelStyle={{width: 150}}
|
||||||
title={<span style={{fontWeight: 500}}>基本信息</span>}
|
title={<span style={{fontWeight: 500}}>基本信息</span>}
|
||||||
column={1}>
|
// column={1}
|
||||||
|
>
|
||||||
<Descriptions.Item label="主机名称">{host.name}</Descriptions.Item>
|
<Descriptions.Item label="主机名称">{host.name}</Descriptions.Item>
|
||||||
<Descriptions.Item label="连接地址">{host.username}@{host.hostname}</Descriptions.Item>
|
<Descriptions.Item label="连接地址">{host.username}@{host.hostname}</Descriptions.Item>
|
||||||
<Descriptions.Item label="连接端口">{host.port}</Descriptions.Item>
|
<Descriptions.Item label="连接端口">{host.port}</Descriptions.Item>
|
||||||
|
@ -137,9 +139,9 @@ export default observer(function () {
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
size="small"
|
size="small"
|
||||||
column={1}
|
// column={1}
|
||||||
className={edit ? styles.hostExtendEdit : null}
|
className={edit ? styles.hostExtendEdit : null}
|
||||||
labelStyle={{width: 150}}
|
// labelStyle={{width: 150}}
|
||||||
style={{marginTop: 24}}
|
style={{marginTop: 24}}
|
||||||
extra={edit ? ([
|
extra={edit ? ([
|
||||||
<Button key="1" type="link" loading={fetching} icon={<SyncOutlined/>} onClick={handleFetch}>同步</Button>,
|
<Button key="1" type="link" loading={fetching} icon={<SyncOutlined/>} onClick={handleFetch}>同步</Button>,
|
||||||
|
@ -270,6 +272,16 @@ export default observer(function () {
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="更新时间">{host.updated_at}</Descriptions.Item>
|
<Descriptions.Item label="更新时间">{host.updated_at}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
{host.id !== undefined && store.detailVisible ? (
|
||||||
|
<Tabs>
|
||||||
|
<Tabs.TabPane tab="进程清单" key="item-1">
|
||||||
|
<ProcessesTable host_id={store.record.id}/>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane tab="网络端口" key="item-2">
|
||||||
|
<PortsTable host_id={store.record.id}/>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
)
|
||||||
})
|
})
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||||
|
* Copyright (c) <spug.dev@gmail.com>
|
||||||
|
* Released under the AGPL-3.0 License.
|
||||||
|
*/
|
||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {Input, Table} from 'antd';
|
||||||
|
import {TableCard} from 'components';
|
||||||
|
import {observer} from "mobx-react";
|
||||||
|
import {http} from "../../libs";
|
||||||
|
|
||||||
|
export default observer(function PortsTable(value) {
|
||||||
|
let [portFetching, setPortFetching] = useState(false)
|
||||||
|
let [dataSource, setDataSource] = useState([])
|
||||||
|
let [searchText, setSearchText] = useState(''); // 新增搜索文本状态
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPorts(value.host_id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function fetchPorts() {
|
||||||
|
console.log("host_id:" + value.host_id, "portFetching:" + portFetching)
|
||||||
|
setPortFetching(true);
|
||||||
|
return http.post('/api/host/ports/', {
|
||||||
|
'host_id': value.host_id
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
setDataSource(res)
|
||||||
|
})
|
||||||
|
.finally(() => setPortFetching(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(value) {
|
||||||
|
setSearchText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredDataSource = dataSource.filter(item =>
|
||||||
|
item.listen.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.pid.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (<TableCard
|
||||||
|
tKey="mi"
|
||||||
|
rowKey="id"
|
||||||
|
title={<Input.Search allowClear value={searchText} placeholder="输入端口/PID检索" style={{maxWidth: 250}}
|
||||||
|
onChange={e => handleSearch(e.target.value)}/>}
|
||||||
|
loading={portFetching}
|
||||||
|
dataSource={filteredDataSource}
|
||||||
|
onReload={fetchPorts}
|
||||||
|
pagination={{
|
||||||
|
showSizeChanger: true,
|
||||||
|
showLessItems: true,
|
||||||
|
showTotal: total => `共 ${total} 条`,
|
||||||
|
defaultPageSize: 50,
|
||||||
|
pageSizeOptions: ['50', '100']
|
||||||
|
}}
|
||||||
|
scroll={{
|
||||||
|
y: 240,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Column title="协议" dataIndex="protocol"/>
|
||||||
|
<Table.Column title="监听地址" dataIndex="listen"/>
|
||||||
|
<Table.Column title="连接数" dataIndex="connections"
|
||||||
|
sorter={(a, b) => a.connections.localeCompare(b.connections)}
|
||||||
|
sortDirections={['descend']}
|
||||||
|
defaultSortOrder="descend"
|
||||||
|
/>
|
||||||
|
<Table.Column title="PID/进程名" dataIndex="pid"
|
||||||
|
/>
|
||||||
|
</TableCard>)
|
||||||
|
})
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||||
|
* Copyright (c) <spug.dev@gmail.com>
|
||||||
|
* Released under the AGPL-3.0 License.
|
||||||
|
*/
|
||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {Input, Table} from 'antd';
|
||||||
|
import {TableCard} from 'components';
|
||||||
|
import {observer} from "mobx-react";
|
||||||
|
import {http} from "../../libs";
|
||||||
|
|
||||||
|
export default observer(function ProcessesTable(value) {
|
||||||
|
let [processFetching, setProcessFetching] = useState(false)
|
||||||
|
let [dataSource, setDataSource] = useState([])
|
||||||
|
let [searchText, setSearchText] = useState(''); // 新增搜索文本状态
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProcesses(value.host_id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function TimestampConverter(timestampInSeconds) {
|
||||||
|
const date = new Date(timestampInSeconds * 1000);
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = ('0' + (date.getMonth() + 1)).slice(-2);
|
||||||
|
const day = ('0' + date.getDate()).slice(-2);
|
||||||
|
const hours = ('0' + date.getHours()).slice(-2);
|
||||||
|
const minutes = ('0' + date.getMinutes()).slice(-2);
|
||||||
|
const seconds = ('0' + date.getSeconds()).slice(-2);
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function formatMemory(memoryUsed) {
|
||||||
|
|
||||||
|
if (memoryUsed >= 1024) {
|
||||||
|
return (memoryUsed / 1024).toFixed(2) + ' MB';
|
||||||
|
} else if (memoryUsed >= 0) {
|
||||||
|
return (memoryUsed) + ' KB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchProcesses() {
|
||||||
|
console.log("host_id:" + value.host_id, "processFetching:" + processFetching)
|
||||||
|
setProcessFetching(true);
|
||||||
|
return http.post('/api/host/processes/', {
|
||||||
|
'host_id': value.host_id
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
setDataSource(res)
|
||||||
|
})
|
||||||
|
.finally(() => setProcessFetching(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(value) {
|
||||||
|
setSearchText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredDataSource = dataSource.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.pid.toString().includes(searchText.toLowerCase()) ||
|
||||||
|
item.command.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (<TableCard
|
||||||
|
tKey="mi"
|
||||||
|
rowKey="id"
|
||||||
|
title={<Input.Search allowClear value={searchText} placeholder="输入进程名/PID/命令检索" style={{maxWidth: 250}}
|
||||||
|
onChange={e => handleSearch(e.target.value)}/>}
|
||||||
|
loading={processFetching}
|
||||||
|
dataSource={filteredDataSource}
|
||||||
|
onReload={fetchProcesses}
|
||||||
|
pagination={{
|
||||||
|
showSizeChanger: true,
|
||||||
|
showLessItems: true,
|
||||||
|
showTotal: total => `共 ${total} 条`,
|
||||||
|
defaultPageSize: 50,
|
||||||
|
pageSizeOptions: ['50', '100']
|
||||||
|
}}
|
||||||
|
scroll={{
|
||||||
|
y: 240,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Column title="进程名 / PID / PPID" width={200}
|
||||||
|
render={info => `${info.name} / ${info.pid} / ${info.ppid}`}/>
|
||||||
|
<Table.Column title="用户 / UID" width={100}
|
||||||
|
render={info => `${info.username} / ${info.uid}`}/>
|
||||||
|
<Table.Column title="启动时间" width={200}
|
||||||
|
render={info => `${TimestampConverter(info.start_time)}`}/>
|
||||||
|
<Table.Column title="CPU" width={100}
|
||||||
|
render={info => `${info.cpu_usage}%`}
|
||||||
|
sorter={(a, b) => a.cpu_usage.localeCompare(b.cpu_usage)}
|
||||||
|
defaultSortOrder="descend"
|
||||||
|
sortDirections={['descend']}
|
||||||
|
/>
|
||||||
|
<Table.Column title="内存" width={200}
|
||||||
|
render={info => {
|
||||||
|
return formatMemory(info.memory) + `(${info.memory_usage}%)`
|
||||||
|
}}
|
||||||
|
sorter={(a, b) => a.memory_usage.localeCompare(b.memory_usage)}
|
||||||
|
defaultSortOrder="descend"
|
||||||
|
sortDirections={['descend']}
|
||||||
|
/>
|
||||||
|
<Table.Column title="命令参数"
|
||||||
|
render={info => `${info.command}`}
|
||||||
|
ellipsis
|
||||||
|
/>
|
||||||
|
</TableCard>)
|
||||||
|
})
|
Loading…
Reference in New Issue