Merge branch 'upstream-master' into upstream-develop

This commit is contained in:
陈景阳
2020-01-01 15:21:17 +08:00
44 changed files with 3303 additions and 229 deletions

View File

@@ -1,11 +1,15 @@
# 0.4.2 (unknown)
# 0.4.2 (2019-12-26)
### Features / Enhancement
- **Disclaimer**. Added page for Disclaimer.
- **Call API to fetch version**. [#371](https://github.com/crawlab-team/crawlab/issues/371)
- **Configure to allow user registration**. [#346](https://github.com/crawlab-team/crawlab/issues/346)
- **Allow adding new users**.
- **More Advanced File Management**. Allow users to add / edit / rename / delete files. [#286](https://github.com/crawlab-team/crawlab/issues/286)
- **Optimized Spider Creation Process**. Allow users to create an empty customized spider before uploading the zip file.
- **Better Task Management**. Allow users to filter tasks by selecting through certian criterions. [#341](https://github.com/crawlab-team/crawlab/issues/341)
### Bug Fixes
- **Duplicated nodes**. [#391](https://github.com/crawlab-team/crawlab/issues/391)
- **"mongodb no reachable" error**. [#373](https://github.com/crawlab-team/crawlab/issues/373)
# 0.4.1 (2019-12-13)

View File

@@ -15,7 +15,7 @@ WORKDIR /app
# install frontend
RUN npm config set unsafe-perm true
RUN npm install -g yarn && yarn install --registry=https://registry.npm.taobao.org
RUN npm install -g yarn && yarn install --registry=https://registry.npm.taobao.org # --sass_binary_site=https://npm.taobao.org/mirrors/node-sass/
RUN npm run build:prod
@@ -27,6 +27,9 @@ ADD . /app
# set as non-interactive
ENV DEBIAN_FRONTEND noninteractive
# set CRAWLAB_IS_DOCKER
ENV CRAWLAB_IS_DOCKER Y
# install packages
RUN apt-get update \
&& apt-get install -y curl git net-tools iputils-ping ntp ntpdate python3 python3-pip \

View File

@@ -15,7 +15,7 @@ WORKDIR /app
# install frontend
RUN npm config set unsafe-perm true
RUN npm install -g yarn && yarn install --registry=https://registry.npm.taobao.org
RUN npm install -g yarn && yarn install --registry=https://registry.npm.taobao.org # --sass_binary_site=https://npm.taobao.org/mirrors/node-sass/
RUN npm run build:prod
@@ -28,7 +28,8 @@ ADD . /app
ENV DEBIAN_FRONTEND noninteractive
# install packages
RUN apt-get update \
RUN chmod 777 /tmp \
&& apt-get update \
&& apt-get install -y curl git net-tools iputils-ping ntp ntpdate python3 python3-pip \
&& ln -s /usr/bin/pip3 /usr/local/bin/pip \
&& ln -s /usr/bin/python3 /usr/local/bin/python

View File

@@ -14,7 +14,7 @@
基于Golang的分布式爬虫管理平台支持Python、NodeJS、Go、Java、PHP等多种编程语言以及多种爬虫框架。
[查看演示 Demo](http://crawlab.cn/demo) | [文档](https://tikazyq.github.io/crawlab-docs)
[查看演示 Demo](http://crawlab.cn/demo) | [文档](http://docs.crawlab.cn)
## 安装
@@ -254,6 +254,9 @@ Crawlab使用起来很方便也很通用可以适用于几乎任何主流
<a href="https://github.com/hantmac">
<img src="https://avatars2.githubusercontent.com/u/7600925?s=460&v=4" height="80">
</a>
<a href="https://github.com/duanbin0414">
<img src="https://avatars3.githubusercontent.com/u/50389867?s=460&v=4" height="80">
</a>
## 社区 & 赞助

View File

@@ -14,7 +14,7 @@
Golang-based distributed web crawler management platform, supporting various languages including Python, NodeJS, Go, Java, PHP and various web crawler frameworks including Scrapy, Puppeteer, Selenium.
[Demo](http://crawlab.cn/demo) | [Documentation](https://tikazyq.github.io/crawlab-docs)
[Demo](http://crawlab.cn/demo) | [Documentation](http://docs.crawlab.cn)
## Installation
@@ -219,6 +219,9 @@ Crawlab is easy to use, general enough to adapt spiders in any language and any
<a href="https://github.com/hantmac">
<img src="https://avatars2.githubusercontent.com/u/7600925?s=460&v=4" height="80">
</a>
<a href="https://github.com/duanbin0414">
<img src="https://avatars3.githubusercontent.com/u/50389867?s=460&v=4" height="80">
</a>
## Community & Sponsorship

View File

@@ -32,6 +32,6 @@ task:
workers: 4
other:
tmppath: "/tmp"
version: 0.4.1
version: 0.4.2
setting:
allowRegister: "N"

View File

@@ -5,3 +5,9 @@ const (
Linux = "linux"
Darwin = "darwin"
)
const (
Python = "python"
NodeJS = "node"
Java = "java"
)

View File

@@ -58,9 +58,9 @@ func (r *Redis) subscribe(ctx context.Context, consume ConsumeFunc, channel ...s
}
done <- nil
case <-tick.C:
//fmt.Printf("ping message \n")
if err := psc.Ping(""); err != nil {
done <- err
fmt.Printf("ping message error: %s \n", err)
//done <- err
}
case err := <-done:
close(done)

View File

@@ -13,3 +13,18 @@ type Executable struct {
FileName string `json:"file_name"`
DisplayName string `json:"display_name"`
}
type Lang struct {
Name string `json:"name"`
ExecutableName string `json:"executable_name"`
ExecutablePath string `json:"executable_path"`
DepExecutablePath string `json:"dep_executable_path"`
Installed bool `json:"installed"`
}
type Dependency struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Installed bool `json:"installed"`
}

View File

@@ -11,6 +11,7 @@ require (
github.com/go-playground/locales v0.12.1 // indirect
github.com/go-playground/universal-translator v0.16.0 // indirect
github.com/gomodule/redigo v2.0.0+incompatible
github.com/imroc/req v0.2.4
github.com/leodido/go-urn v1.1.0 // indirect
github.com/pkg/errors v0.8.1
github.com/satori/go.uuid v1.2.0

View File

@@ -66,6 +66,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imroc/req v0.2.4 h1:8XbvaQpERLAJV6as/cB186DtH5f0m5zAOtHEaTQ4ac0=
github.com/imroc/req v0.2.4/go.mod h1:J9FsaNHDTIVyW/b5r6/Df5qKEEEq2WzZKIgKSajd1AE=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=

View File

@@ -31,24 +31,24 @@ func main() {
log.Error("init config error:" + err.Error())
panic(err)
}
log.Info("初始化配置成功")
log.Info("initialized config successfully")
// 初始化日志设置
logLevel := viper.GetString("log.level")
if logLevel != "" {
log.SetLevelFromString(logLevel)
}
log.Info("初始化日志设置成功")
log.Info("initialized log config successfully")
if viper.GetString("log.isDeletePeriodically") == "Y" {
err := services.InitDeleteLogPeriodically()
if err != nil {
log.Error("Init DeletePeriodically Failed")
log.Error("init DeletePeriodically failed")
panic(err)
}
log.Info("初始化定期清理日志配置成功")
log.Info("initialized periodically cleaning log successfully")
} else {
log.Info("默认未开启定期清理日志配置")
log.Info("periodically cleaning log is switched off")
}
// 初始化Mongodb数据库
@@ -57,7 +57,7 @@ func main() {
debug.PrintStack()
panic(err)
}
log.Info("初始化Mongodb数据库成功")
log.Info("initialized MongoDB successfully")
// 初始化Redis数据库
if err := database.InitRedis(); err != nil {
@@ -65,7 +65,7 @@ func main() {
debug.PrintStack()
panic(err)
}
log.Info("初始化Redis数据库成功")
log.Info("initialized Redis successfully")
if model.IsMaster() {
// 初始化定时任务
@@ -74,8 +74,8 @@ func main() {
debug.PrintStack()
panic(err)
}
log.Info("初始化定时任务成功")
}
log.Info("initialized schedule successfully")
// 初始化任务执行器
if err := services.InitTaskExecutor(); err != nil {
@@ -83,14 +83,14 @@ func main() {
debug.PrintStack()
panic(err)
}
log.Info("初始化任务执行器成功")
log.Info("initialized task executor successfully")
// 初始化节点服务
if err := services.InitNodeService(); err != nil {
log.Error("init node service error:" + err.Error())
panic(err)
}
log.Info("初始化节点配置成功")
log.Info("initialized node service successfully")
// 初始化爬虫服务
if err := services.InitSpiderService(); err != nil {
@@ -98,7 +98,7 @@ func main() {
debug.PrintStack()
panic(err)
}
log.Info("初始化爬虫服务成功")
log.Info("initialized spider service successfully")
// 初始化用户服务
if err := services.InitUserService(); err != nil {
@@ -106,7 +106,15 @@ func main() {
debug.PrintStack()
panic(err)
}
log.Info("初始化用户服务成功")
log.Info("initialized user service successfully")
// 初始化依赖服务
if err := services.InitDepsFetcher(); err != nil {
log.Error("init user service error:" + err.Error())
debug.PrintStack()
panic(err)
}
log.Info("initialized dependency fetcher successfully")
// 以下为主节点服务
if model.IsMaster() {
@@ -122,25 +130,34 @@ func main() {
{
// 路由
// 节点
authGroup.GET("/nodes", routes.GetNodeList) // 节点列表
authGroup.GET("/nodes/:id", routes.GetNode) // 节点详情
authGroup.POST("/nodes/:id", routes.PostNode) // 修改节点
authGroup.GET("/nodes/:id/tasks", routes.GetNodeTaskList) // 节点任务列表
authGroup.GET("/nodes/:id/system", routes.GetSystemInfo) // 节点任务列表
authGroup.DELETE("/nodes/:id", routes.DeleteNode) // 删除节点
authGroup.GET("/nodes", routes.GetNodeList) // 节点列表
authGroup.GET("/nodes/:id", routes.GetNode) // 节点详情
authGroup.POST("/nodes/:id", routes.PostNode) // 修改节点
authGroup.GET("/nodes/:id/tasks", routes.GetNodeTaskList) // 节点任务列表
authGroup.GET("/nodes/:id/system", routes.GetSystemInfo) // 节点任务列表
authGroup.DELETE("/nodes/:id", routes.DeleteNode) // 删除节点
authGroup.GET("/nodes/:id/langs", routes.GetLangList) // 节点语言环境列表
authGroup.GET("/nodes/:id/deps", routes.GetDepList) // 节点第三方依赖列表
authGroup.GET("/nodes/:id/deps/installed", routes.GetInstalledDepList) // 节点已安装第三方依赖列表
// 爬虫
authGroup.GET("/spiders", routes.GetSpiderList) // 爬虫列表
authGroup.GET("/spiders/:id", routes.GetSpider) // 爬虫详情
authGroup.POST("/spiders", routes.PutSpider) // 上传爬虫 TODO: 名称不对
authGroup.POST("/spiders/:id", routes.PostSpider) // 修改爬虫
authGroup.POST("/spiders/:id/publish", routes.PublishSpider) // 发布爬虫
authGroup.DELETE("/spiders/:id", routes.DeleteSpider) // 删除爬虫
authGroup.GET("/spiders/:id/tasks", routes.GetSpiderTasks) // 爬虫任务列表
authGroup.GET("/spiders/:id/file", routes.GetSpiderFile) // 爬虫文件读取
authGroup.POST("/spiders/:id/file", routes.PostSpiderFile) // 爬虫目录写入
authGroup.GET("/spiders/:id/dir", routes.GetSpiderDir) // 爬虫目录
authGroup.GET("/spiders/:id/stats", routes.GetSpiderStats) // 爬虫统计数据
authGroup.GET("/spider/types", routes.GetSpiderTypes) // 爬虫类型
authGroup.GET("/spiders", routes.GetSpiderList) // 爬虫列表
authGroup.GET("/spiders/:id", routes.GetSpider) // 爬虫详情
authGroup.PUT("/spiders", routes.PutSpider) // 添加爬虫
authGroup.POST("/spiders", routes.UploadSpider) // 上传爬虫
authGroup.POST("/spiders/:id", routes.PostSpider) // 修改爬虫
authGroup.POST("/spiders/:id/publish", routes.PublishSpider) // 发布爬虫
authGroup.POST("/spiders/:id/upload", routes.UploadSpiderFromId) // 上传爬虫ID
authGroup.DELETE("/spiders/:id", routes.DeleteSpider) // 删除爬虫
authGroup.GET("/spiders/:id/tasks", routes.GetSpiderTasks) // 爬虫任务列表
authGroup.GET("/spiders/:id/file", routes.GetSpiderFile) // 爬虫文件读取
authGroup.POST("/spiders/:id/file", routes.PostSpiderFile) // 爬虫文件更改
authGroup.PUT("/spiders/:id/file", routes.PutSpiderFile) // 爬虫文件创建
authGroup.PUT("/spiders/:id/dir", routes.PutSpiderDir) // 爬虫目录创建
authGroup.DELETE("/spiders/:id/file", routes.DeleteSpiderFile) // 爬虫文件删除
authGroup.POST("/spiders/:id/file/rename", routes.RenameSpiderFile) // 爬虫文件重命名
authGroup.GET("/spiders/:id/dir", routes.GetSpiderDir) // 爬虫目录
authGroup.GET("/spiders/:id/stats", routes.GetSpiderStats) // 爬虫统计数据
authGroup.GET("/spider/types", routes.GetSpiderTypes) // 爬虫类型
// 可配置爬虫
authGroup.GET("/config_spiders/:id/config", routes.GetConfigSpiderConfig) // 获取可配置爬虫配置
authGroup.POST("/config_spiders/:id/config", routes.PostConfigSpiderConfig) // 更改可配置爬虫配置
@@ -178,6 +195,8 @@ func main() {
authGroup.GET("/me", routes.GetMe) // 获取自己账户
// release版本
authGroup.GET("/version", routes.GetVersion) // 获取发布的版本
// 系统
authGroup.GET("/system/deps", routes.GetAllDepList) // 节点所有第三方依赖列表
}
}

View File

@@ -63,7 +63,9 @@ func GetCurrentNode() (Node, error) {
// 如果获取失败
if err != nil {
// 如果为主节点,表示为第一次注册,插入节点信息
if IsMaster() {
// update: 增加具体错误过滤。防止加入多个master节点后续需要职责拆分
//只在master节点运行的时候才检测master节点的信息是否存在
if IsMaster() && err == mgo.ErrNotFound {
// 获取本机信息
ip, mac, key, err := GetNodeBaseInfo()
if err != nil {

View File

@@ -157,15 +157,15 @@ func GetSpiderByFileId(fileId bson.ObjectId) *Spider {
}
// 获取爬虫(根据名称)
func GetSpiderByName(name string) *Spider {
func GetSpiderByName(name string) Spider {
s, c := database.GetCol("spiders")
defer s.Close()
var result *Spider
var result Spider
if err := c.Find(bson.M{"name": name}).One(&result); err != nil {
log.Errorf("get spider error: %s, spider_name: %s", err.Error(), name)
//debug.PrintStack()
return nil
return result
}
return result
}

View File

@@ -40,7 +40,7 @@ func PutConfigSpider(c *gin.Context) {
}
// 判断爬虫是否存在
if spider := model.GetSpiderByName(spider.Name); spider != nil {
if spider := model.GetSpiderByName(spider.Name); spider.Name != "" {
HandleErrorF(http.StatusBadRequest, c, fmt.Sprintf("spider for '%s' already exists", spider.Name))
return
}

View File

@@ -7,6 +7,7 @@ import (
"crawlab/model"
"crawlab/services"
"crawlab/utils"
"fmt"
"github.com/apex/log"
"github.com/gin-gonic/gin"
"github.com/globalsign/mgo"
@@ -17,6 +18,7 @@ import (
"io/ioutil"
"net/http"
"os"
"path"
"path/filepath"
"runtime/debug"
"strconv"
@@ -117,6 +119,64 @@ func PublishSpider(c *gin.Context) {
}
func PutSpider(c *gin.Context) {
var spider model.Spider
if err := c.ShouldBindJSON(&spider); err != nil {
HandleError(http.StatusBadRequest, c, err)
return
}
// 爬虫名称不能为空
if spider.Name == "" {
HandleErrorF(http.StatusBadRequest, c, "spider name should not be empty")
return
}
// 判断爬虫是否存在
if spider := model.GetSpiderByName(spider.Name); spider.Name != "" {
HandleErrorF(http.StatusBadRequest, c, fmt.Sprintf("spider for '%s' already exists", spider.Name))
return
}
// 设置爬虫类别
spider.Type = constants.Customized
// 将FileId置空
spider.FileId = bson.ObjectIdHex(constants.ObjectIdNull)
// 创建爬虫目录
spiderDir := filepath.Join(viper.GetString("spider.path"), spider.Name)
if utils.Exists(spiderDir) {
if err := os.RemoveAll(spiderDir); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
}
if err := os.MkdirAll(spiderDir, 0777); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
spider.Src = spiderDir
// 添加爬虫到数据库
if err := spider.Add(); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 同步到GridFS
if err := services.UploadSpiderToGridFsFromMaster(spider); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
Data: spider,
})
}
func UploadSpider(c *gin.Context) {
// 从body中获取文件
uploadFile, err := c.FormFile("file")
if err != nil {
@@ -125,6 +185,144 @@ func PutSpider(c *gin.Context) {
return
}
// 获取参数
name := c.PostForm("name")
displayName := c.PostForm("display_name")
col := c.PostForm("col")
cmd := c.PostForm("cmd")
// 如果不为zip文件返回错误
if !strings.HasSuffix(uploadFile.Filename, ".zip") {
HandleError(http.StatusBadRequest, c, errors.New("not a valid zip file"))
return
}
// 以防tmp目录不存在
tmpPath := viper.GetString("other.tmppath")
if !utils.Exists(tmpPath) {
if err := os.MkdirAll(tmpPath, os.ModePerm); err != nil {
log.Error("mkdir other.tmppath dir error:" + err.Error())
debug.PrintStack()
HandleError(http.StatusBadRequest, c, errors.New("mkdir other.tmppath dir error"))
return
}
}
// 保存到本地临时文件
randomId := uuid.NewV4()
tmpFilePath := filepath.Join(tmpPath, randomId.String()+".zip")
if err := c.SaveUploadedFile(uploadFile, tmpFilePath); err != nil {
log.Error("save upload file error: " + err.Error())
debug.PrintStack()
HandleError(http.StatusInternalServerError, c, err)
return
}
// 获取 GridFS 实例
s, gf := database.GetGridFs("files")
defer s.Close()
// 判断文件是否已经存在
var gfFile model.GridFs
if err := gf.Find(bson.M{"filename": uploadFile.Filename}).One(&gfFile); err == nil {
// 已经存在文件,则删除
_ = gf.RemoveId(gfFile.Id)
}
// 上传到GridFs
fid, err := services.UploadToGridFs(uploadFile.Filename, tmpFilePath)
if err != nil {
log.Errorf("upload to grid fs error: %s", err.Error())
debug.PrintStack()
return
}
idx := strings.LastIndex(uploadFile.Filename, "/")
targetFilename := uploadFile.Filename[idx+1:]
// 判断爬虫是否存在
spiderName := strings.Replace(targetFilename, ".zip", "", 1)
if name != "" {
spiderName = name
}
spider := model.GetSpiderByName(spiderName)
if spider.Name == "" {
// 保存爬虫信息
srcPath := viper.GetString("spider.path")
spider := model.Spider{
Name: spiderName,
DisplayName: spiderName,
Type: constants.Customized,
Src: filepath.Join(srcPath, spiderName),
FileId: fid,
}
if name != "" {
spider.Name = name
}
if displayName != "" {
spider.DisplayName = displayName
}
if col != "" {
spider.Col = col
}
if cmd != "" {
spider.Cmd = cmd
}
_ = spider.Add()
} else {
if name != "" {
spider.Name = name
}
if displayName != "" {
spider.DisplayName = displayName
}
if col != "" {
spider.Col = col
}
if cmd != "" {
spider.Cmd = cmd
}
// 更新file_id
spider.FileId = fid
_ = spider.Save()
}
// 发起同步
services.PublishAllSpiders()
// 获取爬虫
spider = model.GetSpiderByName(spiderName)
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
Data: spider,
})
}
func UploadSpiderFromId(c *gin.Context) {
// TODO: 与 UploadSpider 部分逻辑重复,需要优化代码
// 爬虫ID
spiderId := c.Param("id")
// 获取爬虫
spider, err := model.GetSpider(bson.ObjectIdHex(spiderId))
if err != nil {
if err == mgo.ErrNotFound {
HandleErrorF(http.StatusNotFound, c, "cannot find spider")
} else {
HandleError(http.StatusInternalServerError, c, err)
}
return
}
// 从body中获取文件
uploadFile, err := c.FormFile("file")
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 如果不为zip文件返回错误
if !strings.HasSuffix(uploadFile.Filename, ".zip") {
debug.PrintStack()
@@ -172,28 +370,12 @@ func PutSpider(c *gin.Context) {
return
}
idx := strings.LastIndex(uploadFile.Filename, "/")
targetFilename := uploadFile.Filename[idx+1:]
// 更新file_id
spider.FileId = fid
_ = spider.Save()
// 判断爬虫是否存在
spiderName := strings.Replace(targetFilename, ".zip", "", 1)
spider := model.GetSpiderByName(spiderName)
if spider == nil {
// 保存爬虫信息
srcPath := viper.GetString("spider.path")
spider := model.Spider{
Name: spiderName,
DisplayName: spiderName,
Type: constants.Customized,
Src: filepath.Join(srcPath, spiderName),
FileId: fid,
}
_ = spider.Add()
} else {
// 更新file_id
spider.FileId = fid
_ = spider.Save()
}
// 发起同步
services.PublishSpider(spider)
c.JSON(http.StatusOK, Response{
Status: "ok",
@@ -283,6 +465,14 @@ func GetSpiderDir(c *gin.Context) {
})
}
// 爬虫文件管理
type SpiderFileReqBody struct {
Path string `json:"path"`
Content string `json:"content"`
NewPath string `json:"new_path"`
}
func GetSpiderFile(c *gin.Context) {
// 爬虫ID
id := c.Param("id")
@@ -311,11 +501,6 @@ func GetSpiderFile(c *gin.Context) {
})
}
type SpiderFileReqBody struct {
Path string `json:"path"`
Content string `json:"content"`
}
func PostSpiderFile(c *gin.Context) {
// 爬虫ID
id := c.Param("id")
@@ -340,6 +525,12 @@ func PostSpiderFile(c *gin.Context) {
return
}
// 同步到GridFS
if err := services.UploadSpiderToGridFsFromMaster(spider); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 返回结果
c.JSON(http.StatusOK, Response{
Status: "ok",
@@ -347,6 +538,161 @@ func PostSpiderFile(c *gin.Context) {
})
}
func PutSpiderFile(c *gin.Context) {
spiderId := c.Param("id")
var reqBody SpiderFileReqBody
if err := c.ShouldBindJSON(&reqBody); err != nil {
HandleError(http.StatusBadRequest, c, err)
return
}
spider, err := model.GetSpider(bson.ObjectIdHex(spiderId))
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 文件路径
filePath := path.Join(spider.Src, reqBody.Path)
// 如果文件已存在,则报错
if utils.Exists(filePath) {
HandleErrorF(http.StatusInternalServerError, c, fmt.Sprintf(`%s already exists`, filePath))
return
}
// 写入文件
if err := ioutil.WriteFile(filePath, []byte(reqBody.Content), 0777); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 同步到GridFS
if err := services.UploadSpiderToGridFsFromMaster(spider); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
})
}
func PutSpiderDir(c *gin.Context) {
spiderId := c.Param("id")
var reqBody SpiderFileReqBody
if err := c.ShouldBindJSON(&reqBody); err != nil {
HandleError(http.StatusBadRequest, c, err)
return
}
spider, err := model.GetSpider(bson.ObjectIdHex(spiderId))
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 文件路径
filePath := path.Join(spider.Src, reqBody.Path)
// 如果文件已存在,则报错
if utils.Exists(filePath) {
HandleErrorF(http.StatusInternalServerError, c, fmt.Sprintf(`%s already exists`, filePath))
return
}
// 创建文件夹
if err := os.MkdirAll(filePath, 0777); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 同步到GridFS
if err := services.UploadSpiderToGridFsFromMaster(spider); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
})
}
func DeleteSpiderFile(c *gin.Context) {
spiderId := c.Param("id")
var reqBody SpiderFileReqBody
if err := c.ShouldBindJSON(&reqBody); err != nil {
HandleError(http.StatusBadRequest, c, err)
return
}
spider, err := model.GetSpider(bson.ObjectIdHex(spiderId))
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
filePath := path.Join(spider.Src, reqBody.Path)
if err := os.RemoveAll(filePath); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 同步到GridFS
if err := services.UploadSpiderToGridFsFromMaster(spider); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
})
}
func RenameSpiderFile(c *gin.Context) {
spiderId := c.Param("id")
var reqBody SpiderFileReqBody
if err := c.ShouldBindJSON(&reqBody); err != nil {
HandleError(http.StatusBadRequest, c, err)
}
spider, err := model.GetSpider(bson.ObjectIdHex(spiderId))
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 原文件路径
filePath := path.Join(spider.Src, reqBody.Path)
newFilePath := path.Join(spider.Src, reqBody.NewPath)
// 如果新文件已存在,则报错
if utils.Exists(newFilePath) {
HandleErrorF(http.StatusInternalServerError, c, fmt.Sprintf(`%s already exists`, newFilePath))
return
}
// 重命名
if err := os.Rename(filePath, newFilePath); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
// 删除原文件
if err := os.RemoveAll(filePath); err != nil {
HandleError(http.StatusInternalServerError, c, err)
}
// 同步到GridFS
if err := services.UploadSpiderToGridFsFromMaster(spider); err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
})
}
// 爬虫类型
func GetSpiderTypes(c *gin.Context) {
types, err := model.GetSpiderTypes()

110
backend/routes/system.go Normal file
View File

@@ -0,0 +1,110 @@
package routes
import (
"crawlab/constants"
"crawlab/entity"
"crawlab/services"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"strings"
)
func GetLangList(c *gin.Context) {
nodeId := c.Param("id")
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
Data: services.GetLangList(nodeId),
})
}
func GetDepList(c *gin.Context) {
nodeId := c.Param("id")
lang := c.Query("lang")
depName := c.Query("dep_name")
var depList []entity.Dependency
if lang == constants.Python {
list, err := services.GetPythonDepList(nodeId, depName)
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
depList = list
} else {
HandleErrorF(http.StatusBadRequest, c, fmt.Sprintf("%s is not implemented", lang))
return
}
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
Data: depList,
})
}
func GetInstalledDepList(c *gin.Context) {
nodeId := c.Param("id")
lang := c.Query("lang")
var depList []entity.Dependency
if lang == constants.Python {
list, err := services.GetPythonInstalledDepList(nodeId)
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
depList = list
} else {
HandleErrorF(http.StatusBadRequest, c, fmt.Sprintf("%s is not implemented", lang))
return
}
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
Data: depList,
})
}
func GetAllDepList(c *gin.Context) {
lang := c.Query("lang")
depName := c.Query("dep_name")
// 获取所有依赖列表
var list []string
if lang == constants.Python {
_list, err := services.GetPythonDepListFromRedis()
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
list = _list
} else {
HandleErrorF(http.StatusBadRequest, c, fmt.Sprintf("%s is not implemented", lang))
return
}
// 过滤依赖列表
var depList []string
for _, name := range list {
if strings.HasPrefix(strings.ToLower(name), strings.ToLower(depName)) {
depList = append(depList, name)
}
}
// 只取前20
var returnList []string
for i, name := range depList {
if i >= 10 {
break
}
returnList = append(returnList, name)
}
c.JSON(http.StatusOK, Response{
Status: "ok",
Message: "success",
Data: returnList,
})
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/apex/log"
"github.com/globalsign/mgo"
"github.com/globalsign/mgo/bson"
uuid "github.com/satori/go.uuid"
"github.com/spf13/viper"
"os"
"path/filepath"
@@ -30,6 +31,48 @@ type SpiderUploadMessage struct {
SpiderId string
}
// 从主节点上传爬虫到GridFS
func UploadSpiderToGridFsFromMaster(spider model.Spider) error {
// 爬虫所在目录
spiderDir := spider.Src
// 打包为 zip 文件
files, err := utils.GetFilesFromDir(spiderDir)
if err != nil {
return err
}
randomId := uuid.NewV4()
tmpFilePath := filepath.Join(viper.GetString("other.tmppath"), spider.Name+"."+randomId.String()+".zip")
spiderZipFileName := spider.Name + ".zip"
if err := utils.Compress(files, tmpFilePath); err != nil {
return err
}
// 获取 GridFS 实例
s, gf := database.GetGridFs("files")
defer s.Close()
// 判断文件是否已经存在
var gfFile model.GridFs
if err := gf.Find(bson.M{"filename": spiderZipFileName}).One(&gfFile); err == nil {
// 已经存在文件,则删除
_ = gf.RemoveId(gfFile.Id)
}
// 上传到GridFs
fid, err := UploadToGridFs(spiderZipFileName, tmpFilePath)
if err != nil {
log.Errorf("upload to grid fs error: %s", err.Error())
return err
}
// 保存爬虫 FileId
spider.FileId = fid
_ = spider.Save()
return nil
}
// 上传zip文件到GridFS
func UploadToGridFs(fileName string, filePath string) (fid bson.ObjectId, err error) {
fid = ""

View File

@@ -4,28 +4,60 @@ import (
"crawlab/constants"
"crawlab/database"
"crawlab/entity"
"crawlab/lib/cron"
"crawlab/model"
"crawlab/utils"
"encoding/json"
"errors"
"fmt"
"github.com/apex/log"
"github.com/imroc/req"
"os/exec"
"regexp"
"runtime/debug"
"sort"
"strings"
"sync"
)
type PythonDepJsonData struct {
Info PythonDepJsonDataInfo `json:"info"`
}
type PythonDepJsonDataInfo struct {
Name string `json:"name"`
Summary string `json:"summary"`
Version string `json:"version"`
}
type PythonDepNameDict struct {
Name string `json:"name"`
Weight int `json:"weight"`
}
type PythonDepNameDictSlice []PythonDepNameDict
func (s PythonDepNameDictSlice) Len() int { return len(s) }
func (s PythonDepNameDictSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s PythonDepNameDictSlice) Less(i, j int) bool { return s[i].Weight > s[j].Weight }
var SystemInfoChanMap = utils.NewChanMap()
func GetRemoteSystemInfo(id string) (sysInfo entity.SystemInfo, err error) {
func GetRemoteSystemInfo(nodeId string) (sysInfo entity.SystemInfo, err error) {
// 发送消息
msg := entity.NodeMessage{
Type: constants.MsgTypeGetSystemInfo,
NodeId: id,
NodeId: nodeId,
}
// 序列化
msgBytes, _ := json.Marshal(&msg)
if _, err := database.RedisClient.Publish("nodes:"+id, utils.BytesToString(msgBytes)); err != nil {
if _, err := database.RedisClient.Publish("nodes:"+nodeId, utils.BytesToString(msgBytes)); err != nil {
return entity.SystemInfo{}, err
}
// 通道
ch := SystemInfoChanMap.ChanBlocked(id)
ch := SystemInfoChanMap.ChanBlocked(nodeId)
// 等待响应,阻塞
sysInfoStr := <-ch
@@ -38,11 +70,242 @@ func GetRemoteSystemInfo(id string) (sysInfo entity.SystemInfo, err error) {
return sysInfo, nil
}
func GetSystemInfo(id string) (sysInfo entity.SystemInfo, err error) {
if IsMasterNode(id) {
func GetSystemInfo(nodeId string) (sysInfo entity.SystemInfo, err error) {
if IsMasterNode(nodeId) {
sysInfo, err = model.GetLocalSystemInfo()
} else {
sysInfo, err = GetRemoteSystemInfo(id)
sysInfo, err = GetRemoteSystemInfo(nodeId)
}
return
}
func GetLangList(nodeId string) []entity.Lang {
list := []entity.Lang{
{Name: "Python", ExecutableName: "python", ExecutablePath: "/usr/local/bin/python", DepExecutablePath: "/usr/local/bin/pip"},
{Name: "NodeJS", ExecutableName: "node", ExecutablePath: "/usr/local/bin/node"},
{Name: "Java", ExecutableName: "java", ExecutablePath: "/usr/local/bin/java"},
}
for i, lang := range list {
list[i].Installed = IsInstalledLang(nodeId, lang)
}
return list
}
func GetLangFromLangName(nodeId string, name string) entity.Lang {
langList := GetLangList(nodeId)
for _, lang := range langList {
if lang.ExecutableName == name {
return lang
}
}
return entity.Lang{}
}
func GetPythonDepList(nodeId string, searchDepName string) ([]entity.Dependency, error) {
var list []entity.Dependency
// 先从 Redis 获取
depList, err := GetPythonDepListFromRedis()
if err != nil {
return list, err
}
// 过滤相似的依赖
var depNameList PythonDepNameDictSlice
for _, depName := range depList {
if strings.HasPrefix(strings.ToLower(depName), strings.ToLower(searchDepName)) {
var weight int
if strings.ToLower(depName) == strings.ToLower(searchDepName) {
weight = 3
} else if strings.HasPrefix(strings.ToLower(depName), strings.ToLower(searchDepName)) {
weight = 2
} else {
weight = 1
}
depNameList = append(depNameList, PythonDepNameDict{
Name: depName,
Weight: weight,
})
}
}
// 获取已安装依赖
installedDepList, err := GetPythonInstalledDepList(nodeId)
if err != nil {
return list, err
}
// 从依赖源获取数据
var goSync sync.WaitGroup
sort.Stable(depNameList)
for i, depNameDict := range depNameList {
if i > 10 {
break
}
goSync.Add(1)
go func(depName string, n *sync.WaitGroup) {
url := fmt.Sprintf("https://pypi.org/pypi/%s/json", depName)
res, err := req.Get(url)
if err != nil {
n.Done()
return
}
var data PythonDepJsonData
if err := res.ToJSON(&data); err != nil {
n.Done()
return
}
dep := entity.Dependency{
Name: depName,
Version: data.Info.Version,
Description: data.Info.Summary,
}
dep.Installed = IsInstalledDep(installedDepList, dep)
list = append(list, dep)
n.Done()
}(depNameDict.Name, &goSync)
}
goSync.Wait()
return list, nil
}
func GetPythonDepListFromRedis() ([]string, error) {
var list []string
// 从 Redis 获取字符串
rawData, err := database.RedisClient.HGet("system", "deps:python")
if err != nil {
return list, err
}
// 反序列化
if err := json.Unmarshal([]byte(rawData), &list); err != nil {
return list, err
}
// 如果为空,则从依赖源获取列表
if len(list) == 0 {
UpdatePythonDepList()
}
return list, nil
}
func IsInstalledLang(nodeId string, lang entity.Lang) bool {
sysInfo, err := GetSystemInfo(nodeId)
if err != nil {
return false
}
for _, exec := range sysInfo.Executables {
if exec.Path == lang.ExecutablePath {
return true
}
}
return false
}
func IsInstalledDep(installedDepList []entity.Dependency, dep entity.Dependency) bool {
for _, _dep := range installedDepList {
if strings.ToLower(_dep.Name) == strings.ToLower(dep.Name) {
return true
}
}
return false
}
func FetchPythonDepList() ([]string, error) {
// 依赖URL
url := "https://pypi.tuna.tsinghua.edu.cn/simple"
// 输出列表
var list []string
// 请求URL
res, err := req.Get(url)
if err != nil {
log.Error(err.Error())
debug.PrintStack()
return list, err
}
// 获取响应数据
text, err := res.ToString()
if err != nil {
log.Error(err.Error())
debug.PrintStack()
return list, err
}
// 从响应数据中提取依赖名
regex := regexp.MustCompile("<a href=\".*/\">(.*)</a>")
for _, line := range strings.Split(text, "\n") {
arr := regex.FindStringSubmatch(line)
if len(arr) < 2 {
continue
}
list = append(list, arr[1])
}
// 赋值给列表
return list, nil
}
func UpdatePythonDepList() {
// 从依赖源获取列表
list, _ := FetchPythonDepList()
// 序列化
listBytes, err := json.Marshal(list)
if err != nil {
log.Error(err.Error())
debug.PrintStack()
return
}
// 设置Redis
if err := database.RedisClient.HSet("system", "deps:python", string(listBytes)); err != nil {
log.Error(err.Error())
debug.PrintStack()
return
}
}
func GetPythonInstalledDepList(nodeId string) ([]entity.Dependency, error){
var list []entity.Dependency
lang := GetLangFromLangName(nodeId, constants.Python)
if !IsInstalledLang(nodeId, lang) {
return list, errors.New("python is not installed")
}
cmd := exec.Command("pip", "freeze")
outputBytes, err := cmd.Output()
if err != nil {
debug.PrintStack()
return list, err
}
for _, line := range strings.Split(string(outputBytes), "\n") {
arr := strings.Split(line, "==")
if len(arr) < 2 {
continue
}
dep := entity.Dependency{
Name: strings.ToLower(arr[0]),
Version: arr[1],
Installed: true,
}
list = append(list, dep)
}
return list, nil
}
func InitDepsFetcher() error {
c := cron.New(cron.WithSeconds())
c.Start()
if _, err := c.AddFunc("0 */5 * * * *", UpdatePythonDepList); err != nil {
return err
}
return nil
}

201
backend/vendor/github.com/imroc/req/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

302
backend/vendor/github.com/imroc/req/README.md generated vendored Normal file
View File

@@ -0,0 +1,302 @@
# req
[![GoDoc](https://godoc.org/github.com/imroc/req?status.svg)](https://godoc.org/github.com/imroc/req)
A golang http request library for humans
Features
========
- Light weight
- Simple
- Easy play with JSON and XML
- Easy for debug and logging
- Easy file uploads and downloads
- Easy manage cookie
- Easy set up proxy
- Easy set timeout
- Easy customize http client
Document
========
[中文](doc/README_cn.md)
Install
=======
``` sh
go get github.com/imroc/req
```
Overview
=======
`req` implements a friendly API over Go's existing `net/http` library.
`Req` and `Resp` are two most important struct, you can think of `Req` as a client that initiate HTTP requests, `Resp` as a information container for the request and response. They all provide simple and convenient APIs that allows you to do a lot of things.
``` go
func (r *Req) Post(url string, v ...interface{}) (*Resp, error)
```
In most cases, only url is required, others are optional, like headers, params, files or body etc.
There is a default `Req` object, all of its' public methods are wrapped by the `req` package, so you can also think of `req` package as a `Req` object
``` go
// use Req object to initiate requests.
r := req.New()
r.Get(url)
// use req package to initiate request.
req.Get(url)
```
You can use `req.New()` to create lots of `*Req` as client with independent configuration
Examples
=======
[Basic](#Basic)
[Set Header](#Set-Header)
[Set Param](#Set-Param)
[Set Body](#Set-Body)
[Debug](#Debug)
[Output Format](#Format)
[ToJSON & ToXML](#ToJSON-ToXML)
[Get *http.Response](#Response)
[Upload](#Upload)
[Download](#Download)
[Cookie](#Cookie)
[Set Timeout](#Set-Timeout)
[Set Proxy](#Set-Proxy)
[Customize Client](#Customize-Client)
## <a name="Basic">Basic</a>
``` go
header := req.Header{
"Accept": "application/json",
"Authorization": "Basic YWRtaW46YWRtaW4=",
}
param := req.Param{
"name": "imroc",
"cmd": "add",
}
// only url is required, others are optional.
r, err = req.Post("http://foo.bar/api", header, param)
if err != nil {
log.Fatal(err)
}
r.ToJSON(&foo) // response => struct/map
log.Printf("%+v", r) // print info (try it, you may surprise)
```
## <a name="Set-Header">Set Header</a>
Use `req.Header` (it is actually a `map[string]string`)
``` go
authHeader := req.Header{
"Accept": "application/json",
"Authorization": "Basic YWRtaW46YWRtaW4=",
}
req.Get("https://www.baidu.com", authHeader, req.Header{"User-Agent": "V1.1"})
```
use `http.Header`
``` go
header := make(http.Header)
header.Set("Accept", "application/json")
req.Get("https://www.baidu.com", header)
```
## <a name="Set-Param">Set Param</a>
Use `req.Param` (it is actually a `map[string]interface{}`)
``` go
param := req.Param{
"id": "imroc",
"pwd": "roc",
}
req.Get("http://foo.bar/api", param) // http://foo.bar/api?id=imroc&pwd=roc
req.Post(url, param) // body => id=imroc&pwd=roc
```
use `req.QueryParam` force to append params to the url (it is also actually a `map[string]interface{}`)
``` go
req.Post("http://foo.bar/api", req.Param{"name": "roc", "age": "22"}, req.QueryParam{"access_token": "fedledGF9Hg9ehTU"})
/*
POST /api?access_token=fedledGF9Hg9ehTU HTTP/1.1
Host: foo.bar
User-Agent: Go-http-client/1.1
Content-Length: 15
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Accept-Encoding: gzip
age=22&name=roc
*/
```
## <a name="Set-Body">Set Body</a>
Put `string`, `[]byte` and `io.Reader` as body directly.
``` go
req.Post(url, "id=roc&cmd=query")
```
Put object as xml or json body (add `Content-Type` header automatically)
``` go
req.Post(url, req.BodyJSON(&foo))
req.Post(url, req.BodyXML(&bar))
```
## <a name="Debug">Debug</a>
Set global variable `req.Debug` to true, it will print detail infomation for every request.
``` go
req.Debug = true
req.Post("http://localhost/test" "hi")
```
![post](doc/post.png)
## <a name="Format">Output Format</a>
You can use different kind of output format to log the request and response infomation in your log file in defferent scenarios. For example, use `%+v` output format in the development phase, it allows you to observe the details. Use `%v` or `%-v` output format in production phase, just log the information necessarily.
### `%+v` or `%+s`
Output in detail
``` go
r, _ := req.Post(url, header, param)
log.Printf("%+v", r) // output the same format as Debug is enabled
```
### `%v` or `%s`
Output in simple way (default format)
``` go
r, _ := req.Get(url, param)
log.Printf("%v\n", r) // GET http://foo.bar/api?name=roc&cmd=add {"code":"0","msg":"success"}
log.Prinln(r) // smae as above
```
### `%-v` or `%-s`
Output in simple way and keep all in one line (request body or response body may have multiple lines, this format will replace `"\r"` or `"\n"` with `" "`, it's useful when doing some search in your log file)
### Flag
You can call `SetFlags` to control the output content, decide which pieces can be output.
``` go
const (
LreqHead = 1 << iota // output request head (request line and request header)
LreqBody // output request body
LrespHead // output response head (response line and response header)
LrespBody // output response body
Lcost // output time costed by the request
LstdFlags = LreqHead | LreqBody | LrespHead | LrespBody
)
```
``` go
req.SetFlags(req.LreqHead | req.LreqBody | req.LrespHead)
```
### Monitoring time consuming
``` go
req.SetFlags(req.LstdFlags | req.Lcost) // output format add time costed by request
r,_ := req.Get(url)
log.Println(r) // http://foo.bar/api 3.260802ms {"code":0 "msg":"success"}
if r.Cost() > 3 * time.Second { // check cost
log.Println("WARN: slow request:", r)
}
```
## <a name="ToJSON-ToXML">ToJSON & ToXML</a>
``` go
r, _ := req.Get(url)
r.ToJSON(&foo)
r, _ = req.Post(url, req.BodyXML(&bar))
r.ToXML(&baz)
```
## <a name="Response">Get *http.Response</a>
```go
// func (r *Req) Response() *http.Response
r, _ := req.Get(url)
resp := r.Response()
fmt.Println(resp.StatusCode)
```
## <a name="Upload">Upload</a>
Use `req.File` to match files
``` go
req.Post(url, req.File("imroc.png"), req.File("/Users/roc/Pictures/*.png"))
```
Use `req.FileUpload` to fully control
``` go
file, _ := os.Open("imroc.png")
req.Post(url, req.FileUpload{
File: file,
FieldName: "file", // FieldName is form field name
FileName: "avatar.png", //Filename is the name of the file that you wish to upload. We use this to guess the mimetype as well as pass it onto the server
})
```
Use `req.UploadProgress` to listen upload progress
```go
progress := func(current, total int64) {
fmt.Println(float32(current)/float32(total)*100, "%")
}
req.Post(url, req.File("/Users/roc/Pictures/*.png"), req.UploadProgress(progress))
fmt.Println("upload complete")
```
## <a name="Download">Download</a>
``` go
r, _ := req.Get(url)
r.ToFile("imroc.png")
```
Use `req.DownloadProgress` to listen download progress
```go
progress := func(current, total int64) {
fmt.Println(float32(current)/float32(total)*100, "%")
}
r, _ := req.Get(url, req.DownloadProgress(progress))
r.ToFile("hello.mp4")
fmt.Println("download complete")
```
## <a name="Cookie">Cookie</a>
By default, the underlying `*http.Client` will manage your cookie(send cookie header to server automatically if server has set a cookie for you), you can disable it by calling this function :
``` go
req.EnableCookie(false)
```
and you can set cookie in request just using `*http.Cookie`
``` go
cookie := new(http.Cookie)
// ......
req.Get(url, cookie)
```
## <a name="Set-Timeout">Set Timeout</a>
``` go
req.SetTimeout(50 * time.Second)
```
## <a name="Set-Proxy">Set Proxy</a>
By default, req use proxy from system environment if `http_proxy` or `https_proxy` is specified, you can set a custom proxy or disable it by set `nil`
``` go
req.SetProxy(func(r *http.Request) (*url.URL, error) {
if strings.Contains(r.URL.Hostname(), "google") {
return url.Parse("http://my.vpn.com:23456")
}
return nil, nil
})
```
Set a simple proxy (use fixed proxy url for every request)
``` go
req.SetProxyUrl("http://my.proxy.com:23456")
```
## <a name="Customize-Client">Customize Client</a>
Use `SetClient` to change the default underlying `*http.Client`
``` go
req.SetClient(client)
```
Specify independent http client for some requests
``` go
client := &http.Client{Timeout: 30 * time.Second}
req.Get(url, client)
```
Change some properties of default client you want
``` go
req.Client().Jar, _ = cookiejar.New(nil)
trans, _ := req.Client().Transport.(*http.Transport)
trans.MaxIdleConns = 20
trans.TLSHandshakeTimeout = 20 * time.Second
trans.DisableKeepAlives = true
trans.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
```

216
backend/vendor/github.com/imroc/req/dump.go generated vendored Normal file
View File

@@ -0,0 +1,216 @@
package req
import (
"bufio"
"bytes"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
)
// Debug enable debug mode if set to true
var Debug bool
// dumpConn is a net.Conn which writes to Writer and reads from Reader
type dumpConn struct {
io.Writer
io.Reader
}
func (c *dumpConn) Close() error { return nil }
func (c *dumpConn) LocalAddr() net.Addr { return nil }
func (c *dumpConn) RemoteAddr() net.Addr { return nil }
func (c *dumpConn) SetDeadline(t time.Time) error { return nil }
func (c *dumpConn) SetReadDeadline(t time.Time) error { return nil }
func (c *dumpConn) SetWriteDeadline(t time.Time) error { return nil }
// delegateReader is a reader that delegates to another reader,
// once it arrives on a channel.
type delegateReader struct {
c chan io.Reader
r io.Reader // nil until received from c
}
func (r *delegateReader) Read(p []byte) (int, error) {
if r.r == nil {
r.r = <-r.c
}
return r.r.Read(p)
}
type dummyBody struct {
N int
off int
}
func (d *dummyBody) Read(p []byte) (n int, err error) {
if d.N <= 0 {
err = io.EOF
return
}
left := d.N - d.off
if left <= 0 {
err = io.EOF
return
}
if l := len(p); l > 0 {
if l >= left {
n = left
err = io.EOF
} else {
n = l
}
d.off += n
for i := 0; i < n; i++ {
p[i] = '*'
}
}
return
}
func (d *dummyBody) Close() error {
return nil
}
type dumpBuffer struct {
bytes.Buffer
}
func (b *dumpBuffer) Write(p []byte) {
if b.Len() > 0 {
b.Buffer.WriteString("\r\n\r\n")
}
b.Buffer.Write(p)
}
func (b *dumpBuffer) WriteString(s string) {
b.Write([]byte(s))
}
func (r *Resp) dumpRequest(dump *dumpBuffer) {
head := r.r.flag&LreqHead != 0
body := r.r.flag&LreqBody != 0
if head {
r.dumpReqHead(dump)
}
if body {
if r.multipartHelper != nil {
dump.Write(r.multipartHelper.Dump())
} else if len(r.reqBody) > 0 {
dump.Write(r.reqBody)
}
}
}
func (r *Resp) dumpReqHead(dump *dumpBuffer) {
reqSend := new(http.Request)
*reqSend = *r.req
if reqSend.URL.Scheme == "https" {
reqSend.URL = new(url.URL)
*reqSend.URL = *r.req.URL
reqSend.URL.Scheme = "http"
}
if reqSend.ContentLength > 0 {
reqSend.Body = &dummyBody{N: int(reqSend.ContentLength)}
} else {
reqSend.Body = &dummyBody{N: 1}
}
// Use the actual Transport code to record what we would send
// on the wire, but not using TCP. Use a Transport with a
// custom dialer that returns a fake net.Conn that waits
// for the full input (and recording it), and then responds
// with a dummy response.
var buf bytes.Buffer // records the output
pr, pw := io.Pipe()
defer pw.Close()
dr := &delegateReader{c: make(chan io.Reader)}
t := &http.Transport{
Dial: func(net, addr string) (net.Conn, error) {
return &dumpConn{io.MultiWriter(&buf, pw), dr}, nil
},
}
defer t.CloseIdleConnections()
client := new(http.Client)
*client = *r.client
client.Transport = t
// Wait for the request before replying with a dummy response:
go func() {
req, err := http.ReadRequest(bufio.NewReader(pr))
if err == nil {
// Ensure all the body is read; otherwise
// we'll get a partial dump.
io.Copy(ioutil.Discard, req.Body)
req.Body.Close()
}
dr.c <- strings.NewReader("HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n")
pr.Close()
}()
_, err := client.Do(reqSend)
if err != nil {
dump.WriteString(err.Error())
} else {
reqDump := buf.Bytes()
if i := bytes.Index(reqDump, []byte("\r\n\r\n")); i >= 0 {
reqDump = reqDump[:i]
}
dump.Write(reqDump)
}
}
func (r *Resp) dumpResponse(dump *dumpBuffer) {
head := r.r.flag&LrespHead != 0
body := r.r.flag&LrespBody != 0
if head {
respDump, err := httputil.DumpResponse(r.resp, false)
if err != nil {
dump.WriteString(err.Error())
} else {
if i := bytes.Index(respDump, []byte("\r\n\r\n")); i >= 0 {
respDump = respDump[:i]
}
dump.Write(respDump)
}
}
if body && len(r.Bytes()) > 0 {
dump.Write(r.Bytes())
}
}
// Cost return the time cost of the request
func (r *Resp) Cost() time.Duration {
return r.cost
}
// Dump dump the request
func (r *Resp) Dump() string {
dump := new(dumpBuffer)
if r.r.flag&Lcost != 0 {
dump.WriteString(fmt.Sprint(r.cost))
}
r.dumpRequest(dump)
l := dump.Len()
if l > 0 {
dump.WriteString("=================================")
l = dump.Len()
}
r.dumpResponse(dump)
return dump.String()
}

688
backend/vendor/github.com/imroc/req/req.go generated vendored Normal file
View File

@@ -0,0 +1,688 @@
package req
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// default *Req
var std = New()
// flags to decide which part can be outputed
const (
LreqHead = 1 << iota // output request head (request line and request header)
LreqBody // output request body
LrespHead // output response head (response line and response header)
LrespBody // output response body
Lcost // output time costed by the request
LstdFlags = LreqHead | LreqBody | LrespHead | LrespBody
)
// Header represents http request header
type Header map[string]string
func (h Header) Clone() Header {
if h == nil {
return nil
}
hh := Header{}
for k, v := range h {
hh[k] = v
}
return hh
}
// Param represents http request param
type Param map[string]interface{}
// QueryParam is used to force append http request param to the uri
type QueryParam map[string]interface{}
// Host is used for set request's Host
type Host string
// FileUpload represents a file to upload
type FileUpload struct {
// filename in multipart form.
FileName string
// form field name
FieldName string
// file to uplaod, required
File io.ReadCloser
}
type DownloadProgress func(current, total int64)
type UploadProgress func(current, total int64)
// File upload files matching the name pattern such as
// /usr/*/bin/go* (assuming the Separator is '/')
func File(patterns ...string) interface{} {
matches := []string{}
for _, pattern := range patterns {
m, err := filepath.Glob(pattern)
if err != nil {
return err
}
matches = append(matches, m...)
}
if len(matches) == 0 {
return errors.New("req: no file have been matched")
}
uploads := []FileUpload{}
for _, match := range matches {
if s, e := os.Stat(match); e != nil || s.IsDir() {
continue
}
file, _ := os.Open(match)
uploads = append(uploads, FileUpload{
File: file,
FileName: filepath.Base(match),
FieldName: "media",
})
}
return uploads
}
type bodyJson struct {
v interface{}
}
type bodyXml struct {
v interface{}
}
// BodyJSON make the object be encoded in json format and set it to the request body
func BodyJSON(v interface{}) *bodyJson {
return &bodyJson{v: v}
}
// BodyXML make the object be encoded in xml format and set it to the request body
func BodyXML(v interface{}) *bodyXml {
return &bodyXml{v: v}
}
// Req is a convenient client for initiating requests
type Req struct {
client *http.Client
jsonEncOpts *jsonEncOpts
xmlEncOpts *xmlEncOpts
flag int
}
// New create a new *Req
func New() *Req {
return &Req{flag: LstdFlags}
}
type param struct {
url.Values
}
func (p *param) getValues() url.Values {
if p.Values == nil {
p.Values = make(url.Values)
}
return p.Values
}
func (p *param) Copy(pp param) {
if pp.Values == nil {
return
}
vs := p.getValues()
for key, values := range pp.Values {
for _, value := range values {
vs.Add(key, value)
}
}
}
func (p *param) Adds(m map[string]interface{}) {
if len(m) == 0 {
return
}
vs := p.getValues()
for k, v := range m {
vs.Add(k, fmt.Sprint(v))
}
}
func (p *param) Empty() bool {
return p.Values == nil
}
// Do execute a http request with sepecify method and url,
// and it can also have some optional params, depending on your needs.
func (r *Req) Do(method, rawurl string, vs ...interface{}) (resp *Resp, err error) {
if rawurl == "" {
return nil, errors.New("req: url not specified")
}
req := &http.Request{
Method: method,
Header: make(http.Header),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
}
resp = &Resp{req: req, r: r}
var queryParam param
var formParam param
var uploads []FileUpload
var uploadProgress UploadProgress
var progress func(int64, int64)
var delayedFunc []func()
var lastFunc []func()
for _, v := range vs {
switch vv := v.(type) {
case Header:
for key, value := range vv {
req.Header.Add(key, value)
}
case http.Header:
for key, values := range vv {
for _, value := range values {
req.Header.Add(key, value)
}
}
case *bodyJson:
fn, err := setBodyJson(req, resp, r.jsonEncOpts, vv.v)
if err != nil {
return nil, err
}
delayedFunc = append(delayedFunc, fn)
case *bodyXml:
fn, err := setBodyXml(req, resp, r.xmlEncOpts, vv.v)
if err != nil {
return nil, err
}
delayedFunc = append(delayedFunc, fn)
case url.Values:
p := param{vv}
if method == "GET" || method == "HEAD" {
queryParam.Copy(p)
} else {
formParam.Copy(p)
}
case Param:
if method == "GET" || method == "HEAD" {
queryParam.Adds(vv)
} else {
formParam.Adds(vv)
}
case QueryParam:
queryParam.Adds(vv)
case string:
setBodyBytes(req, resp, []byte(vv))
case []byte:
setBodyBytes(req, resp, vv)
case bytes.Buffer:
setBodyBytes(req, resp, vv.Bytes())
case *http.Client:
resp.client = vv
case FileUpload:
uploads = append(uploads, vv)
case []FileUpload:
uploads = append(uploads, vv...)
case *http.Cookie:
req.AddCookie(vv)
case Host:
req.Host = string(vv)
case io.Reader:
fn := setBodyReader(req, resp, vv)
lastFunc = append(lastFunc, fn)
case UploadProgress:
uploadProgress = vv
case DownloadProgress:
resp.downloadProgress = vv
case func(int64, int64):
progress = vv
case context.Context:
req = req.WithContext(vv)
resp.req = req
case error:
return nil, vv
}
}
if length := req.Header.Get("Content-Length"); length != "" {
if l, err := strconv.ParseInt(length, 10, 64); err == nil {
req.ContentLength = l
}
}
if len(uploads) > 0 && (req.Method == "POST" || req.Method == "PUT") { // multipart
var up UploadProgress
if uploadProgress != nil {
up = uploadProgress
} else if progress != nil {
up = UploadProgress(progress)
}
multipartHelper := &multipartHelper{
form: formParam.Values,
uploads: uploads,
uploadProgress: up,
}
multipartHelper.Upload(req)
resp.multipartHelper = multipartHelper
} else {
if progress != nil {
resp.downloadProgress = DownloadProgress(progress)
}
if !formParam.Empty() {
if req.Body != nil {
queryParam.Copy(formParam)
} else {
setBodyBytes(req, resp, []byte(formParam.Encode()))
setContentType(req, "application/x-www-form-urlencoded; charset=UTF-8")
}
}
}
if !queryParam.Empty() {
paramStr := queryParam.Encode()
if strings.IndexByte(rawurl, '?') == -1 {
rawurl = rawurl + "?" + paramStr
} else {
rawurl = rawurl + "&" + paramStr
}
}
u, err := url.Parse(rawurl)
if err != nil {
return nil, err
}
req.URL = u
if host := req.Header.Get("Host"); host != "" {
req.Host = host
}
for _, fn := range delayedFunc {
fn()
}
if resp.client == nil {
resp.client = r.Client()
}
var response *http.Response
if r.flag&Lcost != 0 {
before := time.Now()
response, err = resp.client.Do(req)
after := time.Now()
resp.cost = after.Sub(before)
} else {
response, err = resp.client.Do(req)
}
if err != nil {
return nil, err
}
for _, fn := range lastFunc {
fn()
}
resp.resp = response
if _, ok := resp.client.Transport.(*http.Transport); ok && response.Header.Get("Content-Encoding") == "gzip" && req.Header.Get("Accept-Encoding") != "" {
body, err := gzip.NewReader(response.Body)
if err != nil {
return nil, err
}
response.Body = body
}
// output detail if Debug is enabled
if Debug {
fmt.Println(resp.Dump())
}
return
}
func setBodyBytes(req *http.Request, resp *Resp, data []byte) {
resp.reqBody = data
req.Body = ioutil.NopCloser(bytes.NewReader(data))
req.ContentLength = int64(len(data))
}
func setBodyJson(req *http.Request, resp *Resp, opts *jsonEncOpts, v interface{}) (func(), error) {
var data []byte
switch vv := v.(type) {
case string:
data = []byte(vv)
case []byte:
data = vv
case *bytes.Buffer:
data = vv.Bytes()
default:
if opts != nil {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetIndent(opts.indentPrefix, opts.indentValue)
enc.SetEscapeHTML(opts.escapeHTML)
err := enc.Encode(v)
if err != nil {
return nil, err
}
data = buf.Bytes()
} else {
var err error
data, err = json.Marshal(v)
if err != nil {
return nil, err
}
}
}
setBodyBytes(req, resp, data)
delayedFunc := func() {
setContentType(req, "application/json; charset=UTF-8")
}
return delayedFunc, nil
}
func setBodyXml(req *http.Request, resp *Resp, opts *xmlEncOpts, v interface{}) (func(), error) {
var data []byte
switch vv := v.(type) {
case string:
data = []byte(vv)
case []byte:
data = vv
case *bytes.Buffer:
data = vv.Bytes()
default:
if opts != nil {
var buf bytes.Buffer
enc := xml.NewEncoder(&buf)
enc.Indent(opts.prefix, opts.indent)
err := enc.Encode(v)
if err != nil {
return nil, err
}
data = buf.Bytes()
} else {
var err error
data, err = xml.Marshal(v)
if err != nil {
return nil, err
}
}
}
setBodyBytes(req, resp, data)
delayedFunc := func() {
setContentType(req, "application/xml; charset=UTF-8")
}
return delayedFunc, nil
}
func setContentType(req *http.Request, contentType string) {
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", contentType)
}
}
func setBodyReader(req *http.Request, resp *Resp, rd io.Reader) func() {
var rc io.ReadCloser
switch r := rd.(type) {
case *os.File:
stat, err := r.Stat()
if err == nil {
req.ContentLength = stat.Size()
}
rc = r
case io.ReadCloser:
rc = r
default:
rc = ioutil.NopCloser(rd)
}
bw := &bodyWrapper{
ReadCloser: rc,
limit: 102400,
}
req.Body = bw
lastFunc := func() {
resp.reqBody = bw.buf.Bytes()
}
return lastFunc
}
type bodyWrapper struct {
io.ReadCloser
buf bytes.Buffer
limit int
}
func (b *bodyWrapper) Read(p []byte) (n int, err error) {
n, err = b.ReadCloser.Read(p)
if left := b.limit - b.buf.Len(); left > 0 && n > 0 {
if n <= left {
b.buf.Write(p[:n])
} else {
b.buf.Write(p[:left])
}
}
return
}
type multipartHelper struct {
form url.Values
uploads []FileUpload
dump []byte
uploadProgress UploadProgress
}
func (m *multipartHelper) Upload(req *http.Request) {
pr, pw := io.Pipe()
bodyWriter := multipart.NewWriter(pw)
go func() {
for key, values := range m.form {
for _, value := range values {
bodyWriter.WriteField(key, value)
}
}
var upload func(io.Writer, io.Reader) error
if m.uploadProgress != nil {
var total int64
for _, up := range m.uploads {
if file, ok := up.File.(*os.File); ok {
stat, err := file.Stat()
if err != nil {
continue
}
total += stat.Size()
}
}
var current int64
buf := make([]byte, 1024)
var lastTime time.Time
upload = func(w io.Writer, r io.Reader) error {
for {
n, err := r.Read(buf)
if n > 0 {
_, _err := w.Write(buf[:n])
if _err != nil {
return _err
}
current += int64(n)
if now := time.Now(); now.Sub(lastTime) > 200*time.Millisecond {
lastTime = now
m.uploadProgress(current, total)
}
}
if err == io.EOF {
return nil
}
if err != nil {
return err
}
}
}
}
i := 0
for _, up := range m.uploads {
if up.FieldName == "" {
i++
up.FieldName = "file" + strconv.Itoa(i)
}
fileWriter, err := bodyWriter.CreateFormFile(up.FieldName, up.FileName)
if err != nil {
continue
}
//iocopy
if upload == nil {
io.Copy(fileWriter, up.File)
} else {
if _, ok := up.File.(*os.File); ok {
upload(fileWriter, up.File)
} else {
io.Copy(fileWriter, up.File)
}
}
up.File.Close()
}
bodyWriter.Close()
pw.Close()
}()
req.Header.Set("Content-Type", bodyWriter.FormDataContentType())
req.Body = ioutil.NopCloser(pr)
}
func (m *multipartHelper) Dump() []byte {
if m.dump != nil {
return m.dump
}
var buf bytes.Buffer
bodyWriter := multipart.NewWriter(&buf)
for key, values := range m.form {
for _, value := range values {
m.writeField(bodyWriter, key, value)
}
}
for _, up := range m.uploads {
m.writeFile(bodyWriter, up.FieldName, up.FileName)
}
bodyWriter.Close()
m.dump = buf.Bytes()
return m.dump
}
func (m *multipartHelper) writeField(w *multipart.Writer, fieldname, value string) error {
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"`, fieldname))
p, err := w.CreatePart(h)
if err != nil {
return err
}
_, err = p.Write([]byte(value))
return err
}
func (m *multipartHelper) writeFile(w *multipart.Writer, fieldname, filename string) error {
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
fieldname, filename))
h.Set("Content-Type", "application/octet-stream")
p, err := w.CreatePart(h)
if err != nil {
return err
}
_, err = p.Write([]byte("******"))
return err
}
// Get execute a http GET request
func (r *Req) Get(url string, v ...interface{}) (*Resp, error) {
return r.Do("GET", url, v...)
}
// Post execute a http POST request
func (r *Req) Post(url string, v ...interface{}) (*Resp, error) {
return r.Do("POST", url, v...)
}
// Put execute a http PUT request
func (r *Req) Put(url string, v ...interface{}) (*Resp, error) {
return r.Do("PUT", url, v...)
}
// Patch execute a http PATCH request
func (r *Req) Patch(url string, v ...interface{}) (*Resp, error) {
return r.Do("PATCH", url, v...)
}
// Delete execute a http DELETE request
func (r *Req) Delete(url string, v ...interface{}) (*Resp, error) {
return r.Do("DELETE", url, v...)
}
// Head execute a http HEAD request
func (r *Req) Head(url string, v ...interface{}) (*Resp, error) {
return r.Do("HEAD", url, v...)
}
// Options execute a http OPTIONS request
func (r *Req) Options(url string, v ...interface{}) (*Resp, error) {
return r.Do("OPTIONS", url, v...)
}
// Get execute a http GET request
func Get(url string, v ...interface{}) (*Resp, error) {
return std.Get(url, v...)
}
// Post execute a http POST request
func Post(url string, v ...interface{}) (*Resp, error) {
return std.Post(url, v...)
}
// Put execute a http PUT request
func Put(url string, v ...interface{}) (*Resp, error) {
return std.Put(url, v...)
}
// Head execute a http HEAD request
func Head(url string, v ...interface{}) (*Resp, error) {
return std.Head(url, v...)
}
// Options execute a http OPTIONS request
func Options(url string, v ...interface{}) (*Resp, error) {
return std.Options(url, v...)
}
// Delete execute a http DELETE request
func Delete(url string, v ...interface{}) (*Resp, error) {
return std.Delete(url, v...)
}
// Patch execute a http PATCH request
func Patch(url string, v ...interface{}) (*Resp, error) {
return std.Patch(url, v...)
}
// Do execute request.
func Do(method, url string, v ...interface{}) (*Resp, error) {
return std.Do(method, url, v...)
}

215
backend/vendor/github.com/imroc/req/resp.go generated vendored Normal file
View File

@@ -0,0 +1,215 @@
package req
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"regexp"
"time"
)
// Resp represents a request with it's response
type Resp struct {
r *Req
req *http.Request
resp *http.Response
client *http.Client
cost time.Duration
*multipartHelper
reqBody []byte
respBody []byte
downloadProgress DownloadProgress
err error // delayed error
}
// Request returns *http.Request
func (r *Resp) Request() *http.Request {
return r.req
}
// Response returns *http.Response
func (r *Resp) Response() *http.Response {
return r.resp
}
// Bytes returns response body as []byte
func (r *Resp) Bytes() []byte {
data, _ := r.ToBytes()
return data
}
// ToBytes returns response body as []byte,
// return error if error happend when reading
// the response body
func (r *Resp) ToBytes() ([]byte, error) {
if r.err != nil {
return nil, r.err
}
if r.respBody != nil {
return r.respBody, nil
}
defer r.resp.Body.Close()
respBody, err := ioutil.ReadAll(r.resp.Body)
if err != nil {
r.err = err
return nil, err
}
r.respBody = respBody
return r.respBody, nil
}
// String returns response body as string
func (r *Resp) String() string {
data, _ := r.ToBytes()
return string(data)
}
// ToString returns response body as string,
// return error if error happend when reading
// the response body
func (r *Resp) ToString() (string, error) {
data, err := r.ToBytes()
return string(data), err
}
// ToJSON convert json response body to struct or map
func (r *Resp) ToJSON(v interface{}) error {
data, err := r.ToBytes()
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// ToXML convert xml response body to struct or map
func (r *Resp) ToXML(v interface{}) error {
data, err := r.ToBytes()
if err != nil {
return err
}
return xml.Unmarshal(data, v)
}
// ToFile download the response body to file with optional download callback
func (r *Resp) ToFile(name string) error {
//TODO set name to the suffix of url path if name == ""
file, err := os.Create(name)
if err != nil {
return err
}
defer file.Close()
if r.respBody != nil {
_, err = file.Write(r.respBody)
return err
}
if r.downloadProgress != nil && r.resp.ContentLength > 0 {
return r.download(file)
}
defer r.resp.Body.Close()
_, err = io.Copy(file, r.resp.Body)
return err
}
func (r *Resp) download(file *os.File) error {
p := make([]byte, 1024)
b := r.resp.Body
defer b.Close()
total := r.resp.ContentLength
var current int64
var lastTime time.Time
for {
l, err := b.Read(p)
if l > 0 {
_, _err := file.Write(p[:l])
if _err != nil {
return _err
}
current += int64(l)
if now := time.Now(); now.Sub(lastTime) > 200*time.Millisecond {
lastTime = now
r.downloadProgress(current, total)
}
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
var regNewline = regexp.MustCompile(`\n|\r`)
func (r *Resp) autoFormat(s fmt.State) {
req := r.req
if r.r.flag&Lcost != 0 {
fmt.Fprint(s, req.Method, " ", req.URL.String(), " ", r.cost)
} else {
fmt.Fprint(s, req.Method, " ", req.URL.String())
}
// test if it is should be outputed pretty
var pretty bool
var parts []string
addPart := func(part string) {
if part == "" {
return
}
parts = append(parts, part)
if !pretty && regNewline.MatchString(part) {
pretty = true
}
}
if r.r.flag&LreqBody != 0 { // request body
addPart(string(r.reqBody))
}
if r.r.flag&LrespBody != 0 { // response body
addPart(r.String())
}
for _, part := range parts {
if pretty {
fmt.Fprint(s, "\n")
}
fmt.Fprint(s, " ", part)
}
}
func (r *Resp) miniFormat(s fmt.State) {
req := r.req
if r.r.flag&Lcost != 0 {
fmt.Fprint(s, req.Method, " ", req.URL.String(), " ", r.cost)
} else {
fmt.Fprint(s, req.Method, " ", req.URL.String())
}
if r.r.flag&LreqBody != 0 && len(r.reqBody) > 0 { // request body
str := regNewline.ReplaceAllString(string(r.reqBody), " ")
fmt.Fprint(s, " ", str)
}
if r.r.flag&LrespBody != 0 && r.String() != "" { // response body
str := regNewline.ReplaceAllString(r.String(), " ")
fmt.Fprint(s, " ", str)
}
}
// Format fort the response
func (r *Resp) Format(s fmt.State, verb rune) {
if r == nil || r.req == nil {
return
}
if s.Flag('+') { // include header and format pretty.
fmt.Fprint(s, r.Dump())
} else if s.Flag('-') { // keep all informations in one line.
r.miniFormat(s)
} else { // auto
r.autoFormat(s)
}
}

236
backend/vendor/github.com/imroc/req/setting.go generated vendored Normal file
View File

@@ -0,0 +1,236 @@
package req
import (
"crypto/tls"
"errors"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"time"
)
// create a default client
func newClient() *http.Client {
jar, _ := cookiejar.New(nil)
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Jar: jar,
Transport: transport,
Timeout: 2 * time.Minute,
}
}
// Client return the default underlying http client
func (r *Req) Client() *http.Client {
if r.client == nil {
r.client = newClient()
}
return r.client
}
// Client return the default underlying http client
func Client() *http.Client {
return std.Client()
}
// SetClient sets the underlying http.Client.
func (r *Req) SetClient(client *http.Client) {
r.client = client // use default if client == nil
}
// SetClient sets the default http.Client for requests.
func SetClient(client *http.Client) {
std.SetClient(client)
}
// SetFlags control display format of *Resp
func (r *Req) SetFlags(flags int) {
r.flag = flags
}
// SetFlags control display format of *Resp
func SetFlags(flags int) {
std.SetFlags(flags)
}
// Flags return output format for the *Resp
func (r *Req) Flags() int {
return r.flag
}
// Flags return output format for the *Resp
func Flags() int {
return std.Flags()
}
func (r *Req) getTransport() *http.Transport {
trans, _ := r.Client().Transport.(*http.Transport)
return trans
}
// EnableInsecureTLS allows insecure https
func (r *Req) EnableInsecureTLS(enable bool) {
trans := r.getTransport()
if trans == nil {
return
}
if trans.TLSClientConfig == nil {
trans.TLSClientConfig = &tls.Config{}
}
trans.TLSClientConfig.InsecureSkipVerify = enable
}
func EnableInsecureTLS(enable bool) {
std.EnableInsecureTLS(enable)
}
// EnableCookieenable or disable cookie manager
func (r *Req) EnableCookie(enable bool) {
if enable {
jar, _ := cookiejar.New(nil)
r.Client().Jar = jar
} else {
r.Client().Jar = nil
}
}
// EnableCookieenable or disable cookie manager
func EnableCookie(enable bool) {
std.EnableCookie(enable)
}
// SetTimeout sets the timeout for every request
func (r *Req) SetTimeout(d time.Duration) {
r.Client().Timeout = d
}
// SetTimeout sets the timeout for every request
func SetTimeout(d time.Duration) {
std.SetTimeout(d)
}
// SetProxyUrl set the simple proxy with fixed proxy url
func (r *Req) SetProxyUrl(rawurl string) error {
trans := r.getTransport()
if trans == nil {
return errors.New("req: no transport")
}
u, err := url.Parse(rawurl)
if err != nil {
return err
}
trans.Proxy = http.ProxyURL(u)
return nil
}
// SetProxyUrl set the simple proxy with fixed proxy url
func SetProxyUrl(rawurl string) error {
return std.SetProxyUrl(rawurl)
}
// SetProxy sets the proxy for every request
func (r *Req) SetProxy(proxy func(*http.Request) (*url.URL, error)) error {
trans := r.getTransport()
if trans == nil {
return errors.New("req: no transport")
}
trans.Proxy = proxy
return nil
}
// SetProxy sets the proxy for every request
func SetProxy(proxy func(*http.Request) (*url.URL, error)) error {
return std.SetProxy(proxy)
}
type jsonEncOpts struct {
indentPrefix string
indentValue string
escapeHTML bool
}
func (r *Req) getJSONEncOpts() *jsonEncOpts {
if r.jsonEncOpts == nil {
r.jsonEncOpts = &jsonEncOpts{escapeHTML: true}
}
return r.jsonEncOpts
}
// SetJSONEscapeHTML specifies whether problematic HTML characters
// should be escaped inside JSON quoted strings.
// The default behavior is to escape &, <, and > to \u0026, \u003c, and \u003e
// to avoid certain safety problems that can arise when embedding JSON in HTML.
//
// In non-HTML settings where the escaping interferes with the readability
// of the output, SetEscapeHTML(false) disables this behavior.
func (r *Req) SetJSONEscapeHTML(escape bool) {
opts := r.getJSONEncOpts()
opts.escapeHTML = escape
}
// SetJSONEscapeHTML specifies whether problematic HTML characters
// should be escaped inside JSON quoted strings.
// The default behavior is to escape &, <, and > to \u0026, \u003c, and \u003e
// to avoid certain safety problems that can arise when embedding JSON in HTML.
//
// In non-HTML settings where the escaping interferes with the readability
// of the output, SetEscapeHTML(false) disables this behavior.
func SetJSONEscapeHTML(escape bool) {
std.SetJSONEscapeHTML(escape)
}
// SetJSONIndent instructs the encoder to format each subsequent encoded
// value as if indented by the package-level function Indent(dst, src, prefix, indent).
// Calling SetIndent("", "") disables indentation.
func (r *Req) SetJSONIndent(prefix, indent string) {
opts := r.getJSONEncOpts()
opts.indentPrefix = prefix
opts.indentValue = indent
}
// SetJSONIndent instructs the encoder to format each subsequent encoded
// value as if indented by the package-level function Indent(dst, src, prefix, indent).
// Calling SetIndent("", "") disables indentation.
func SetJSONIndent(prefix, indent string) {
std.SetJSONIndent(prefix, indent)
}
type xmlEncOpts struct {
prefix string
indent string
}
func (r *Req) getXMLEncOpts() *xmlEncOpts {
if r.xmlEncOpts == nil {
r.xmlEncOpts = &xmlEncOpts{}
}
return r.xmlEncOpts
}
// SetXMLIndent sets the encoder to generate XML in which each element
// begins on a new indented line that starts with prefix and is followed by
// one or more copies of indent according to the nesting depth.
func (r *Req) SetXMLIndent(prefix, indent string) {
opts := r.getXMLEncOpts()
opts.prefix = prefix
opts.indent = indent
}
// SetXMLIndent sets the encoder to generate XML in which each element
// begins on a new indented line that starts with prefix and is followed by
// one or more copies of indent according to the nesting depth.
func SetXMLIndent(prefix, indent string) {
std.SetXMLIndent(prefix, indent)
}

View File

@@ -1,21 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/modern-go/concurrent"
packages = ["."]
revision = "e0a39a4cb4216ea8db28e22a69f4ec25610d513a"
version = "1.0.0"
[[projects]]
name = "github.com/modern-go/reflect2"
packages = ["."]
revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd"
version = "1.0.1"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "ea54a775e5a354cb015502d2e7aa4b74230fc77e894f34a838b268c25ec8eeb8"
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -1,15 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/modern-go/concurrent"
packages = ["."]
revision = "e0a39a4cb4216ea8db28e22a69f4ec25610d513a"
version = "1.0.0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "daee8a88b3498b61c5640056665b8b9eea062006f5e596bbb6a3ed9119a11ec7"
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -40,6 +40,8 @@ github.com/hashicorp/hcl/hcl/token
github.com/hashicorp/hcl/json/parser
github.com/hashicorp/hcl/json/scanner
github.com/hashicorp/hcl/json/token
# github.com/imroc/req v0.2.4
github.com/imroc/req
# github.com/json-iterator/go v1.1.6
github.com/json-iterator/go
# github.com/jtolds/gls v4.20.0+incompatible

View File

@@ -6,25 +6,25 @@ services:
environment:
CRAWLAB_API_ADDRESS: "http://localhost:8000" # backend API address 后端 API 地址,设置为 http://<宿主机IP>:<端口>,端口为映射出来的端口
CRAWLAB_SERVER_MASTER: "Y" # whether to be master node 是否为主节点,主节点为 Y工作节点为 N
CRAWLAB_MONGO_HOST: "mongo1" # MongoDB host address MongoDB 的地址,在 docker compose 网络中,直接引用服务名称
CRAWLAB_MONGO_HOST: "mongo" # MongoDB host address MongoDB 的地址,在 docker compose 网络中,直接引用服务名称
CRAWLAB_REDIS_ADDRESS: "redis" # Redis host address Redis 的地址,在 docker compose 网络中,直接引用服务名称
ports:
- "8080:8080" # frontend port mapping 前端端口映射
- "8000:8000" # backend port mapping 后端端口映射
depends_on:
- mongo1
- mongo
- redis
worker:
image: tikazyq/crawlab:latest
container_name: worker
environment:
CRAWLAB_SERVER_MASTER: "N"
CRAWLAB_MONGO_HOST: "mongo1"
CRAWLAB_MONGO_HOST: "mongo"
CRAWLAB_REDIS_ADDRESS: "redis"
depends_on:
- mongo1
- mongo
- redis
mongo1:
mongo:
image: mongo:latest
restart: always
# volumes:

View File

@@ -1,6 +1,6 @@
{
"name": "crawlab",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --ip=0.0.0.0 --mode=development",

View File

@@ -7,15 +7,57 @@
<font-awesome-icon :icon="['fa', 'arrow-left']"/>
{{$t('Back')}}
</el-button>
<el-button v-if="showFile" type="primary" size="small" style="margin-right: 10px;" @click="onFileSave">
<el-popover v-model="isShowDelete">
<el-button size="small" type="default" @click="() => this.isShowDelete = false">
{{$t('Cancel')}}
</el-button>
<el-button size="small" type="danger" @click="onFileDelete">
{{$t('Confirm')}}
</el-button>
<template slot="reference">
<el-button v-if="currentPath !== ''" type="danger" size="small" style="margin-right: 10px;"
@click="() => this.isShowDelete = true">
<font-awesome-icon :icon="['fa', 'trash']"/>
{{$t('Remove')}}
</el-button>
</template>
</el-popover>
<el-popover v-model="isShowRename">
<el-input v-model="name" :placeholder="$t('Name')" style="margin-bottom: 10px"/>
<div style="text-align: right">
<el-button size="small" type="warning" @click="onRenameFile">
{{$t('Confirm')}}
</el-button>
</div>
<template slot="reference">
<el-button v-if="showFile" type="warning" size="small" style="margin-right: 10px;"
@click="onOpenRename">
<font-awesome-icon :icon="['fa', 'redo']"/>
{{$t('Rename')}}
</el-button>
</template>
</el-popover>
<el-button v-if="showFile" type="success" size="small" style="margin-right: 10px;" @click="onFileSave">
<font-awesome-icon :icon="['fa', 'save']"/>
{{$t('Save')}}
</el-button>
<!--TODO: new files/directories-->
<el-button v-if="false" type="primary" size="small" style="margin-right: 10px;">
<font-awesome-icon :icon="['fa', 'file-alt']"/>
{{$t('New File')}}
</el-button>
<el-popover v-if="!showFile" v-model="isShowAdd" @hide="onHideAdd">
<el-input v-model="name" :placeholder="$t('Name')"/>
<div class="add-type-list">
<el-button size="small" type="success" icon="el-icon-document-add" @click="onAddFile">
{{$t('File')}}
</el-button>
<el-button size="small" type="primary" icon="el-icon-folder-add" @click="onAddDir">
{{$t('Directory')}}
</el-button>
</div>
<template slot="reference">
<el-button type="primary" size="small" style="margin-right: 10px;">
<font-awesome-icon :icon="['fa', 'plus']"/>
{{$t('Create')}}
</el-button>
</template>
</el-popover>
</div>
<!--./back-->
@@ -70,7 +112,11 @@ export default {
return {
code: 'var hello = \'world\'',
isEdit: false,
showFile: false
showFile: false,
name: '',
isShowAdd: false,
isShowDelete: false,
isShowRename: false
}
},
computed: {
@@ -101,6 +147,7 @@ export default {
this.$store.commit('file/SET_CURRENT_PATH', item.path)
this.$store.dispatch('file/getFileContent', { path: item.path })
}
this.$st.sendEv('爬虫详情', '文件', '点击')
},
onBack () {
const sep = '/'
@@ -109,16 +156,71 @@ export default {
const path = arr.join(sep)
this.$store.commit('file/SET_CURRENT_PATH', path)
this.$store.dispatch('file/getFileList', { path: this.currentPath })
this.$st.sendEv('爬虫详情', '文件', '回退')
},
onFileSave () {
this.$store.dispatch('file/saveFileContent', { path: this.currentPath })
.then(() => {
this.$message.success(this.$t('Saved file successfully'))
})
async onFileSave () {
await this.$store.dispatch('file/saveFileContent', { path: this.currentPath })
this.$message.success(this.$t('Saved file successfully'))
this.$st.sendEv('爬虫详情', '文件', '保存')
},
onBackFile () {
this.showFile = false
this.onBack()
},
onHideAdd () {
this.name = ''
},
async onAddFile () {
if (!this.name) {
this.$message.error(this.$t('Name cannot be empty'))
return
}
const path = this.currentPath + '/' + this.name
await this.$store.dispatch('file/addFile', { path })
await this.$store.dispatch('file/getFileList', { path: this.currentPath })
this.isShowAdd = false
this.showFile = true
this.$store.commit('file/SET_FILE_CONTENT', '')
this.$store.commit('file/SET_CURRENT_PATH', path)
await this.$store.dispatch('file/getFileContent', { path })
this.$st.sendEv('爬虫详情', '文件', '添加')
},
async onAddDir () {
if (!this.name) {
this.$message.error(this.$t('Name cannot be empty'))
return
}
await this.$store.dispatch('file/addDir', { path: this.currentPath + '/' + this.name })
await this.$store.dispatch('file/getFileList', { path: this.currentPath })
this.isShowAdd = false
this.$st.sendEv('爬虫详情', '文件', '添加')
},
async onFileDelete () {
await this.$store.dispatch('file/deleteFile', { path: this.currentPath })
this.$message.success(this.$t('Deleted successfully'))
this.isShowDelete = false
this.onBackFile()
this.$st.sendEv('爬虫详情', '文件', '删除')
},
onOpenRename () {
this.isShowRename = true
const arr = this.currentPath.split('/')
this.name = arr[arr.length - 1]
},
async onRenameFile () {
let newPath
if (this.currentPath.split('/').length === 1) {
newPath = this.name
} else {
const arr = this.currentPath.split('/')
newPath = arr[0] + '/' + this.name
}
await this.$store.dispatch('file/renameFile', { path: this.currentPath, newPath })
this.$store.commit('file/SET_CURRENT_PATH', newPath)
this.$message.success(this.$t('Renamed successfully'))
this.isShowRename = false
this.$st.sendEv('爬虫详情', '文件', '重命名')
}
},
created () {
@@ -236,4 +338,14 @@ export default {
font-size: 14px;
color: rgba(3, 47, 98, 1);
}
.add-type-list {
text-align: right;
margin-top: 10px;
}
.add-type {
cursor: pointer;
font-weight: bolder;
}
</style>

View File

@@ -21,7 +21,8 @@
<el-form-item :label="$t('Source Folder')">
<el-input v-model="spiderForm.src" :placeholder="$t('Source Folder')" disabled></el-input>
</el-form-item>
<el-form-item v-if="spiderForm.type === 'customized'" :label="$t('Execute Command')" prop="cmd" required :inline-message="true">
<el-form-item v-if="spiderForm.type === 'customized'" :label="$t('Execute Command')" prop="cmd" required
:inline-message="true">
<el-input v-model="spiderForm.cmd" :placeholder="$t('Execute Command')"
:disabled="isView"></el-input>
</el-form-item>
@@ -45,20 +46,41 @@
</el-select>
</el-form-item>
<el-form-item :label="$t('Remark')">
<el-input v-model="spiderForm.remark"/>
<el-input type="textarea" v-model="spiderForm.remark" :placeholder="$t('Remark')"/>
</el-form-item>
</el-form>
</el-row>
<el-row class="button-container" v-if="!isView">
<el-button size="small" v-if="isShowRun" type="danger" @click="onCrawl">{{$t('Run')}}</el-button>
<el-button size="small" type="success" @click="onSave">{{$t('Save')}}</el-button>
<el-upload
v-if="spiderForm.type === 'customized'"
:action="$request.baseUrl + `/spiders/${spiderForm._id}/upload`"
:headers="{Authorization:token}"
:on-progress="() => this.uploadLoading = true"
:on-error="onUploadError"
:on-success="onUploadSuccess"
:file-list="fileList"
style="display:inline-block;margin-right:10px"
>
<el-button size="normal" type="primary" icon="el-icon-upload" v-loading="uploadLoading">
{{$t('Upload')}}
</el-button>
</el-upload>
<el-button size="normal" v-if="isShowRun" type="danger" @click="onCrawl"
icon="el-icon-video-play">
{{$t('Run')}}
</el-button>
<el-button size="normal" type="success" @click="onSave"
icon="el-icon-check">
{{$t('Save')}}
</el-button>
</el-row>
</div>
</template>
<script>
import {
mapState
mapState,
mapGetters
} from 'vuex'
import CrawlConfirmDialog from '../Common/CrawlConfirmDialog'
@@ -88,6 +110,8 @@ export default {
callback()
}
return {
uploadLoading: false,
fileList: [],
crawlConfirmDialogVisible: false,
cmdRule: [
{ message: 'Execute Command should not be empty', required: true }
@@ -101,6 +125,9 @@ export default {
...mapState('spider', [
'spiderForm'
]),
...mapGetters('user', [
'token'
]),
isShowRun () {
if (this.spiderForm.type === 'customized') {
return !!this.spiderForm.cmd
@@ -143,6 +170,16 @@ export default {
},
onSiteSelect (item) {
this.spiderForm.site = item._id
},
onUploadSuccess () {
this.$store.dispatch('file/getFileList', this.spiderForm.src)
this.uploadLoading = false
this.$message.success(this.$t('Uploaded spider files successfully'))
},
onUploadError () {
this.uploadLoading = false
}
}
}
@@ -162,4 +199,8 @@ export default {
.el-autocomplete {
width: 100%;
}
.info-view >>> .el-upload-list {
display: none;
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<div class="node-installation">
<el-form inline>
<el-form-item v-if="!isShowInstalled">
<el-autocomplete
v-model="depName"
style="width: 240px"
:placeholder="$t('Search Dependencies')"
:fetchSuggestions="fetchAllDepList"
minlength="2"
@select="onSearch"
/>
</el-form-item>
<el-form-item>
<el-button icon="el-icon-search" type="success" @click="onSearch">
{{$t('Search')}}
</el-button>
</el-form-item>
<el-form-item>
<el-checkbox v-model="isShowInstalled" :label="$t('Show installed')" @change="onIsShowInstalledChange"/>
</el-form-item>
</el-form>
<el-tabs v-model="activeTab">
<el-tab-pane v-for="lang in langList" :key="lang.name" :label="lang.name" :name="lang.executable_name"/>
</el-tabs>
<template v-if="activeLang.installed">
<el-table
height="calc(100vh - 280px)"
:data="computedDepList"
:empty-text="depName ? $t('No Data') : $t('Please search dependencies')"
v-loading="loading"
border
>
<el-table-column
:label="$t('Name')"
prop="name"
width="180"
/>
<el-table-column
:label="$t('Latest Version')"
prop="version"
width="100"
/>
<el-table-column
v-if="!isShowInstalled"
:label="$t('Description')"
prop="description"
/>
<el-table-column
:label="$t('Action')"
>
<template slot-scope="scope">
<el-button
size="mini"
:type="scope.row.installed ? 'danger' : 'primary' "
>
{{scope.row.installed ? $t('Uninstall') : $t('Install')}}
</el-button>
</template>
</el-table-column>
</el-table>
</template>
<template v-else>
<div class="install-wrapper">
<h3>{{activeLang.name + $t(' is not installed, do you want to install it?')}}</h3>
<el-button type="primary" style="width: 240px;font-weight: bolder;font-size: 18px">
{{$t('Install')}}
</el-button>
</div>
</template>
</div>
</template>
<script>
import {
mapState
} from 'vuex'
export default {
name: 'NodeInstallation',
data () {
return {
activeTab: '',
langList: [],
depName: '',
depList: [],
loading: false,
isShowInstalled: false,
installedDepList: []
}
},
computed: {
...mapState('node', [
'nodeForm'
]),
activeLang () {
for (let i = 0; i < this.langList.length; i++) {
if (this.langList[i].executable_name === this.activeTab) {
return this.langList[i]
}
}
return {}
},
computedDepList () {
if (this.isShowInstalled) {
return this.installedDepList
} else {
return this.depList
}
}
},
methods: {
async getDepList () {
this.loading = true
const res = await this.$request.get(`/nodes/${this.nodeForm._id}/deps`, {
lang: this.activeLang.executable_name,
dep_name: this.depName
})
this.loading = false
this.depList = res.data.data.sort((a, b) => a.name > b.name ? 1 : -1)
},
async getInstalledDepList () {
this.loading = true
const res = await this.$request.get(`/nodes/${this.nodeForm._id}/deps/installed`, {
lang: this.activeLang.executable_name
})
this.loading = false
this.installedDepList = res.data.data
},
async fetchAllDepList (queryString, callback) {
const res = await this.$request.get('/system/deps', {
lang: this.activeLang.executable_name,
dep_name: queryString
})
callback(res.data.data ? res.data.data.map(d => {
return { value: d, label: d }
}) : [])
},
onSearch () {
if (!this.isShowInstalled) {
this.getDepList()
} else {
this.getInstalledDepList()
}
},
onIsShowInstalledChange (val) {
if (val) {
this.getInstalledDepList()
}
}
},
async created () {
const arr = this.$route.path.split('/')
const id = arr[arr.length - 1]
const res = await this.$request.get(`/nodes/${id}/langs`)
this.langList = res.data.data
this.activeTab = this.langList[0].executable_name || ''
}
}
</script>
<style scoped>
</style>

View File

@@ -67,7 +67,7 @@ export default {
.log-view {
margin-top: 0!important;
min-height: 100%;
overflow-y: scroll;
overflow-y: scroll!important;
list-style: none;
color: #A9B7C6;
background: #2B2B2B;

View File

@@ -40,6 +40,7 @@ export default {
// 操作
Add: '添加',
Create: '创建',
Run: '运行',
Deploy: '部署',
Save: '保存',
@@ -63,6 +64,9 @@ export default {
'Item Threshold': '子项阈值',
'Back': '返回',
'New File': '新建文件',
'Rename': '重命名',
'Install': '安装',
'Uninstall': '卸载',
// 主页
'Total Tasks': '总任务数',
@@ -83,6 +87,8 @@ export default {
'Node Network': '节点拓扑图',
'Master': '主节点',
'Worker': '工作节点',
'Installation': '安装',
'Search Dependencies': '搜索依赖',
// 节点列表
'IP': 'IP地址',
@@ -234,6 +240,9 @@ export default {
// 文件
'Choose Folder': '选择文件',
'File': '文件',
'Folder': '文件夹',
'Directory': '目录',
// 导入
'Import Spider': '导入爬虫',
@@ -257,11 +266,14 @@ export default {
'ARCH': '操作架构',
'Number of CPU': 'CPU数',
'Executables': '执行文件',
'Latest Version': '最新版本',
// 弹出框
'Notification': '提示',
'Are you sure to delete this node?': '你确定要删除该节点?',
'Are you sure to run this spider?': '你确定要运行该爬虫?',
'Added spider successfully': '成功添加爬虫',
'Uploaded spider files successfully': '成功上传爬虫文件',
'Node info has been saved successfully': '节点信息已成功保存',
'A task has been scheduled successfully': '已经成功派发一个任务',
'Are you sure to delete this spider?': '你确定要删除该爬虫?',
@@ -277,10 +289,17 @@ export default {
'username already exists': '用户名已存在',
'Deleted successfully': '成功删除',
'Saved successfully': '成功保存',
'Please zip your spider files from the root directory': '爬虫文件请从根目录下开始压缩。',
'Renamed successfully': '重命名保存',
'You can click "Add" to create an empty spider and upload files later.': '您可以点击"添加"按钮创建空的爬虫,之后再上传文件。',
'OR, you can also click "Upload" and upload a zip file containing your spider project.': '或者,您也可以点击"上传"按钮并上传一个包含爬虫项目的 zip 文件。',
'NOTE: When uploading a zip file, please zip your spider files from the ROOT DIRECTORY.': '注意: 上传 zip 文件时,请从 根目录 下开始压缩爬虫文件。',
'English': 'English',
'Are you sure to delete the schedule task?': '确定删除定时任务?',
' is not installed, do you want to install it?': ' 还没有安装,您是否打算安装它?',
'Disclaimer': '免责声明',
'Please search dependencies': '请搜索依赖',
'No Data': '暂无数据',
'Show installed': '只看已安装',
// 登录
'Sign in': '登录',

View File

@@ -25,8 +25,9 @@ const actions = {
const { path } = payload
const spiderId = rootState.spider.spiderForm._id
commit('SET_CURRENT_PATH', path)
request.get(`/spiders/${spiderId}/dir`, { path })
return request.get(`/spiders/${spiderId}/dir`, { path })
.then(response => {
if (!response.data.data) response.data.data = []
commit(
'SET_FILE_LIST',
response.data.data
@@ -38,10 +39,35 @@ const actions = {
getFileContent ({ commit, rootState }, payload) {
const { path } = payload
const spiderId = rootState.spider.spiderForm._id
request.get(`/spiders/${spiderId}/file`, { path })
return request.get(`/spiders/${spiderId}/file`, { path })
.then(response => {
commit('SET_FILE_CONTENT', response.data.data)
})
},
saveFileContent ({ state, rootState }, payload) {
const { path } = payload
const spiderId = rootState.spider.spiderForm._id
return request.post(`/spiders/${spiderId}/file`, { path, content: state.fileContent })
},
addFile ({ rootState }, payload) {
const { path } = payload
const spiderId = rootState.spider.spiderForm._id
return request.put(`/spiders/${spiderId}/file`, { path })
},
addDir ({ rootState }, payload) {
const { path } = payload
const spiderId = rootState.spider.spiderForm._id
return request.put(`/spiders/${spiderId}/dir`, { path })
},
deleteFile ({ rootState }, payload) {
const { path } = payload
const spiderId = rootState.spider.spiderForm._id
return request.delete(`/spiders/${spiderId}/file`, { path })
},
renameFile ({ rootState }, payload) {
const { path, newPath } = payload
const spiderId = rootState.spider.spiderForm._id
return request.post(`/spiders/${spiderId}/file/rename`, { path, new_path: newPath })
}
}

View File

@@ -3,7 +3,7 @@ import request from '../../api/request'
const state = {
// NodeList
nodeList: [],
nodeForm: { _id: {} },
nodeForm: {},
// spider to deploy/run
activeSpider: {}

View File

@@ -174,6 +174,9 @@ const actions = {
addConfigSpider ({ state }) {
return request.put(`/config_spiders`, state.spiderForm)
},
addSpider ({ state }) {
return request.put(`/spiders`, state.spiderForm)
},
async getTemplateList ({ state, commit }) {
const res = await request.get(`/config_spiders_templates`)
commit('SET_TEMPLATE_LIST', res.data.data)

View File

@@ -12,7 +12,8 @@ const state = {
// filter
filter: {
node_id: '',
spider_id: ''
spider_id: '',
status: ''
},
// pagination
pageNum: 1,
@@ -89,7 +90,8 @@ const actions = {
page_num: state.pageNum,
page_size: state.pageSize,
node_id: state.filter.node_id || undefined,
spider_id: state.filter.spider_id || undefined
spider_id: state.filter.spider_id || undefined,
status: state.filter.status || undefined
})
.then(response => {
commit('SET_TASK_LIST', response.data.data || [])

View File

@@ -8,9 +8,6 @@
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown" class="user-dropdown">
<!-- <el-dropdown-item>-->
<!-- <span style="display:block;">v0.4.1</span>-->
<!-- </el-dropdown-item>-->
<el-dropdown-item>
<span style="display:block;" @click="logout">{{$t('Logout')}}</span>
</el-dropdown-item>
@@ -31,7 +28,7 @@
</el-dropdown-menu>
</el-dropdown>
<el-dropdown class="documentation right">
<a href="https://docs.crawlab.cn" target="_blank">
<a href="http://docs.crawlab.cn" target="_blank">
<font-awesome-icon :icon="['far', 'question-circle']"/>
<span style="margin-left: 5px;">{{$t('Documentation')}}</span>
</a>

View File

@@ -13,6 +13,9 @@
<el-tab-pane :label="$t('Overview')" name="overview">
<node-overview></node-overview>
</el-tab-pane>
<el-tab-pane :label="$t('Installation')" name="installation">
<node-installation></node-installation>
</el-tab-pane>
<el-tab-pane :label="$t('Deployed Spiders')" name="spiders" v-if="false">
{{$t('Deployed Spiders')}}
</el-tab-pane>
@@ -25,11 +28,13 @@ import {
mapState
} from 'vuex'
import NodeOverview from '../../components/Overview/NodeOverview'
import NodeInstallation from '../../components/Node/NodeInstallation'
export default {
name: 'NodeDetail',
components: {
NodeOverview
NodeOverview,
NodeInstallation
},
data () {
return {
@@ -43,7 +48,9 @@ export default {
])
},
methods: {
onTabClick () {
onTabClick (name) {
if (name === 'installation') {
}
},
onNodeChange (id) {
this.$router.push(`/nodes/${id}`)

View File

@@ -34,6 +34,50 @@
:visible.sync="addDialogVisible"
:before-close="onAddDialogClose">
<el-tabs :active-name="spiderType">
<el-tab-pane name="customized" :label="$t('Customized')">
<el-form :model="spiderForm" ref="addCustomizedForm" inline-message label-width="120px">
<el-form-item :label="$t('Spider Name')" prop="name" required>
<el-input v-model="spiderForm.name" :placeholder="$t('Spider Name')"/>
</el-form-item>
<el-form-item :label="$t('Display Name')" prop="display_name" required>
<el-input v-model="spiderForm.display_name" :placeholder="$t('Display Name')"/>
</el-form-item>
<el-form-item :label="$t('Execute Command')" prop="cmd" required>
<el-input v-model="spiderForm.cmd" :placeholder="$t('Execute Command')"/>
</el-form-item>
<el-form-item :label="$t('Results')" prop="col" required>
<el-input v-model="spiderForm.col" :placeholder="$t('Results')"/>
</el-form-item>
<el-form-item :label="$t('Upload Zip File')" label-width="120px" name="site">
<el-upload
:action="$request.baseUrl + '/spiders'"
:data="uploadForm"
:headers="{Authorization:token}"
:on-success="onUploadSuccess"
:file-list="fileList">
<el-button size="normal" type="primary" icon="el-icon-upload"
style="width: 160px; font-size: 18px;font-weight: bolder">
{{$t('Upload')}}
</el-button>
</el-upload>
</el-form-item>
</el-form>
<el-alert
type="warning"
:closable="false"
style="margin-bottom: 10px"
>
<p>{{$t('You can click "Add" to create an empty spider and upload files later.')}}</p>
<p>{{$t('OR, you can also click "Upload" and upload a zip file containing your spider project.')}}</p>
<p style="font-weight: bolder">
<i class="fa fa-exclamation-triangle"></i> {{$t('NOTE: When uploading a zip file, please zip your' +
' spider files from the ROOT DIRECTORY.')}}
</p>
</el-alert>
<div class="actions">
<el-button type="primary" @click="onAddCustomized">{{$t('Add')}}</el-button>
</div>
</el-tab-pane>
<el-tab-pane name="configurable" :label="$t('Configurable')">
<el-form :model="spiderForm" ref="addConfigurableForm" inline-message label-width="120px">
<el-form-item :label="$t('Spider Name')" prop="name" required>
@@ -60,63 +104,10 @@
<el-button type="primary" @click="onAddConfigurable">{{$t('Add')}}</el-button>
</div>
</el-tab-pane>
<el-tab-pane name="customized" :label="$t('Customized')">
<el-form :model="spiderForm" ref="addCustomizedForm" inline-message>
<el-form-item :label="$t('Upload Zip File')" label-width="120px" name="site">
<el-upload
:action="$request.baseUrl + '/spiders'"
:headers="{Authorization:token}"
:on-change="onUploadChange"
:on-success="onUploadSuccess"
:file-list="fileList">
<el-button size="small" type="primary" icon="el-icon-upload">{{$t('Upload')}}</el-button>
</el-upload>
</el-form-item>
</el-form>
<el-alert type="error" :title="$t('Please zip your spider files from the root directory')"
:closable="false"></el-alert>
</el-tab-pane>
</el-tabs>
</el-dialog>
<!--./add dialog-->
<!--configurable spider dialog-->
<el-dialog :title="$t('Add Configurable Spider')"
width="40%"
:visible.sync="addConfigurableDialogVisible"
:before-close="onAddConfigurableDialogClose">
<el-form :model="spiderForm" ref="addConfigurableForm" inline-message>
<el-form-item :label="$t('Spider Name')" label-width="120px" prop="name" required>
<el-input :placeholder="$t('Spider Name')" v-model="spiderForm.name"></el-input>
</el-form-item>
<el-form-item :label="$t('Results Collection')" label-width="120px" name="col">
<el-input :placeholder="$t('Results Collection')" v-model="spiderForm.col"></el-input>
</el-form-item>
<el-form-item :label="$t('Site')" label-width="120px" name="site">
<el-autocomplete v-model="spiderForm.site"
:placeholder="$t('Site')"
:fetch-suggestions="fetchSiteSuggestions"
@select="onAddConfigurableSiteSelect">
</el-autocomplete>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="addConfigurableDialogVisible = false">{{$t('Cancel')}}</el-button>
<el-button v-loading="addConfigurableLoading" type="primary"
@click="onAddConfigurableSpider">{{$t('Add')}}</el-button>
</span>
</el-dialog>
<!--./configurable spider dialog-->
<!--customized spider dialog-->
<el-dialog :title="$t('Add Customized Spider')"
width="40%"
:visible.sync="addCustomizedDialogVisible"
:before-close="onAddCustomizedDialogClose">
</el-dialog>
<!--./customized spider dialog-->
<!--crawl confirm dialog-->
<crawl-confirm-dialog
:visible="crawlConfirmDialogVisible"
@@ -154,10 +145,14 @@
<el-button size="small" v-if="false" type="primary" icon="fa fa-download" @click="openImportDialog">
{{$t('Import Spiders')}}
</el-button>
<el-button size="small" type="success"
icon="el-icon-plus"
class="btn add"
@click="onAdd">
<el-button
size="normal"
type="success"
icon="el-icon-plus"
class="btn add"
@click="onAdd"
style="font-weight: bolder"
>
{{$t('Add Spider')}}
</el-button>
@@ -299,8 +294,6 @@ export default {
isEditMode: false,
dialogVisible: false,
addDialogVisible: false,
addConfigurableDialogVisible: false,
addCustomizedDialogVisible: false,
crawlConfirmDialogVisible: false,
activeSpiderId: undefined,
filter: {
@@ -320,7 +313,7 @@ export default {
name: [{ required: true, message: 'Required Field', trigger: 'change' }]
},
fileList: [],
spiderType: 'configurable'
spiderType: 'customized'
}
},
computed: {
@@ -333,7 +326,15 @@ export default {
]),
...mapGetters('user', [
'token'
])
]),
uploadForm () {
return {
name: this.spiderForm.name,
display_name: this.spiderForm.display_name,
col: this.spiderForm.col,
cmd: this.spiderForm.cmd
}
}
},
methods: {
onSpiderTypeChange (val) {
@@ -374,9 +375,19 @@ export default {
})
},
onAddCustomized () {
this.addDialogVisible = false
this.addCustomizedDialogVisible = true
this.$st.sendEv('爬虫列表', '添加爬虫', '自定义爬虫')
this.$refs['addCustomizedForm'].validate(async res => {
if (!res) return
let res2
try {
res2 = await this.$store.dispatch('spider/addSpider')
} catch (e) {
this.$message.error(this.$t('Something wrong happened'))
return
}
await this.$store.dispatch('spider/getSpiderList')
this.$router.push(`/spiders/${res2.data.data._id}`)
this.$st.sendEv('爬虫列表', '添加爬虫', '自定义爬虫')
})
},
onRefresh () {
this.getList()
@@ -510,9 +521,7 @@ export default {
}
})
},
onUploadChange () {
},
onUploadSuccess () {
onUploadSuccess (res) {
// clear fileList
this.fileList = []
@@ -521,8 +530,11 @@ export default {
this.getList()
}, 500)
// close popup
this.addCustomizedDialogVisible = false
// message
this.$message.success(this.$t('Uploaded spider files successfully'))
// navigate to spider detail
this.$router.push(`/spiders/${res.data._id}`)
},
getTime (str) {
if (!str || str.match('^0001')) return 'NA'

View File

@@ -4,6 +4,29 @@
<!--filter-->
<div class="filter">
<div class="left">
<el-form model="filter" label-width="100px" label-position="right" inline>
<el-form-item prop="node_id" :label="$t('Node')">
<el-select v-model="filter.node_id" size="small" :placeholder="$t('Node')" @change="onFilterChange">
<el-option value="" :label="$t('All')"/>
<el-option v-for="node in nodeList" :key="node._id" :value="node._id" :label="node.name"/>
</el-select>
</el-form-item>
<el-form-item prop="spider_id" :label="$t('Spider')">
<el-select v-model="filter.spider_id" size="small" :placeholder="$t('Spider')" @change="onFilterChange">
<el-option value="" :label="$t('All')"/>
<el-option v-for="spider in spiderList" :key="spider._id" :value="spider._id" :label="spider.name"/>
</el-select>
</el-form-item>
<el-form-item prop="status" :label="$t('Status')">
<el-select v-model="filter.status" size="small" :placeholder="$t('Status')" @change="onFilterChange">
<el-option value="" :label="$t('All')"></el-option>
<el-option value="finished" :label="$t('Finished')"></el-option>
<el-option value="running" :label="$t('Running')"></el-option>
<el-option value="error" :label="$t('Error')"></el-option>
<el-option value="cancelled" :label="$t('Cancelled')"></el-option>
</el-select>
</el-form-item>
</el-form>
</div>
<div class="right">
<el-button @click="onRemoveMultipleTask" size="small" type="danger">
@@ -310,6 +333,10 @@ export default {
},
onSelectionChange (val) {
this.multipleSelection = val
},
onFilterChange () {
this.$store.dispatch('task/getTaskList')
this.$st.sendEv('任务列表', '筛选任务')
}
},
created () {

View File

@@ -1336,7 +1336,7 @@ async@^1.5.2:
version "1.5.2"
resolved "http://registry.npm.taobao.org/async/download/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
async@^2.1.4, async@^2.5.0:
async@^2.1.4:
version "2.6.2"
resolved "http://registry.npm.taobao.org/async/download/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381"
dependencies:
@@ -2199,6 +2199,11 @@ commander@^2.18.0, commander@^2.19.0:
version "2.19.0"
resolved "http://registry.npm.taobao.org/commander/download/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
commander@~2.20.3:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commondir@^1.0.1:
version "1.0.1"
resolved "http://registry.npm.taobao.org/commondir/download/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
@@ -4031,10 +4036,11 @@ handle-thing@^2.0.0:
resolved "http://registry.npm.taobao.org/handle-thing/download/handle-thing-2.0.0.tgz#0e039695ff50c93fc288557d696f3c1dc6776754"
handlebars@^4.0.3:
version "4.1.0"
resolved "http://registry.npm.taobao.org/handlebars/download/handlebars-4.1.0.tgz#0d6a6f34ff1f63cecec8423aa4169827bf787c3a"
version "4.5.3"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.5.3.tgz#5cf75bd8714f7605713511a56be7c349becb0482"
integrity sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==
dependencies:
async "^2.5.0"
neo-async "^2.6.0"
optimist "^0.6.1"
source-map "^0.6.1"
optionalDependencies:
@@ -5727,7 +5733,8 @@ minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0:
minimist@~0.0.1:
version "0.0.10"
resolved "http://registry.npm.taobao.org/minimist/download/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=
minipass@^2.2.1, minipass@^2.3.4:
version "2.3.5"
@@ -5863,8 +5870,9 @@ negotiator@0.6.1:
resolved "http://registry.npm.taobao.org/negotiator/download/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
neo-async@^2.5.0, neo-async@^2.6.0:
version "2.6.0"
resolved "http://registry.npm.taobao.org/neo-async/download/neo-async-2.6.0.tgz#b9d15e4d71c6762908654b5183ed38b753340835"
version "2.6.1"
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c"
integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==
nice-try@^1.0.4:
version "1.0.5"
@@ -6205,7 +6213,8 @@ opn@^5.1.0, opn@^5.3.0:
optimist@^0.6.1:
version "0.6.1"
resolved "http://registry.npm.taobao.org/optimist/download/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY=
dependencies:
minimist "~0.0.1"
wordwrap "~0.0.2"
@@ -7745,7 +7754,8 @@ source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7:
source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
version "0.6.1"
resolved "http://registry.npm.taobao.org/source-map/download/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
spdx-correct@^3.0.0:
version "3.1.0"
@@ -8328,13 +8338,21 @@ typedarray@^0.0.6:
version "0.0.6"
resolved "http://registry.npm.taobao.org/typedarray/download/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
uglify-js@3.4.x, uglify-js@^3.1.4:
uglify-js@3.4.x:
version "3.4.9"
resolved "http://registry.npm.taobao.org/uglify-js/download/uglify-js-3.4.9.tgz#af02f180c1207d76432e473ed24a28f4a782bae3"
dependencies:
commander "~2.17.1"
source-map "~0.6.1"
uglify-js@^3.1.4:
version "3.7.3"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.7.3.tgz#f918fce9182f466d5140f24bb0ff35c2d32dcc6a"
integrity sha512-7tINm46/3puUA4hCkKYo4Xdts+JDaVC9ZPRcG8Xw9R4nhO/gZgUM3TENq8IF4Vatk8qCig4MzP/c8G4u2BkVQg==
dependencies:
commander "~2.20.3"
source-map "~0.6.1"
unicode-canonical-property-names-ecmascript@^1.0.4:
version "1.0.4"
resolved "http://registry.npm.taobao.org/unicode-canonical-property-names-ecmascript/download/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
@@ -8862,7 +8880,8 @@ wide-align@^1.1.0:
wordwrap@~0.0.2:
version "0.0.3"
resolved "http://registry.npm.taobao.org/wordwrap/download/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
wordwrap@~1.0.0:
version "1.0.0"