diff --git a/CHANGELOG-zh.md b/CHANGELOG-zh.md index b3420b47..c4f169c9 100644 --- a/CHANGELOG-zh.md +++ b/CHANGELOG-zh.md @@ -1,3 +1,20 @@ +# 0.4.10 (2020-04-21) +### 功能 / 优化 +- **优化日志管理**. 集中化管理日志,储存在 MongoDB,减少对 PubSub 的依赖,允许日志异常检测. +- **自动安装依赖**. 允许从 `requirements.txt` 和 `package.json` 自动安装依赖. +- **API Token**. 允许用户生成 API Token,并利用它们来集成到自己的系统中. +- **Web Hook**. 当任务开始或结束时,触发 Web Hook http 请求到预定义好的 URL. +- **自动生成结果集**. 如果没有设置,自动设置结果集为 `results_`. +- **优化项目列表**. 项目列表中不展示 "No Project". +- **升级 Node.js**. 将 Node.js 版本从 v8.12 升级到 v10.19. +- **定时任务增加运行按钮**. 允许用户在定时任务界面手动运行爬虫任务. + +### Bug 修复 +- **无法注册**. [#670](https://github.com/crawlab-team/crawlab/issues/670) +- **爬虫定时任务标签 Cron 表达式显示秒**. [#678](https://github.com/crawlab-team/crawlab/issues/678) +- **爬虫每日数据缺失**. [#684](https://github.com/crawlab-team/crawlab/issues/684) +- **结果数量未即时更新**. [#689](https://github.com/crawlab-team/crawlab/issues/689) + # 0.4.9 (2020-03-31) ### 功能 / 优化 - **挑战**. 用户可以完成不同的趣味挑战.. @@ -10,6 +27,7 @@ - **支持任务重试**. 允许任务重新触发历史任务. ### Bug 修复 +- **无法注册**. [#670](https://github.com/crawlab-team/crawlab/issues/670) - **CLI 无法在 Windows 上使用**. [#580](https://github.com/crawlab-team/crawlab/issues/580) - **重新上传错误**. [#643](https://github.com/crawlab-team/crawlab/issues/643) [#640](https://github.com/crawlab-team/crawlab/issues/640) - **上传丢失文件目录**. [#646](https://github.com/crawlab-team/crawlab/issues/646) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae15b56d..a697e655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +# 0.4.10 (2020-04-21) +### Features / Enhancement +- **Enhanced Log Management**. Centralizing log storage in MongoDB, reduced the dependency of PubSub, allowing log error detection. +- **API Token**. Allow users to generate API tokens and use them to integrate into their own systems. +- **Web Hook**. Trigger a Web Hook http request to pre-defined URL when a task starts or finishes. +- **Auto Install Dependencies**. Allow installing dependencies automatically from `requirements.txt` or `package.json`. +- **Auto Results Collection**. Set results collection to `results_` if it is not set. +- **Optimized Project List**. Not display "No Project" item in the project list. +- **Upgrade Node.js**. Upgrade Node.js version from v8.12 to v10.19. +- **Add Run Button in Schedule Page**. Allow users to manually run task in Schedule Page. + +### Bug Fixes +- **Cannot register**. [#670](https://github.com/crawlab-team/crawlab/issues/670) +- **Spider schedule tab cron expression shows second**. [#678](https://github.com/crawlab-team/crawlab/issues/678) +- **Missing daily stats in spider**. [#684](https://github.com/crawlab-team/crawlab/issues/684) +- **Results count not update in time**. [#689](https://github.com/crawlab-team/crawlab/issues/689) + # 0.4.9 (2020-03-31) ### Features / Enhancement - **Challenges**. Users can achieve different challenges based on their actions. diff --git a/backend/conf/config.yml b/backend/conf/config.yml index 08f223c5..17341e95 100644 --- a/backend/conf/config.yml +++ b/backend/conf/config.yml @@ -38,13 +38,14 @@ task: workers: 4 other: tmppath: "/tmp" -version: 0.4.9 +version: 0.4.10 setting: allowRegister: "N" enableTutorial: "N" runOnMaster: "Y" demoSpiders: "N" checkScrapy: "Y" + autoInstall: "Y" notification: mail: server: '' diff --git a/backend/constants/log.go b/backend/constants/log.go new file mode 100644 index 00000000..5f0b4a66 --- /dev/null +++ b/backend/constants/log.go @@ -0,0 +1,5 @@ +package constants + +const ( + ErrorRegexPattern = "(?:[ :,.]|^)((?:error|exception|traceback)s?)(?:[ :,.]|$)" +) diff --git a/backend/entity/system.go b/backend/entity/system.go index 2738b55a..bb95216d 100644 --- a/backend/entity/system.go +++ b/backend/entity/system.go @@ -22,6 +22,8 @@ type Lang struct { LockPath string `json:"lock_path"` InstallScript string `json:"install_script"` InstallStatus string `json:"install_status"` + DepFileName string `json:"dep_file_name"` + InstallDepArgs string `json:"install_dep_cmd"` } type Dependency struct { @@ -30,3 +32,7 @@ type Dependency struct { Description string `json:"description"` Installed bool `json:"installed"` } + +type PackageJson struct { + Dependencies map[string]string `json:"dependencies"` +} diff --git a/backend/main.go b/backend/main.go index 0a04c70f..6ab022f4 100644 --- a/backend/main.go +++ b/backend/main.go @@ -218,6 +218,7 @@ func main() { authGroup.DELETE("/tasks_by_status", routes.DeleteTaskByStatus) // 删除指定状态的任务 authGroup.POST("/tasks/:id/cancel", routes.CancelTask) // 取消任务 authGroup.GET("/tasks/:id/log", routes.GetTaskLog) // 任务日志 + authGroup.GET("/tasks/:id/error-log", routes.GetTaskErrorLog) // 任务错误日志 authGroup.GET("/tasks/:id/results", routes.GetTaskResults) // 任务结果 authGroup.GET("/tasks/:id/results/download", routes.DownloadTaskResultsCsv) // 下载任务结果 authGroup.POST("/tasks/:id/restart", routes.RestartTask) // 重新开始任务 @@ -274,6 +275,12 @@ func main() { authGroup.PUT("/actions", routes.PutAction) // 新增操作 //authGroup.POST("/actions/:id", routes.PostAction) // 修改操作 } + // API Token + { + authGroup.GET("/tokens", routes.GetTokens) // 获取 Tokens + authGroup.PUT("/tokens", routes.PutToken) // 添加 Token + authGroup.DELETE("/tokens/:id", routes.DeleteToken) // 删除 Token + } // 统计数据 authGroup.GET("/stats/home", routes.GetHomeStats) // 首页统计数据 // 文件 diff --git a/backend/middlewares/auth.go b/backend/middlewares/auth.go index 07249e82..8ab27728 100644 --- a/backend/middlewares/auth.go +++ b/backend/middlewares/auth.go @@ -11,14 +11,6 @@ import ( func AuthorizationMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - // 如果为登录或注册,不用校验 - //if c.Request.URL.Path == "/login" || - // (c.Request.URL.Path == "/users" && c.Request.Method == "PUT") || - // strings.HasSuffix(c.Request.URL.Path, "download") { - // c.Next() - // return - //} - // 获取token string tokenStr := c.GetHeader("Authorization") @@ -46,6 +38,8 @@ func AuthorizationMiddleware() gin.HandlerFunc { return } } + + // 设置用户 c.Set(constants.ContextUser, &user) // 校验成功 diff --git a/backend/model/log.go b/backend/model/log.go index 32d77694..fecf7def 100644 --- a/backend/model/log.go +++ b/backend/model/log.go @@ -4,6 +4,7 @@ import ( "crawlab/database" "crawlab/utils" "github.com/apex/log" + "github.com/globalsign/mgo" "github.com/globalsign/mgo/bson" "os" "runtime/debug" @@ -11,11 +12,22 @@ import ( ) type LogItem struct { - Id bson.ObjectId `json:"_id" bson:"_id"` - Message string `json:"msg" bson:"msg"` - TaskId string `json:"task_id" bson:"task_id"` - IsError bool `json:"is_error" bson:"is_error"` - Ts time.Time `json:"ts" bson:"ts"` + Id bson.ObjectId `json:"_id" bson:"_id"` + Message string `json:"msg" bson:"msg"` + TaskId string `json:"task_id" bson:"task_id"` + Seq int64 `json:"seq" bson:"seq"` + Ts time.Time `json:"ts" bson:"ts"` + ExpireTs time.Time `json:"expire_ts" bson:"expire_ts"` +} + +type ErrorLogItem struct { + Id bson.ObjectId `json:"_id" bson:"_id"` + TaskId string `json:"task_id" bson:"task_id"` + Message string `json:"msg" bson:"msg"` + LogId bson.ObjectId `json:"log_id" bson:"log_id"` + Seq int64 `json:"seq" bson:"seq"` + Ts time.Time `json:"ts" bson:"ts"` + ExpireTs time.Time `json:"expire_ts" bson:"expire_ts"` } // 获取本地日志 @@ -65,15 +77,91 @@ func AddLogItem(l LogItem) error { return nil } -func GetLogItemList(filter interface{}, skip int, limit int, sortStr string) ([]LogItem, error) { +func AddLogItems(ls []LogItem) error { + if len(ls) == 0 { + return nil + } + s, c := database.GetCol("logs") + defer s.Close() + var docs []interface{} + for _, l := range ls { + docs = append(docs, l) + } + if err := c.Insert(docs...); err != nil { + log.Errorf("insert log error: " + err.Error()) + debug.PrintStack() + return err + } + return nil +} + +func AddErrorLogItem(e ErrorLogItem) error { + s, c := database.GetCol("error_logs") + defer s.Close() + var l LogItem + err := c.FindId(bson.M{"log_id": e.LogId}).One(&l) + if err != nil && err == mgo.ErrNotFound { + if err := c.Insert(e); err != nil { + log.Errorf("insert log error: " + err.Error()) + debug.PrintStack() + return err + } + } + return nil +} + +func GetLogItemList(query bson.M, keyword string, skip int, limit int, sortStr string) ([]LogItem, error) { s, c := database.GetCol("logs") defer s.Close() + filter := query + var logItems []LogItem - if err := c.Find(filter).Skip(skip).Limit(limit).Sort(sortStr).All(&logItems); err != nil { - debug.PrintStack() - return logItems, err + if keyword == "" { + filter["seq"] = bson.M{ + "$gte": skip, + "$lt": skip + limit, + } + if err := c.Find(filter).Sort(sortStr).All(&logItems); err != nil { + debug.PrintStack() + return logItems, err + } + } else { + filter["msg"] = bson.M{ + "$regex": bson.RegEx{ + Pattern: keyword, + Options: "i", + }, + } + if err := c.Find(filter).Sort(sortStr).Skip(skip).Limit(limit).All(&logItems); err != nil { + debug.PrintStack() + return logItems, err + } } return logItems, nil } + +func GetLogItemTotal(query bson.M, keyword string) (int, error) { + s, c := database.GetCol("logs") + defer s.Close() + + filter := query + + if keyword != "" { + filter["msg"] = bson.M{ + "$regex": bson.RegEx{ + Pattern: keyword, + Options: "i", + }, + } + } + + total, err := c.Find(filter).Count() + if err != nil { + debug.PrintStack() + return total, err + } + + return total, nil +} diff --git a/backend/model/spider.go b/backend/model/spider.go index 666ed7d1..8b44481e 100644 --- a/backend/model/spider.go +++ b/backend/model/spider.go @@ -64,6 +64,10 @@ type Spider struct { DedupField string `json:"dedup_field" bson:"dedup_field"` // 去重字段 DedupMethod string `json:"dedup_method" bson:"dedup_method"` // 去重方式 + // Web Hook + IsWebHook bool `json:"is_web_hook" bson:"is_web_hook"` // 是否开启 Web Hook + WebHookUrl string `json:"web_hook_url" bson:"web_hook_url"` // Web Hook URL + // 前端展示 LastRunTs time.Time `json:"last_run_ts"` // 最后一次执行时间 LastStatus string `json:"last_status"` // 最后执行状态 diff --git a/backend/model/task.go b/backend/model/task.go index 24076409..35e738ab 100644 --- a/backend/model/task.go +++ b/backend/model/task.go @@ -3,6 +3,7 @@ package model import ( "crawlab/constants" "crawlab/database" + "crawlab/utils" "github.com/apex/log" "github.com/globalsign/mgo/bson" "runtime/debug" @@ -21,6 +22,7 @@ type Task struct { Param string `json:"param" bson:"param"` Error string `json:"error" bson:"error"` ResultCount int `json:"result_count" bson:"result_count"` + ErrorLogCount int `json:"error_log_count" bson:"error_log_count"` WaitDuration float64 `json:"wait_duration" bson:"wait_duration"` RuntimeDuration float64 `json:"runtime_duration" bson:"runtime_duration"` TotalDuration float64 `json:"total_duration" bson:"total_duration"` @@ -88,11 +90,9 @@ func (t *Task) GetResults(pageNum int, pageSize int) (results []interface{}, tot return } - if spider.Col == "" { - return - } + col := utils.GetSpiderCol(spider.Col, spider.Name) - s, c := database.GetCol(spider.Col) + s, c := database.GetCol(col) defer s.Close() query := bson.M{ @@ -109,17 +109,39 @@ func (t *Task) GetResults(pageNum int, pageSize int) (results []interface{}, tot return } -func (t *Task) GetLogItems() (logItems []LogItem, err error) { +func (t *Task) GetLogItems(keyword string, page int, pageSize int) (logItems []LogItem, logTotal int, err error) { query := bson.M{ "task_id": t.Id, } - logItems, err = GetLogItemList(query, 0, constants.Infinite, "+_id") + logTotal, err = GetLogItemTotal(query, keyword) if err != nil { - return logItems, err + return logItems, logTotal, err } - return logItems, nil + logItems, err = GetLogItemList(query, keyword, (page-1)*pageSize, pageSize, "+_id") + if err != nil { + return logItems, logTotal, err + } + + return logItems, logTotal, nil +} + +func (t *Task) GetErrorLogItems(n int) (errLogItems []ErrorLogItem, err error) { + s, c := database.GetCol("error_logs") + defer s.Close() + + query := bson.M{ + "task_id": t.Id, + } + + if err := c.Find(query).Limit(n).All(&errLogItems); err != nil { + log.Errorf("find error logs error: " + err.Error()) + debug.PrintStack() + return errLogItems, err + } + + return errLogItems, nil } func GetTaskList(filter interface{}, skip int, limit int, sortKey string) ([]Task, error) { @@ -365,8 +387,11 @@ func UpdateTaskResultCount(id string) (err error) { return err } + // default results collection + col := utils.GetSpiderCol(spider.Col, spider.Name) + // 获取结果数量 - s, c := database.GetCol(spider.Col) + s, c := database.GetCol(col) defer s.Close() resultCount, err := c.Find(bson.M{"task_id": task.Id}).Count() if err != nil { @@ -385,6 +410,41 @@ func UpdateTaskResultCount(id string) (err error) { return nil } +// update error log count +func UpdateErrorLogCount(id string) (err error) { + s, c := database.GetCol("error_logs") + defer s.Close() + + query := bson.M{ + "task_id": id, + } + count, err := c.Find(query).Count() + if err != nil { + log.Errorf("update error log count error: " + err.Error()) + debug.PrintStack() + return err + } + + st, ct := database.GetCol("tasks") + defer st.Close() + + task, err := GetTask(id) + if err != nil { + log.Errorf(err.Error()) + return err + } + task.ErrorLogCount = count + + if err := ct.UpdateId(id, task); err != nil { + log.Errorf("update error log count error: " + err.Error()) + debug.PrintStack() + return err + } + + return nil +} + +// convert all running tasks to abnormal tasks func UpdateTaskToAbnormal(nodeId bson.ObjectId) error { s, c := database.GetCol("tasks") defer s.Close() @@ -406,3 +466,45 @@ func UpdateTaskToAbnormal(nodeId bson.ObjectId) error { } return nil } + +// update task error logs +func UpdateTaskErrorLogs(taskId string, errorRegexPattern string) error { + s, c := database.GetCol("logs") + defer s.Close() + + if errorRegexPattern == "" { + errorRegexPattern = constants.ErrorRegexPattern + } + + query := bson.M{ + "task_id": taskId, + "msg": bson.M{ + "$regex": bson.RegEx{ + Pattern: errorRegexPattern, + Options: "i", + }, + }, + } + var logs []LogItem + if err := c.Find(query).All(&logs); err != nil { + log.Errorf("find error logs error: " + err.Error()) + debug.PrintStack() + return err + } + + for _, l := range logs { + e := ErrorLogItem{ + Id: bson.NewObjectId(), + TaskId: l.TaskId, + Message: l.Message, + LogId: l.Id, + Seq: l.Seq, + Ts: time.Now(), + } + if err := AddErrorLogItem(e); err != nil { + return err + } + } + + return nil +} diff --git a/backend/model/token.go b/backend/model/token.go new file mode 100644 index 00000000..b5763866 --- /dev/null +++ b/backend/model/token.go @@ -0,0 +1,80 @@ +package model + +import ( + "crawlab/database" + "github.com/apex/log" + "github.com/globalsign/mgo/bson" + "runtime/debug" + "time" +) + +type Token struct { + Id bson.ObjectId `json:"_id" bson:"_id"` + Token string `json:"token" bson:"token"` + UserId bson.ObjectId `json:"user_id" bson:"user_id"` + CreateTs time.Time `json:"create_ts" bson:"create_ts"` + UpdateTs time.Time `json:"update_ts" bson:"update_ts"` +} + +func (t *Token) Add() error { + s, c := database.GetCol("tokens") + defer s.Close() + + if err := c.Insert(t); err != nil { + log.Errorf("insert token error: " + err.Error()) + debug.PrintStack() + return err + } + + return nil +} + +func (t *Token) Delete() error { + s, c := database.GetCol("tokens") + defer s.Close() + + if err := c.RemoveId(t.Id); err != nil { + log.Errorf("insert token error: " + err.Error()) + debug.PrintStack() + return err + } + + return nil +} + +func GetTokenById(id bson.ObjectId) (t Token, err error) { + s, c := database.GetCol("tokens") + defer s.Close() + + if err = c.FindId(id).One(&t); err != nil { + return t, err + } + + return t, nil +} + +func GetTokensByUserId(uid bson.ObjectId) (tokens []Token, err error) { + s, c := database.GetCol("tokens") + defer s.Close() + + if err = c.Find(bson.M{"user_id": uid}).All(&tokens); err != nil { + log.Errorf("find tokens error: " + err.Error()) + debug.PrintStack() + return tokens, err + } + + return tokens, nil +} + +func DeleteTokenById(id bson.ObjectId) error { + t, err := GetTokenById(id) + if err != nil { + return err + } + + if err := t.Delete(); err != nil { + return err + } + + return nil +} diff --git a/backend/model/user.go b/backend/model/user.go index 074a197a..ba693cd9 100644 --- a/backend/model/user.go +++ b/backend/model/user.go @@ -29,6 +29,9 @@ type UserSetting struct { DingTalkRobotWebhook string `json:"ding_talk_robot_webhook" bson:"ding_talk_robot_webhook"` WechatRobotWebhook string `json:"wechat_robot_webhook" bson:"wechat_robot_webhook"` EnabledNotifications []string `json:"enabled_notifications" bson:"enabled_notifications"` + ErrorRegexPattern string `json:"error_regex_pattern" bson:"error_regex_pattern"` + MaxErrorLog int `json:"max_error_log" bson:"max_error_log"` + LogExpireDuration int64 `json:"log_expire_duration" bson:"log_expire_duration"` } func (user *User) Save() error { diff --git a/backend/routes/task.go b/backend/routes/task.go index 9239d057..2484b300 100644 --- a/backend/routes/task.go +++ b/backend/routes/task.go @@ -234,13 +234,43 @@ func DeleteTask(c *gin.Context) { } func GetTaskLog(c *gin.Context) { + type RequestData struct { + PageNum int `form:"page_num"` + PageSize int `form:"page_size"` + Keyword string `form:"keyword"` + } id := c.Param("id") - logItems, err := services.GetTaskLog(id) + var reqData RequestData + if err := c.ShouldBindQuery(&reqData); err != nil { + HandleErrorF(http.StatusBadRequest, c, "invalid request") + return + } + logItems, logTotal, err := services.GetTaskLog(id, reqData.Keyword, reqData.PageNum, reqData.PageSize) if err != nil { HandleError(http.StatusInternalServerError, c, err) return } - HandleSuccessData(c, logItems) + c.JSON(http.StatusOK, ListResponse{ + Status: "ok", + Message: "success", + Data: logItems, + Total: logTotal, + }) +} + +func GetTaskErrorLog(c *gin.Context) { + id := c.Param("id") + u := services.GetCurrentUser(c) + errLogItems, err := services.GetTaskErrorLog(id, u.Setting.MaxErrorLog) + if err != nil { + HandleError(http.StatusInternalServerError, c, err) + return + } + c.JSON(http.StatusOK, Response{ + Status: "ok", + Message: "success", + Data: errLogItems, + }) } func GetTaskResults(c *gin.Context) { @@ -364,4 +394,4 @@ func RestartTask(c *gin.Context) { return } HandleSuccess(c) -} \ No newline at end of file +} diff --git a/backend/routes/token.go b/backend/routes/token.go new file mode 100644 index 00000000..57ad5990 --- /dev/null +++ b/backend/routes/token.go @@ -0,0 +1,68 @@ +package routes + +import ( + "crawlab/model" + "crawlab/services" + "github.com/gin-gonic/gin" + "github.com/globalsign/mgo/bson" + "net/http" + "time" +) + +func GetTokens(c *gin.Context) { + u := services.GetCurrentUser(c) + + tokens, err := model.GetTokensByUserId(u.Id) + if err != nil { + HandleError(http.StatusInternalServerError, c, err) + return + } + + c.JSON(http.StatusOK, Response{ + Status: "ok", + Message: "success", + Data: tokens, + }) +} + +func PutToken(c *gin.Context) { + u := services.GetCurrentUser(c) + + tokenStr, err := services.MakeToken(u) + if err != nil { + HandleError(http.StatusInternalServerError, c, err) + return + } + + t := model.Token{ + Id: bson.NewObjectId(), + Token: tokenStr, + UserId: u.Id, + CreateTs: time.Now(), + UpdateTs: time.Now(), + } + + if err := t.Add(); err != nil { + HandleError(http.StatusInternalServerError, c, err) + return + } + + c.JSON(http.StatusOK, Response{ + Status: "ok", + Message: "success", + }) +} + +func DeleteToken(c *gin.Context) { + id := c.Param("id") + + if err := model.DeleteTokenById(bson.ObjectIdHex(id)); err != nil { + HandleError(http.StatusInternalServerError, c, err) + return + } + + c.JSON(http.StatusOK, Response{ + Status: "ok", + Message: "success", + }) +} diff --git a/backend/routes/user.go b/backend/routes/user.go index 86d46a61..56a8cb2c 100644 --- a/backend/routes/user.go +++ b/backend/routes/user.go @@ -98,6 +98,11 @@ func PutUser(c *gin.Context) { // UserId uid := services.GetCurrentUserId(c) + // 空 UserId 处理 + if uid == "" { + uid = bson.ObjectIdHex(constants.ObjectIdNull) + } + // 添加用户 if err := services.CreateNewUser(reqData.Username, reqData.Password, reqData.Role, reqData.Email, uid); err != nil { HandleError(http.StatusInternalServerError, c, err) @@ -237,6 +242,11 @@ func PostMe(c *gin.Context) { user.Setting.WechatRobotWebhook = reqBody.Setting.WechatRobotWebhook } user.Setting.EnabledNotifications = reqBody.Setting.EnabledNotifications + user.Setting.ErrorRegexPattern = reqBody.Setting.ErrorRegexPattern + if reqBody.Setting.MaxErrorLog != 0 { + user.Setting.MaxErrorLog = reqBody.Setting.MaxErrorLog + } + user.Setting.LogExpireDuration = reqBody.Setting.LogExpireDuration if user.UserId.Hex() == "" { user.UserId = bson.ObjectIdHex(constants.ObjectIdNull) diff --git a/backend/scripts/install-nodejs.sh b/backend/scripts/install-nodejs.sh index cf01c7f8..129bbc44 100644 --- a/backend/scripts/install-nodejs.sh +++ b/backend/scripts/install-nodejs.sh @@ -12,16 +12,16 @@ BASE_DIR=`dirname $0` export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm -# install Node.js v8.12 +# install Node.js v10.19 export NVM_NODEJS_ORG_MIRROR=http://npm.taobao.org/mirrors/node -nvm install 8.12 +nvm install 10.19 # create soft links -ln -s $HOME/.nvm/versions/node/v8.12.0/bin/npm /usr/local/bin/npm -ln -s $HOME/.nvm/versions/node/v8.12.0/bin/node /usr/local/bin/node +ln -s $HOME/.nvm/versions/node/v10.19.0/bin/npm /usr/local/bin/npm +ln -s $HOME/.nvm/versions/node/v10.19.0/bin/node /usr/local/bin/node # environments manipulation -export NODE_PATH=$HOME.nvm/versions/node/v8.12.0/lib/node_modules +export NODE_PATH=$HOME.nvm/versions/node/v10.19.0/lib/node_modules export PATH=$NODE_PATH:$PATH # install chromium diff --git a/backend/services/log.go b/backend/services/log.go index d2055886..11aaa4e9 100644 --- a/backend/services/log.go +++ b/backend/services/log.go @@ -7,7 +7,6 @@ import ( "crawlab/lib/cron" "crawlab/model" "crawlab/utils" - "encoding/json" "github.com/apex/log" "github.com/globalsign/mgo" "github.com/globalsign/mgo/bson" @@ -22,41 +21,6 @@ import ( // 任务日志频道映射 var TaskLogChanMap = utils.NewChanMap() -// 获取远端日志 -func GetRemoteLog(task model.Task) (logStr string, err error) { - // 序列化消息 - msg := entity.NodeMessage{ - Type: constants.MsgTypeGetLog, - LogPath: task.LogPath, - TaskId: task.Id, - } - msgBytes, err := json.Marshal(&msg) - if err != nil { - log.Errorf(err.Error()) - debug.PrintStack() - return "", err - } - - // 发布获取日志消息 - channel := "nodes:" + task.NodeId.Hex() - if _, err := database.RedisClient.Publish(channel, utils.BytesToString(msgBytes)); err != nil { - log.Errorf(err.Error()) - return "", err - } - - // 生成频道,等待获取log - ch := TaskLogChanMap.ChanBlocked(task.Id) - - select { - case logStr = <-ch: - log.Infof("get remote log") - case <-time.After(30 * time.Second): - logStr = "get remote log timeout" - } - - return logStr, nil -} - // 定时删除日志 func DeleteLogPeriodically() { logDir := viper.GetString("log.path") @@ -168,10 +132,31 @@ func InitDeleteLogPeriodically() error { func InitLogIndexes() error { s, c := database.GetCol("logs") defer s.Close() + se, ce := database.GetCol("error_logs") + defer s.Close() + defer se.Close() - _ = c.EnsureIndexKey("task_id") _ = c.EnsureIndex(mgo.Index{ - Key: []string{"$text:msg"}, + Key: []string{"task_id", "seq"}, + }) + _ = c.EnsureIndex(mgo.Index{ + Key: []string{"task_id", "msg"}, + }) + _ = c.EnsureIndex(mgo.Index{ + Key: []string{"expire_ts"}, + Sparse: true, + ExpireAfter: 0 * time.Second, + }) + _ = ce.EnsureIndex(mgo.Index{ + Key: []string{"task_id"}, + }) + _ = ce.EnsureIndex(mgo.Index{ + Key: []string{"log_id"}, + }) + _ = ce.EnsureIndex(mgo.Index{ + Key: []string{"expire_ts"}, + Sparse: true, + ExpireAfter: 0 * time.Second, }) return nil diff --git a/backend/services/spider.go b/backend/services/spider.go index 2a8a3d48..e8fc37c2 100644 --- a/backend/services/spider.go +++ b/backend/services/spider.go @@ -218,6 +218,9 @@ func PublishSpider(spider model.Spider) { Spider: spider, } + // 安装依赖 + go spiderSync.InstallDeps() + //目录不存在,则直接下载 path := filepath.Join(viper.GetString("spider.path"), spider.Name) if !utils.Exists(path) { @@ -434,7 +437,9 @@ func CopySpider(spider model.Spider, newName string) error { } func UpdateSpiderDedup(spider model.Spider) error { - s, c := database.GetCol(spider.Col) + col := utils.GetSpiderCol(spider.Col, spider.Name) + + s, c := database.GetCol(col) defer s.Close() if !spider.IsDedup { diff --git a/backend/services/spider_handler/spider.go b/backend/services/spider_handler/spider.go index 189fed60..08934f2f 100644 --- a/backend/services/spider_handler/spider.go +++ b/backend/services/spider_handler/spider.go @@ -16,6 +16,8 @@ import ( "path" "path/filepath" "runtime/debug" + "strings" + "sync" ) const ( @@ -183,3 +185,63 @@ func (s *SpiderSync) Download() { _ = database.RedisClient.HDel("spider", key) } + +// locks for dependency installation +var installLockMap sync.Map + +// install dependencies +func (s *SpiderSync) InstallDeps() { + langs := utils.GetLangList() + for _, l := range langs { + // no dep file name is found, skip + if l.DepFileName == "" { + continue + } + + // being locked, i.e. installation is running, skip + key := s.Spider.Name + "|" + l.Name + _, locked := installLockMap.Load(key) + if locked { + continue + } + + // no dep file found, skip + if !utils.Exists(path.Join(s.Spider.Src, l.DepFileName)) { + continue + } + + // no dep install executable found, skip + if !utils.Exists(l.DepExecutablePath) { + continue + } + + // lock + installLockMap.Store(key, true) + + // command to install dependencies + cmd := exec.Command(l.DepExecutablePath, strings.Split(l.InstallDepArgs, " ")...) + + // working directory + cmd.Dir = s.Spider.Src + + // compatibility with node.js + if l.ExecutableName == constants.Nodejs { + deps, err := utils.GetPackageJsonDeps(path.Join(s.Spider.Src, l.DepFileName)) + if err != nil { + continue + } + cmd = exec.Command(l.DepExecutablePath, strings.Split(l.InstallDepArgs+" "+strings.Join(deps, " "), " ")...) + } + + // start executing command + output, err := cmd.Output() + if err != nil { + log.Errorf("install dep error: " + err.Error()) + log.Errorf(string(output)) + debug.PrintStack() + } + + // unlock + installLockMap.Delete(key) + } +} diff --git a/backend/services/task.go b/backend/services/task.go index cf47c632..a8f0ff30 100644 --- a/backend/services/task.go +++ b/backend/services/task.go @@ -15,8 +15,10 @@ import ( "fmt" "github.com/apex/log" "github.com/globalsign/mgo/bson" + "github.com/imroc/req" uuid "github.com/satori/go.uuid" "github.com/spf13/viper" + "net/http" "os" "os/exec" "path" @@ -113,16 +115,19 @@ func SetEnv(cmd *exec.Cmd, envs []model.Env, task model.Task, spider model.Spide // 默认把Node.js的全局node_modules加入环境变量 envPath := os.Getenv("PATH") homePath := os.Getenv("HOME") - nodeVersion := "v8.12.0" + nodeVersion := "v10.19.0" nodePath := path.Join(homePath, ".nvm/versions/node", nodeVersion, "lib/node_modules") if !strings.Contains(envPath, nodePath) { _ = os.Setenv("PATH", nodePath+":"+envPath) } _ = os.Setenv("NODE_PATH", nodePath) + // default results collection + col := utils.GetSpiderCol(spider.Col, spider.Name) + // 默认环境变量 cmd.Env = append(os.Environ(), "CRAWLAB_TASK_ID="+task.Id) - cmd.Env = append(cmd.Env, "CRAWLAB_COLLECTION="+spider.Col) + cmd.Env = append(cmd.Env, "CRAWLAB_COLLECTION="+col) cmd.Env = append(cmd.Env, "CRAWLAB_MONGO_HOST="+viper.GetString("mongo.host")) cmd.Env = append(cmd.Env, "CRAWLAB_MONGO_PORT="+viper.GetString("mongo.port")) if viper.GetString("mongo.db") != "" { @@ -161,16 +166,7 @@ func SetEnv(cmd *exec.Cmd, envs []model.Env, task model.Task, spider model.Spide return cmd } -func SetLogConfig(cmd *exec.Cmd, t model.Task) error { - //fLog, err := os.Create(path) - //if err != nil { - // log.Errorf("create task log file error: %s", path) - // debug.PrintStack() - // return err - //} - //cmd.Stdout = fLog - //cmd.Stderr = fLog - +func SetLogConfig(cmd *exec.Cmd, t model.Task, u model.User) error { // get stdout reader stdout, err := cmd.StdoutPipe() readerStdout := bufio.NewReader(stdout) @@ -189,21 +185,49 @@ func SetLogConfig(cmd *exec.Cmd, t model.Task) error { return err } + var seq int64 + var logs []model.LogItem + isStdoutFinished := false + isStderrFinished := false + + // periodically (1 sec) insert log items + go func() { + for { + _ = model.AddLogItems(logs) + logs = []model.LogItem{} + if isStdoutFinished && isStderrFinished { + break + } + time.Sleep(5 * time.Second) + } + }() + + // expire duration (in seconds) + expireDuration := u.Setting.LogExpireDuration + if expireDuration == 0 { + // by default not expire + expireDuration = constants.Infinite + } + // read stdout go func() { for { line, err := readerStdout.ReadString('\n') if err != nil { + isStdoutFinished = true break } line = strings.Replace(line, "\n", "", -1) - _ = model.AddLogItem(model.LogItem{ - Id: bson.NewObjectId(), - Message: line, - TaskId: t.Id, - IsError: false, - Ts: time.Now(), - }) + seq++ + l := model.LogItem{ + Id: bson.NewObjectId(), + Seq: seq, + Message: line, + TaskId: t.Id, + Ts: time.Now(), + ExpireTs: time.Now().Add(time.Duration(expireDuration) * time.Second), + } + logs = append(logs, l) } }() @@ -211,24 +235,28 @@ func SetLogConfig(cmd *exec.Cmd, t model.Task) error { go func() { for { line, err := readerStderr.ReadString('\n') - line = strings.Replace(line, "\n", "", -1) if err != nil { + isStderrFinished = true break } - _ = model.AddLogItem(model.LogItem{ - Id: bson.NewObjectId(), - Message: line, - TaskId: t.Id, - IsError: true, - Ts: time.Now(), - }) + line = strings.Replace(line, "\n", "", -1) + seq++ + l := model.LogItem{ + Id: bson.NewObjectId(), + Seq: seq, + Message: line, + TaskId: t.Id, + Ts: time.Now(), + ExpireTs: time.Now().Add(time.Duration(expireDuration) * time.Second), + } + logs = append(logs, l) } }() return nil } -func FinishOrCancelTask(ch chan string, cmd *exec.Cmd, t model.Task) { +func FinishOrCancelTask(ch chan string, cmd *exec.Cmd, s model.Spider, t model.Task) { // 传入信号,此处阻塞 signal := <-ch log.Infof("process received signal: %s", signal) @@ -259,6 +287,8 @@ func FinishOrCancelTask(ch chan string, cmd *exec.Cmd, t model.Task) { t.FinishTs = time.Now() _ = t.Save() + + go FinishUpTask(s, t) } func StartTaskProcess(cmd *exec.Cmd, t model.Task) error { @@ -275,7 +305,7 @@ func StartTaskProcess(cmd *exec.Cmd, t model.Task) error { return nil } -func WaitTaskProcess(cmd *exec.Cmd, t model.Task) error { +func WaitTaskProcess(cmd *exec.Cmd, t model.Task, s model.Spider) error { if err := cmd.Wait(); err != nil { log.Errorf("wait process finish error: %s", err.Error()) debug.PrintStack() @@ -291,16 +321,19 @@ func WaitTaskProcess(cmd *exec.Cmd, t model.Task) error { t.FinishTs = time.Now() t.Status = constants.StatusError _ = t.Save() + + FinishUpTask(s, t) } } return err } + return nil } // 执行shell命令 -func ExecuteShellCmd(cmdStr string, cwd string, t model.Task, s model.Spider) (err error) { +func ExecuteShellCmd(cmdStr string, cwd string, t model.Task, s model.Spider, u model.User) (err error) { log.Infof("cwd: %s", cwd) log.Infof("cmd: %s", cmdStr) @@ -316,7 +349,7 @@ func ExecuteShellCmd(cmdStr string, cwd string, t model.Task, s model.Spider) (e cmd.Dir = cwd // 日志配置 - if err := SetLogConfig(cmd, t); err != nil { + if err := SetLogConfig(cmd, t, u); err != nil { return err } @@ -341,7 +374,7 @@ func ExecuteShellCmd(cmdStr string, cwd string, t model.Task, s model.Spider) (e // 起一个goroutine来监控进程 ch := utils.TaskExecChanMap.ChanBlocked(t.Id) - go FinishOrCancelTask(ch, cmd, t) + go FinishOrCancelTask(ch, cmd, s, t) // kill的时候,可以kill所有的子进程 if runtime.GOOS != constants.Windows { @@ -354,7 +387,7 @@ func ExecuteShellCmd(cmdStr string, cwd string, t model.Task, s model.Spider) (e } // 同步等待进程完成 - if err := WaitTaskProcess(cmd, t); err != nil { + if err := WaitTaskProcess(cmd, t, s); err != nil { return err } ch <- constants.TaskFinish @@ -412,6 +445,22 @@ func SaveTaskResultCount(id string) func() { } } +// Scan Error Logs +func ScanErrorLogs(t model.Task) func() { + return func() { + u, err := model.GetUser(t.UserId) + if err != nil { + return + } + if err := model.UpdateTaskErrorLogs(t.Id, u.Setting.ErrorRegexPattern); err != nil { + return + } + if err := model.UpdateErrorLogCount(t.Id); err != nil { + return + } + } +} + // 执行任务 func ExecuteTask(id int) { if flag, ok := LockList.Load(id); ok { @@ -508,12 +557,22 @@ func ExecuteTask(id int) { cmd += " " + t.Param } + // 获得触发任务用户 + user, err := model.GetUser(t.UserId) + if err != nil { + log.Errorf(GetWorkerPrefix(id) + err.Error()) + return + } + // 任务赋值 t.NodeId = node.Id // 任务节点信息 t.StartTs = time.Now() // 任务开始时间 t.Status = constants.StatusRunning // 任务状态 t.WaitDuration = t.StartTs.Sub(t.CreateTs).Seconds() // 等待时长 + // 发送 Web Hook 请求 (任务开始) + go SendWebHookRequest(user, t, spider) + // 文件检查 if err := SpiderFileCheck(t, spider); err != nil { log.Errorf("spider file check error: %s", err.Error()) @@ -527,26 +586,29 @@ func ExecuteTask(id int) { _ = t.Save() // 起一个cron执行器来统计任务结果数 - if spider.Col != "" { - cronExec := cron.New(cron.WithSeconds()) - _, err = cronExec.AddFunc("*/5 * * * * *", SaveTaskResultCount(t.Id)) - if err != nil { - log.Errorf(GetWorkerPrefix(id) + err.Error()) - return - } - cronExec.Start() - defer cronExec.Stop() - } - - // 获得触发任务用户 - user, err := model.GetUser(t.UserId) + cronExec := cron.New(cron.WithSeconds()) + _, err = cronExec.AddFunc("*/5 * * * * *", SaveTaskResultCount(t.Id)) if err != nil { log.Errorf(GetWorkerPrefix(id) + err.Error()) + debug.PrintStack() return } + cronExec.Start() + defer cronExec.Stop() + + // 起一个cron来更新错误日志 + cronExecErrLog := cron.New(cron.WithSeconds()) + _, err = cronExecErrLog.AddFunc("*/30 * * * * *", ScanErrorLogs(t)) + if err != nil { + log.Errorf(GetWorkerPrefix(id) + err.Error()) + debug.PrintStack() + return + } + cronExecErrLog.Start() + defer cronExecErrLog.Stop() // 执行Shell命令 - if err := ExecuteShellCmd(cmd, cwd, t, spider); err != nil { + if err := ExecuteShellCmd(cmd, cwd, t, spider, user); err != nil { log.Errorf(GetWorkerPrefix(id) + err.Error()) // 如果发生错误,则发送通知 @@ -554,16 +616,15 @@ func ExecuteTask(id int) { if user.Setting.NotificationTrigger == constants.NotificationTriggerOnTaskEnd || user.Setting.NotificationTrigger == constants.NotificationTriggerOnTaskError { SendNotifications(user, t, spider) } + + // 发送 Web Hook 请求 (任务开始) + go SendWebHookRequest(user, t, spider) + return } - // 更新任务结果数 - if spider.Col != "" { - if err := model.UpdateTaskResultCount(t.Id); err != nil { - log.Errorf(GetWorkerPrefix(id) + err.Error()) - return - } - } + // 完成任务收尾工作 + go FinishUpTask(spider, t) // 完成进程 t, err = model.GetTask(t.Id) @@ -578,6 +639,9 @@ func ExecuteTask(id int) { t.RuntimeDuration = t.FinishTs.Sub(t.StartTs).Seconds() // 运行时长 t.TotalDuration = t.FinishTs.Sub(t.CreateTs).Seconds() // 总时长 + // 发送 Web Hook 请求 (任务结束) + go SendWebHookRequest(user, t, spider) + // 如果是任务结束时发送通知,则发送通知 if user.Setting.NotificationTrigger == constants.NotificationTriggerOnTaskEnd { SendNotifications(user, t, spider) @@ -598,6 +662,20 @@ func ExecuteTask(id int) { log.Infof(GetWorkerPrefix(id) + "task (id:" + t.Id + ")" + " finished. elapsed:" + durationStr + " sec") } +func FinishUpTask(s model.Spider, t model.Task) { + // 更新任务结果数 + go func() { + if err := model.UpdateTaskResultCount(t.Id); err != nil { + return + } + }() + + // 更新任务错误日志 + go func() { + ScanErrorLogs(t)() + }() +} + func SpiderFileCheck(t model.Task, spider model.Spider) error { // 判断爬虫文件是否存在 gfFile := model.GetGridFs(spider.FileId) @@ -622,60 +700,34 @@ func SpiderFileCheck(t model.Task, spider model.Spider) error { return nil } -func GetTaskLog(id string) (logItems []model.LogItem, err error) { +func GetTaskLog(id string, keyword string, page int, pageSize int) (logItems []model.LogItem, logTotal int, err error) { task, err := model.GetTask(id) if err != nil { return } - logItems, err = task.GetLogItems() + logItems, logTotal, err = task.GetLogItems(keyword, page, pageSize) if err != nil { - return logItems, err + return logItems, logTotal, err } - return logItems, nil + return logItems, logTotal, nil +} - //if IsMasterNode(task.NodeId.Hex()) { - // if !utils.Exists(task.LogPath) { - // fileDir, err := MakeLogDir(task) - // - // if err != nil { - // log.Errorf(err.Error()) - // } - // - // fileP := GetLogFilePaths(fileDir, task) - // - // // 获取日志文件路径 - // fLog, err := os.Create(fileP) - // defer fLog.Close() - // if err != nil { - // log.Errorf("create task log file error: %s", fileP) - // debug.PrintStack() - // } - // task.LogPath = fileP - // if err := task.Save(); err != nil { - // log.Errorf(err.Error()) - // debug.PrintStack() - // } - // - // } - // // 若为主节点,获取本机日志 - // logBytes, err := model.GetLocalLog(task.LogPath) - // if err != nil { - // log.Errorf(err.Error()) - // logStr = err.Error() - // } else { - // logStr = utils.BytesToString(logBytes) - // } - // return logStr, err - //} - //// 若不为主节点,获取远端日志 - //logStr, err = GetRemoteLog(task) - //if err != nil { - // log.Errorf(err.Error()) - // - //} - //return logStr, err +func GetTaskErrorLog(id string, n int) (errLogItems []model.ErrorLogItem, err error) { + if n == 0 { + n = 1000 + } + + task, err := model.GetTask(id) + if err != nil { + return + } + errLogItems, err = task.GetErrorLogItems(n) + if err != nil { + return + } + return errLogItems, nil } func CancelTask(id string) (err error) { @@ -953,6 +1005,44 @@ func SendNotifications(u model.User, t model.Task, s model.Spider) { } } +func SendWebHookRequest(u model.User, t model.Task, s model.Spider) { + type RequestBody struct { + Status string `json:"status"` + Task model.Task `json:"task"` + Spider model.Spider `json:"spider"` + UserName string `json:"user_name"` + } + + if s.IsWebHook && s.WebHookUrl != "" { + // request header + header := req.Header{ + "Content-Type": "application/json; charset=utf-8", + } + + // request body + reqBody := RequestBody{ + Status: t.Status, + UserName: u.Username, + Task: t, + Spider: s, + } + + // make POST http request + res, err := req.Post(s.WebHookUrl, header, req.BodyJSON(reqBody)) + if err != nil { + log.Errorf("sent web hook request with error: " + err.Error()) + debug.PrintStack() + return + } + if res.Response().StatusCode != http.StatusOK { + log.Errorf(fmt.Sprintf("sent web hook request with error http code: %d, task_id: %s, status: %s", res.Response().StatusCode, t.Id, t.Status)) + debug.PrintStack() + return + } + log.Infof(fmt.Sprintf("sent web hook request, task_id: %s, status: %s)", t.Id, t.Status)) + } +} + func InitTaskExecutor() error { // 构造任务执行器 c := cron.New(cron.WithSeconds()) diff --git a/backend/services/user.go b/backend/services/user.go index adc56136..fbad2c71 100644 --- a/backend/services/user.go +++ b/backend/services/user.go @@ -114,6 +114,9 @@ func CreateNewUser(username string, password string, role string, email string, func GetCurrentUser(c *gin.Context) *model.User { data, _ := c.Get(constants.ContextUser) + if data == nil { + return &model.User{} + } return data.(*model.User) } diff --git a/backend/utils/spider.go b/backend/utils/spider.go new file mode 100644 index 00000000..4484ccf0 --- /dev/null +++ b/backend/utils/spider.go @@ -0,0 +1,8 @@ +package utils + +func GetSpiderCol(col string, name string) string { + if col == "" { + return "results_" + name + } + return col +} diff --git a/backend/utils/system.go b/backend/utils/system.go index f8f917be..5721aadf 100644 --- a/backend/utils/system.go +++ b/backend/utils/system.go @@ -1,6 +1,12 @@ package utils -import "crawlab/entity" +import ( + "crawlab/entity" + "encoding/json" + "github.com/apex/log" + "io/ioutil" + "runtime/debug" +) func GetLangList() []entity.Lang { list := []entity.Lang{ @@ -10,6 +16,8 @@ func GetLangList() []entity.Lang { ExecutablePaths: []string{"/usr/bin/python", "/usr/local/bin/python"}, DepExecutablePath: "/usr/local/bin/pip", LockPath: "/tmp/install-python.lock", + DepFileName: "requirements.txt", + InstallDepArgs: "install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt", }, { Name: "Node.js", @@ -18,6 +26,8 @@ func GetLangList() []entity.Lang { DepExecutablePath: "/usr/local/bin/npm", LockPath: "/tmp/install-nodejs.lock", InstallScript: "install-nodejs.sh", + DepFileName: "package.json", + InstallDepArgs: "install -g --registry=https://registry.npm.taobao.org", }, { Name: "Java", @@ -60,3 +70,24 @@ func GetLangFromLangNamePlain(name string) entity.Lang { } return entity.Lang{} } + +func GetPackageJsonDeps(filepath string) (deps []string, err error) { + data, err := ioutil.ReadFile(filepath) + if err != nil { + log.Errorf("get package.json deps error: " + err.Error()) + debug.PrintStack() + return deps, err + } + var packageJson entity.PackageJson + if err := json.Unmarshal(data, &packageJson); err != nil { + log.Errorf("get package.json deps error: " + err.Error()) + debug.PrintStack() + return deps, err + } + + for d, v := range packageJson.Dependencies { + deps = append(deps, d+"@"+v) + } + + return deps, nil +} diff --git a/frontend/package.json b/frontend/package.json index 0eb99542..3149be12 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "crawlab", - "version": "0.4.9", + "version": "0.4.10", "private": true, "scripts": { "serve": "vue-cli-service serve --ip=0.0.0.0 --mode=development", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1917a490..64942c3c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -48,6 +48,9 @@ export default { // get latest version await this.$store.dispatch('version/getLatestRelease') + // get user info + await this.$store.dispatch('user/getInfo') + // remove loading-placeholder const elLoading = document.querySelector('#loading-placeholder') elLoading.remove() diff --git a/frontend/src/components/InfoView/SpiderInfoView.vue b/frontend/src/components/InfoView/SpiderInfoView.vue index 359cea46..d66d7632 100644 --- a/frontend/src/components/InfoView/SpiderInfoView.vue +++ b/frontend/src/components/InfoView/SpiderInfoView.vue @@ -45,10 +45,10 @@ /> - + @@ -96,7 +96,7 @@ -
+ +
+ + +
+
diff --git a/frontend/src/components/InfoView/TaskInfoView.vue b/frontend/src/components/InfoView/TaskInfoView.vue index b85fef40..d8c4250e 100644 --- a/frontend/src/components/InfoView/TaskInfoView.vue +++ b/frontend/src/components/InfoView/TaskInfoView.vue @@ -12,8 +12,8 @@ @@ -79,8 +79,7 @@