Merge pull request #554 from crawlab-team/release

Release
This commit is contained in:
Marvin Zhang
2020-02-13 17:13:23 +08:00
committed by GitHub
34 changed files with 695 additions and 407 deletions

View File

@@ -1,3 +1,16 @@
# 0.4.6 (2020-02-13)
### 功能 / 优化
- **Node.js SDK **. 用户可以将 SDK 应用到他们的 Node.js 爬虫中.
- **日志管理优化**. 日志搜索错误高亮自动滚动.
- **任务执行流程优化**. 允许用户在触发任务后跳转到该任务详情页.
- **任务展示优化**. 在爬虫详情页的最近任务表格中加入了参数. [#295](https://github.com/crawlab-team/crawlab/issues/295)
- **爬虫列表优化**. 在爬虫列表页加入"更新时间""创建时间". [#505](https://github.com/crawlab-team/crawlab/issues/505)
- **页面加载展位器**.
### Bug 修复
- **定时任务配置失去焦点**. [#519](https://github.com/crawlab-team/crawlab/issues/519)
- **无法用 CLI 工具上传爬虫**. [#524](https://github.com/crawlab-team/crawlab/issues/524)
# 0.4.5 (2020-02-03)
### 功能 / 优化
- **交互式教程**. 引导用户了解 Crawlab 的主要功能.

View File

@@ -1,3 +1,16 @@
# 0.4.6 (2020-02-13)
### Features / Enhancement
- **SDK for Node.js**. Users can apply SDK in their Node.js spiders.
- **Log Management Optimization**. Log search, error highlight, auto-scrolling.
- **Task Execution Process Optimization**. Allow users to be redirected to task detail page after triggering a task.
- **Task Display Optimization**. Added "Param" in the Latest Tasks table in the spider detail page. [#295](https://github.com/crawlab-team/crawlab/issues/295)
- **Spider List Optimization**. Added "Update Time" and "Create Time" in spider list page.
- **Page Loading Placeholder**.
### Bug Fixes
- **Lost Focus in Schedule Configuration**. [#519](https://github.com/crawlab-team/crawlab/issues/519)
- **Unable to Upload Spider using CLI**. [#524](https://github.com/crawlab-team/crawlab/issues/524)
# 0.4.5 (2020-02-03)
### Features / Enhancement
- **Interactive Tutorial**. Guide users through the main functionalities of Crawlab.

View File

@@ -54,6 +54,9 @@ COPY --from=frontend-build /app/conf/crawlab.conf /etc/nginx/conf.d
# working directory
WORKDIR /app/backend
# timezone environment
ENV TZ Asia/Shanghai
# frontend port
EXPOSE 8080

View File

@@ -52,6 +52,9 @@ COPY --from=frontend-build /app/conf/crawlab.conf /etc/nginx/conf.d
# working directory
WORKDIR /app/backend
# timezone environment
ENV TZ Asia/Shanghai
# frontend port
EXPOSE 8080

View File

@@ -35,7 +35,7 @@ task:
workers: 4
other:
tmppath: "/tmp"
version: 0.4.5
version: 0.4.6
setting:
allowRegister: "N"
notification:

View File

@@ -100,6 +100,9 @@ func PutTask(c *gin.Context) {
return
}
// 任务ID
var taskIds []string
if reqBody.RunType == constants.RunTypeAllNodes {
// 所有节点
nodes, err := model.GetNodeList(nil)
@@ -115,10 +118,13 @@ func PutTask(c *gin.Context) {
UserId: services.GetCurrentUser(c).Id,
}
if err := services.AddTask(t); err != nil {
id, err := services.AddTask(t);
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
taskIds = append(taskIds, id)
}
} else if reqBody.RunType == constants.RunTypeRandom {
// 随机
@@ -127,10 +133,12 @@ func PutTask(c *gin.Context) {
Param: reqBody.Param,
UserId: services.GetCurrentUser(c).Id,
}
if err := services.AddTask(t); err != nil {
id, err := services.AddTask(t);
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
taskIds = append(taskIds, id)
} else if reqBody.RunType == constants.RunTypeSelectedNodes {
// 指定节点
for _, nodeId := range reqBody.NodeIds {
@@ -141,16 +149,19 @@ func PutTask(c *gin.Context) {
UserId: services.GetCurrentUser(c).Id,
}
if err := services.AddTask(t); err != nil {
id, err := services.AddTask(t);
if err != nil {
HandleError(http.StatusInternalServerError, c, err)
return
}
taskIds = append(taskIds, id)
}
} else {
HandleErrorF(http.StatusInternalServerError, c, "invalid run_type")
return
}
HandleSuccess(c)
HandleSuccessData(c, taskIds)
}
func DeleteTaskByStatus(c *gin.Context) {

View File

@@ -37,7 +37,7 @@ func AddScheduleTask(s model.Schedule) func() {
UserId: s.UserId,
}
if err := AddTask(t); err != nil {
if _, err := AddTask(t); err != nil {
return
}
}
@@ -49,7 +49,7 @@ func AddScheduleTask(s model.Schedule) func() {
Param: s.Param,
UserId: s.UserId,
}
if err := AddTask(t); err != nil {
if _, err := AddTask(t); err != nil {
log.Errorf(err.Error())
debug.PrintStack()
return
@@ -65,7 +65,7 @@ func AddScheduleTask(s model.Schedule) func() {
UserId: s.UserId,
}
if err := AddTask(t); err != nil {
if _, err := AddTask(t); err != nil {
return
}
}

View File

@@ -666,7 +666,7 @@ func CancelTask(id string) (err error) {
return nil
}
func AddTask(t model.Task) error {
func AddTask(t model.Task) (string, error) {
// 生成任务ID
id := uuid.NewV4()
t.Id = id.String()
@@ -683,17 +683,17 @@ func AddTask(t model.Task) error {
if err := model.AddTask(t); err != nil {
log.Errorf(err.Error())
debug.PrintStack()
return err
return t.Id, err
}
// 加入任务队列
if err := AssignTask(t); err != nil {
log.Errorf(err.Error())
debug.PrintStack()
return err
return t.Id, err
}
return nil
return t.Id, nil
}
func GetTaskEmailMarkdownContent(t model.Task, s model.Spider) string {

View File

@@ -35,6 +35,8 @@ services:
depends_on:
- mongo
- redis
# volumes:
# - "/var/crawlab/log:/var/logs/crawlab" # log persistent 日志持久化
worker:
image: tikazyq/crawlab:latest
container_name: worker
@@ -45,6 +47,8 @@ services:
depends_on:
- mongo
- redis
# volumes:
# - "/var/crawlab/log:/var/logs/crawlab" # log persistent 日志持久化
mongo:
image: mongo:latest
restart: always
@@ -55,7 +59,7 @@ services:
redis:
image: redis:latest
restart: always
# command: redis --requirepass "password" # set redis password 设置 Redis 密码
# command: redis-server --requirepass "password" # set redis password 设置 Redis 密码
# volumes:
# - "/opt/crawlab/redis/data:/data" # make data persistent 持久化
# ports:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<!-- Place this tag in your head or just before your close body tag. -->
<script async defer src="https://buttons.github.io/buttons.js"></script>
<title>Crawlab</title>
</head>
<body>
<!--<script src=<%= BASE_URL %>/tinymce4.7.5/tinymce.min.js></script>-->
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

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

View File

@@ -9,11 +9,140 @@
<!-- Place this tag in your head or just before your close body tag. -->
<script async defer src="https://buttons.github.io/buttons.js"></script>
<style>
#app {
position: fixed;
z-index: 999;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
#loading-placeholder {
position: fixed;
z-index: -1;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#loading-placeholder .title-wrapper {
height: 54px;
}
#loading-placeholder .title {
font-family: "Verdana", serif;
font-weight: 600;
font-size: 48px;
color: #409EFF;
text-align: center;
cursor: default;
letter-spacing: -5px;
margin: 0;
}
#loading-placeholder .title > span {
display: inline-block;
animation: change-shape 1s infinite;
}
#loading-placeholder .title > span:nth-child(1) {
animation-delay: calc(1s / 7 * 0 / 2);
}
#loading-placeholder .title > span:nth-child(2) {
animation-delay: calc(1s / 7 * 1 / 2);
}
#loading-placeholder .title > span:nth-child(3) {
animation-delay: calc(1s / 7 * 2 / 2);
}
#loading-placeholder .title > span:nth-child(4) {
animation-delay: calc(1s / 7 * 3 / 2);
}
#loading-placeholder .title > span:nth-child(5) {
animation-delay: calc(1s / 7 * 4 / 2);
}
#loading-placeholder .title > span:nth-child(6) {
animation-delay: calc(1s / 7 * 5 / 2);
}
#loading-placeholder .title > span:nth-child(7) {
animation-delay: calc(1s / 7 * 6 / 2);
}
#loading-placeholder .loading-text {
text-align: center;
font-weight: bolder;
font-family: "Verdana", serif;
font-style: italic;
color: #889aa4;
font-size: 18px;
animation: blink-loading 2s ease-in infinite;
}
@keyframes blink-loading {
0% {
opacity: 100%;
}
50% {
opacity: 50%;
}
100% {
opacity: 100%;
}
}
@keyframes change-shape {
0% {
transform: scale(1);
}
25% {
transform: scale(1.2);
}
50% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
</style>
<title>Crawlab</title>
</head>
<body>
<!--<script src=<%= BASE_URL %>/tinymce4.7.5/tinymce.min.js></script>-->
<div id="loading-placeholder">
<div style="margin-bottom: 150px">
<div class="title-wrapper">
<h3 class="title">
<span>C</span>
<span>R</span>
<span>A</span>
<span>W</span>
<span>L</span>
<span>A</span>
<span>B</span>
</h3>
</div>
<div class="loading-text">
Loading...
</div>
</div>
</div>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>

View File

@@ -30,13 +30,22 @@
<el-input v-model="form.param" :placeholder="$t('Parameters')"></el-input>
</el-form-item>
<el-form-item class="disclaimer-wrapper">
<el-checkbox v-model="isAllowDisclaimer"/>
<span style="margin-left: 5px">我已阅读并同意 <a href="javascript:" @click="onClickDisclaimer">免责声明</a> 所有内容</span>
<div>
<el-checkbox v-model="isAllowDisclaimer"/>
<span style="margin-left: 5px">我已阅读并同意 <a href="javascript:" @click="onClickDisclaimer">免责声明</a> 所有内容</span>
</div>
<div>
<el-checkbox v-model="isRedirect"/>
<span style="margin-left: 5px">跳转到任务详情页</span>
</div>
</el-form-item>
<el-form-item>
</el-form-item>
</el-form>
<template slot="footer">
<el-button type="plain" size="small" @click="$emit('close')">{{$t('Cancel')}}</el-button>
<el-button type="primary" size="small" @click="onConfirm" :disabled="!isAllowDisclaimer">{{$t('Confirm')}}</el-button>
<el-button type="primary" size="small" @click="onConfirm" :disabled="!isAllowDisclaimer">{{$t('Confirm')}}
</el-button>
</template>
</el-dialog>
</template>
@@ -64,7 +73,8 @@ export default {
param: '',
nodeList: []
},
isAllowDisclaimer: true
isAllowDisclaimer: true,
isRedirect: true
}
},
methods: {
@@ -72,20 +82,27 @@ export default {
this.$emit('close')
},
onConfirm () {
this.$refs['form'].validate(res => {
if (!res) return
this.$refs['form'].validate(async valid => {
if (!valid) return
this.$store.dispatch('spider/crawlSpider', {
const res = await this.$store.dispatch('spider/crawlSpider', {
spiderId: this.spiderId,
nodeIds: this.form.nodeIds,
param: this.form.param,
runType: this.form.runType
})
.then(() => {
this.$message.success(this.$t('A task has been scheduled successfully'))
})
const id = res.data.data[0]
this.$message.success(this.$t('A task has been scheduled successfully'))
this.$emit('close')
this.$st.sendEv('爬虫确认', '确认运行', this.form.runType)
if (this.isRedirect) {
this.$router.push('/tasks/' + id)
this.$st.sendEv('爬虫确认', '跳转到任务详情')
}
})
},
onClickDisclaimer () {

View File

@@ -131,12 +131,16 @@
<div class="button-group-container">
<div class="button-group">
<el-button id="btn-run" type="danger" @click="onCrawl">{{$t('Run')}}</el-button>
<el-button id="btn-run" size="small" type="danger" @click="onCrawl">
{{$t('Run')}}
</el-button>
<!-- <el-button type="primary" @click="onExtractFields" v-loading="extractFieldsLoading">-->
<!-- {{$t('ExtractFields')}}-->
<!-- </el-button>-->
<!-- <el-button type="warning" @click="onPreview" v-loading="previewLoading">{{$t('Preview')}}</el-button>-->
<el-button id="btn-save" type="success" @click="onSave" v-loading="saveLoading">{{$t('Save')}}</el-button>
<el-button id="btn-save" size="small" type="success" @click="onSave" v-loading="saveLoading">
{{$t('Save')}}
</el-button>
</div>
</div>
</el-row>

View File

@@ -23,6 +23,7 @@
v-model="spiderForm.project_id"
:placeholder="$t('Project')"
filterable
:disabled="isView"
>
<el-option
v-for="p in projectList"
@@ -60,7 +61,7 @@
</el-select>
</el-form-item>
<el-form-item :label="$t('Remark')">
<el-input type="textarea" v-model="spiderForm.remark" :placeholder="$t('Remark')"/>
<el-input type="textarea" v-model="spiderForm.remark" :placeholder="$t('Remark')" :disabled="isView"/>
</el-form-item>
</el-form>
</el-row>

View File

@@ -11,6 +11,24 @@
</el-form-item>
<el-form-item :label="$t('Status')">
<status-tag :status="taskForm.status"/>
<el-badge
v-if="errorLogData.length > 0"
:value="errorLogData.length"
style="margin-left:10px; cursor:pointer;"
>
<el-tag type="danger" @click="onClickLogWithErrors">
<i class="el-icon-warning"></i>
{{$t('Log with errors')}}
</el-tag>
</el-badge>
<el-tag
v-if="taskForm.status === 'finished' && taskForm.result_count === 0"
type="danger"
style="margin-left: 10px"
>
<i class="el-icon-warning"></i>
{{$t('Empty results')}}
</el-tag>
</el-form-item>
<el-form-item :label="$t('Log File Path')">
<el-input v-model="taskForm.log_path" placeholder="Log File Path" disabled></el-input>
@@ -28,7 +46,7 @@
<el-input :value="getTime(taskForm.finish_ts)" placeholder="Finish Time" disabled></el-input>
</el-form-item>
<el-form-item :label="$t('Wait Duration (sec)')">
<el-input :value="getWaitDuration(taskForm)" placeholder="Wait Duration" disabled></el-input>
<el-input :value="getWaitDuration(taskForm)" placeholder="Wait Duration" disabled></el-input>
</el-form-item>
<el-form-item :label="$t('Runtime Duration (sec)')">
<el-input :value="getRuntimeDuration(taskForm)" placeholder="Runtime Duration" disabled></el-input>
@@ -37,7 +55,7 @@
<el-input :value="getTotalDuration(taskForm)" placeholder="Runtime Duration" disabled></el-input>
</el-form-item>
<el-form-item :label="$t('Results Count')">
<el-input v-model="taskForm.result_count" placeholder="Results Count" disabled></el-input>
<el-input v-model="taskForm.result_count" placeholder="Results Count" disabled></el-input>
</el-form-item>
<!--<el-form-item :label="$t('Average Results Count per Second')">-->
<!--<el-input v-model="taskForm.avg_num_results" placeholder="Average Results Count per Second" disabled>-->
@@ -51,7 +69,9 @@
</el-form>
</el-row>
<el-row class="button-container">
<el-button v-if="isRunning" type="danger" @click="onStop">{{$t('Stop')}}</el-button>
<el-button v-if="isRunning" size="small" type="danger" @click="onStop" icon="el-icon-video-pause">
{{$t('Stop')}}
</el-button>
<!--<el-button type="danger" @click="onRestart">Restart</el-button>-->
</el-row>
</div>
@@ -59,7 +79,8 @@
<script>
import {
mapState
mapState,
mapGetters
} from 'vuex'
import StatusTag from '../Status/StatusTag'
import dayjs from 'dayjs'
@@ -69,7 +90,11 @@ export default {
components: { StatusTag },
computed: {
...mapState('task', [
'taskForm'
'taskForm',
'taskLog'
]),
...mapGetters('task', [
'errorLogData'
]),
isRunning () {
return ['pending', 'running'].includes(this.taskForm.status)
@@ -99,6 +124,10 @@ export default {
getTotalDuration (row) {
if (!row.finish_ts || row.finish_ts.match('^0001')) return 'NA'
return dayjs(row.finish_ts).diff(row.create_ts, 'second')
},
onClickLogWithErrors () {
this.$emit('click-log')
this.$st.sendEv('任务详情', '概览', '点击日志错误')
}
}
}

View File

@@ -3,7 +3,7 @@
<el-col :span="12" style="padding-right: 20px;">
<el-row class="task-info-overview-wrapper wrapper">
<h4 class="title">{{$t('Task Info')}}</h4>
<task-info-view/>
<task-info-view @click-log="() => $emit('click-log')"/>
</el-row>
<el-row style="border-bottom:1px solid #e4e7ed;margin:0 0 20px 0;padding-bottom:20px;"/>
</el-col>

View File

@@ -1,7 +1,14 @@
<template>
<div class="log-item">
<div class="log-item" :style="style" :class="`log-item-${index} ${active ? 'active' : ''}`">
<div class="line-no">{{index}}</div>
<div class="line-content" ><span v-if="isAnsi" v-html="data">{{data}}</span><span v-else="">{{data}}</span></div>
<div class="line-content">
<span v-if="isLogEnd" style="color: #E6A23C">
<span class="loading-text">{{$t('Updating log...')}}</span>
<i class="el-icon-loading"></i>
</span>
<span v-else-if="isAnsi" v-html="dataHtml"></span>
<span v-else v-html="dataHtml"></span>
</div>
</div>
</template>
@@ -20,6 +27,39 @@ export default {
isAnsi: {
type: Boolean,
default: false
},
searchString: {
type: String,
default: ''
},
active: {
type: Boolean,
default: false
}
},
data () {
return {
errorRegex: this.$utils.log.errorRegex
}
},
computed: {
dataHtml () {
let html = this.data.replace(this.errorRegex, ' <span style="font-weight: bolder; text-decoration: underline">$1</span> ')
if (!this.searchString) return html
html = html.replace(new RegExp(`(${this.searchString})`, 'gi'), '<mark>$1</mark>')
return html
},
style () {
let color = ''
if (this.data.match(this.errorRegex)) {
color = '#F56C6C'
}
return {
color
}
},
isLogEnd () {
return this.data === '###LOG_END###'
}
}
}
@@ -27,28 +67,56 @@ export default {
<style scoped>
.log-item {
display: table;
display: block;
}
.log-item:hover {
background: rgba(55, 57, 59, 0.5);
}
.log-item:first-child .line-no {
padding-top: 10px;
text-align: right;
}
.log-item .line-no {
display: table-cell;
display: inline-block;
width: 70px;
color: #A9B7C6;
background: #313335;
padding-right: 10px;
text-align: right;
flex-basis: 40px;
width: 70px;
}
.log-item.active .line-no {
background: #E6A23C;
color: white;
font-weight: bolder;
}
.log-item .line-content {
padding-left: 10px;
display: table-cell;
/*display: inline-block;*/
word-break: break-word;
flex-basis: calc(100% - 50px);
display: inline-block;
width: calc(100% - 70px);
white-space: nowrap;
}
.loading-text {
margin-right: 5px;
animation: blink 2s ease-in infinite;
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0.3;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -1,20 +1,97 @@
<template>
<virtual-list
class="log-view"
:size="6"
:remain="100"
:item="item"
:itemcount="logData.length"
:itemprops="getItemProps"
>
</virtual-list>
<div class="log-view-container">
<div class="filter-wrapper">
<div class="left">
<el-button
size="small"
type="primary"
icon="el-icon-download"
style="margin-right: 10px"
:disabled="isToBottom"
@click="onAutoScroll"
>
{{$t('Auto-Scroll')}}
</el-button>
<el-input
v-model="searchString"
size="small"
suffix-icon="el-icon-search"
:placeholder="$t('Search Log')"
style="width: 240px; margin-right: 10px"
/>
</div>
<div class="right">
<el-badge
v-if="errorLogData.length > 0"
:value="errorLogData.length"
>
<el-button
type="danger"
size="small"
icon="el-icon-warning-outline"
@click="toggleErrors"
>
{{$t('Error Count')}}
</el-button>
</el-badge>
</div>
</div>
<div class="content">
<div
class="log-view-wrapper"
:class="isErrorsCollapsed ? 'errors-collapsed' : ''"
>
<virtual-list
class="log-view"
ref="log-view"
:start="currentLogIndex - 1"
:offset="0"
:size="18"
:remain="remainSize"
:item="item"
:itemcount="filteredLogData.length"
:itemprops="getItemProps"
:tobottom="onToBottom"
:onscroll="onScroll"
/>
</div>
<div
v-show="!isErrorsCollapsed && !isErrorCollapsing"
class="errors-wrapper"
:class="isErrorsCollapsed ? 'collapsed' : ''"
>
<ul class="error-list">
<li
v-for="item in errorLogData"
:key="item.index"
class="error-item"
:class="currentLogIndex === item.index ? 'active' : ''"
@click="onClickError(item)"
>
<span class="line-no">
{{item.index}}
</span>
<span class="line-content">
{{item.data}}
</span>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import LogItem from './LogItem'
import {
mapState,
mapGetters
} from 'vuex'
import VirtualList from 'vue-virtual-scroll-list'
import Convert from 'ansi-to-html'
import hasAnsi from 'has-ansi'
import LogItem from './LogItem'
const convert = new Convert()
export default {
name: 'LogView',
@@ -29,23 +106,52 @@ export default {
},
data () {
return {
item: LogItem
item: LogItem,
searchString: '',
isToBottom: false,
isScrolling: false,
isScrolling2nd: false,
errorRegex: this.$utils.log.errorRegex,
currentOffset: 0,
isErrorsCollapsed: true,
isErrorCollapsing: false
}
},
computed: {
logData () {
return this.data.split('\n')
.map((d, i) => {
return {
index: i + 1,
data: d
}
})
...mapState('task', [
'taskForm'
]),
...mapGetters('task', [
'logData',
'errorLogData'
]),
currentLogIndex: {
get () {
return this.$store.state.task.currentLogIndex
},
set (value) {
this.$store.commit('task/SET_CURRENT_LOG_INDEX', value)
}
},
filteredLogData () {
return this.logData.filter(d => {
if (!this.searchString) return true
return !!d.data.toLowerCase().match(this.searchString.toLowerCase())
})
},
remainSize () {
const height = document.querySelector('body').clientHeight
return (height - 240) / 18
}
},
watch: {
searchString () {
this.$st.sendEv('任务详情', '日志', '搜索日志')
}
},
methods: {
getItemProps (index) {
const logItem = this.logData[index]
const logItem = this.filteredLogData[index]
const isAnsi = hasAnsi(logItem.data)
return {
// <item/> will render with itemProps.
@@ -53,24 +159,162 @@ export default {
props: {
index: logItem.index,
data: isAnsi ? convert.toHtml(logItem.data) : logItem.data,
searchString: this.searchString,
active: logItem.active,
isAnsi
}
}
},
onToBottom () {
if (this.isScrolling) return
this.isToBottom = true
},
onScroll () {
if (this.isScrolling2nd) {
this.isToBottom = false
}
this.isScrolling = true
setTimeout(() => {
this.isScrolling2nd = true
setTimeout(() => {
this.isScrolling2nd = false
}, 50)
}, 50)
setTimeout(() => {
this.isScrolling = false
}, 100)
},
toBottom () {
this.$el.querySelector('.log-view').scrollTo({ top: 99999999 })
setTimeout(() => {
this.isToBottom = true
}, 50)
},
onAutoScroll () {
this.toBottom()
},
toggleErrors () {
this.isErrorsCollapsed = !this.isErrorsCollapsed
this.isErrorCollapsing = true
setTimeout(() => {
this.isErrorCollapsing = false
}, 300)
},
onClickError (item) {
this.currentLogIndex = item.index
this.isToBottom = false
const handle = setInterval(() => {
this.isToBottom = false
}, 10)
setTimeout(() => {
clearInterval(handle)
}, 500)
}
},
mounted () {
this.currentLogIndex = 0
this.handle = setInterval(() => {
if (this.isToBottom) {
this.toBottom()
}
}, 200)
},
destroyed () {
clearInterval(this.handle)
}
}
</script>
<style scoped>
.filter-wrapper {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.content {
display: block;
}
.log-view-wrapper {
float: left;
flex-basis: calc(100% - 240px);
width: calc(100% - 300px);
transition: width 0.3s;
}
.log-view-wrapper.errors-collapsed {
flex-basis: 100%;
width: 100%;
}
.log-view {
margin-top: 0!important;
min-height: 100%;
overflow-y: scroll!important;
margin-top: 0 !important;
overflow-y: scroll !important;
list-style: none;
color: #A9B7C6;
background: #2B2B2B;
border: none;
}
.errors-wrapper {
float: left;
display: inline-block;
margin: 0;
padding: 0;
flex-basis: 240px;
width: 300px;
transition: opacity 0.3s;
border-top: 1px solid #DCDFE6;
border-right: 1px solid #DCDFE6;
border-bottom: 1px solid #DCDFE6;
height: calc(100vh - 240px);
font-size: 16px;
overflow: auto;
}
.errors-wrapper.collapsed {
width: 0;
}
.errors-wrapper .error-list {
list-style: none;
padding: 0;
margin: 0;
}
.errors-wrapper .error-list .error-item {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
/*height: 18px;*/
border-bottom: 1px solid white;
padding: 5px 0;
background: #F56C6C;
color: white;
cursor: pointer;
}
.errors-wrapper .error-list .error-item.active {
background: #E6A23C;
font-weight: bolder;
text-decoration: underline;
}
.errors-wrapper .error-list .error-item:hover {
font-weight: bolder;
text-decoration: underline;
}
.errors-wrapper .error-list .error-item .line-no {
display: inline-block;
text-align: right;
width: 70px;
}
.errors-wrapper .error-list .error-item .line-content {
display: inline;
width: calc(100% - 70px);
padding-left: 10px;
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<el-tag :type="type">
<el-tag :type="type" class="status-tag">
<i :class="icon"></i>
{{$t(label)}}
</el-tag>
</template>
@@ -38,6 +39,16 @@ export default {
return s.label
}
return 'NA'
},
icon () {
if (this.status === 'finished') {
return 'el-icon-check'
} else if (this.status === 'running') {
return 'el-icon-loading'
} else if (this.status === 'error') {
return 'el-icon-error'
}
return ''
}
}
}

View File

@@ -8,7 +8,12 @@
<el-table-column :key="col" :label="col" :property="col" min-width="120">
<template slot-scope="scope">
<el-popover trigger="hover" :content="getString(scope.row[col])" popper-class="cell-popover">
<div slot="reference" class="wrapper">
<div v-if="isUrl(scope.row[col])" slot="reference" class="wrapper">
<a :href="getString(scope.row[col])" target="_blank" style="color: #409eff">
{{getString(scope.row[col])}}
</a>
</div>
<div v-else slot="reference" class="wrapper">
{{getString(scope.row[col])}}
</div>
</el-popover>
@@ -68,6 +73,11 @@ export default {
}
},
methods: {
isUrl (value) {
if (!value) return false
if (!value.match) return false
return !!value.match(/^https?:\/\//)
},
onPageChange () {
this.$emit('page-change', { pageNum: this.pageNum, pageSize: this.pageSize })
},

View File

@@ -208,6 +208,12 @@ export default {
'Run Type': '运行类型',
'Random': '随机',
'Selected Nodes': '指定节点',
'Search Log': '搜索日志',
'Auto-Scroll': '自动滚动',
'Updating log...': '正在更新日志...',
'Error Count': '错误数',
'Log with errors': '日志错误',
'Empty results': '空结果',
// 任务列表
'Node': '节点',

View File

@@ -1,4 +1,5 @@
import request from '../../api/request'
import utils from '../../utils'
const state = {
// TaskList
@@ -6,6 +7,7 @@ const state = {
taskListTotalCount: 0,
taskForm: {},
taskLog: '',
currentLogIndex: 0,
taskResultsData: [],
taskResultsColumns: [],
taskResultsTotalCount: 0,
@@ -36,6 +38,32 @@ const getters = {
}
}
return keys
},
logData (state) {
const data = state.taskLog.split('\n')
.map((d, i) => {
return {
index: i + 1,
data: d,
active: state.currentLogIndex === i + 1
}
})
if (state.taskForm && state.taskForm.status === 'running') {
data.push({
index: data.length + 1,
data: '###LOG_END###'
})
data.push({
index: data.length + 1,
data: ''
})
}
return data
},
errorLogData (state, getters) {
return getters.logData.filter(d => {
return d.data.match(utils.log.errorRegex)
})
}
}
@@ -49,6 +77,9 @@ const mutations = {
SET_TASK_LOG (state, value) {
state.taskLog = value
},
SET_CURRENT_LOG_INDEX (state, value) {
state.currentLogIndex = value
},
SET_TASK_RESULTS_DATA (state, value) {
state.taskResultsData = value
},
@@ -110,7 +141,6 @@ const actions = {
})
},
getTaskLog ({ state, commit }, id) {
commit('SET_TASK_LOG', '')
return request.get(`/tasks/${id}/log`)
.then(response => {
commit('SET_TASK_LOG', response.data.data)

View File

@@ -1,9 +1,11 @@
import stats from './stats'
import encrypt from './encrypt'
import tour from './tour'
import log from './log'
export default {
stats,
encrypt,
tour
tour,
log
}

View File

@@ -0,0 +1,5 @@
const regexToken = ' :,.'
export default {
errorRegex: new RegExp(`(?:[${regexToken}]|^)((?:error|exception|traceback)s?)(?:[${regexToken}]|$)`, 'gi')
}

View File

@@ -60,6 +60,7 @@ export default {
position: relative;
height: 100%;
width: 100%;
background: white;
&.mobile.openSidebar {
position: fixed;

View File

@@ -362,7 +362,7 @@ const initCanvas = () => {
</style>
<style rel="stylesheet/scss" lang="scss" scoped>
$bg: transparent;
$bg: white;
$dark_gray: #889aa4;
$light_gray: #aaa;
.login-container {

View File

@@ -18,7 +18,7 @@
</div>
<!--tabs-->
<el-tabs v-model="activeTabName" @tab-click="onTabClick" type="card">
<el-tabs v-model="activeTabName" @tab-click="onTabClick" type="border-card">
<el-tab-pane :label="$t('Overview')" name="overview">
<node-overview></node-overview>
</el-tab-pane>
@@ -136,8 +136,8 @@ export default {
display: flex;
align-items: center;
position: absolute;
right: 20px;
margin-top: -7px;
right: 30px;
margin-top: 4px;
/*float: right;*/
z-index: 999;
}
@@ -147,6 +147,8 @@ export default {
}
.label {
color: #909399;
font-weight: 100;
width: 100px;
text-align: right;
}

View File

@@ -1,296 +0,0 @@
<template>
<div class="app-container">
<!--add popup-->
<el-dialog
:title="isEditMode ? 'Edit Spider' : 'Add Spider'"
:visible.sync="dialogVisible"
width="60%"
:before-close="onDialogClose">
<el-form label-width="150px"
:model="spiderForm"
:rules="spiderFormRules"
ref="spiderForm"
label-position="right">
<el-form-item label="Spider Name">
<el-input v-model="spiderForm.name" placeholder="Spider Name"></el-input>
</el-form-item>
<el-form-item label="Source Folder">
<el-input v-model="spiderForm.src" placeholder="Source Folder"></el-input>
</el-form-item>
<el-form-item label="Execute Command">
<el-input v-model="spiderForm.cmd" placeholder="Execute Command"></el-input>
</el-form-item>
<el-form-item label="Spider Type">
<el-select v-model="spiderForm.type" placeholder="Select Spider Type">
<el-option :value="1" label="Scrapy"></el-option>
<el-option :value="2" label="PySpider"></el-option>
<el-option :value="3" label="WebMagic"></el-option>
</el-select>
</el-form-item>
<el-form-item label="Language">
<el-select v-model="spiderForm.lang" placeholder="Select Language">
<el-option :value="1" label="Python"></el-option>
<el-option :value="2" label="Nodejs"></el-option>
<el-option :value="3" label="Java"></el-option>
</el-select>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="onCancel">Cancel</el-button>
<el-button type="primary" @click="onSubmit">Submit</el-button>
</span>
</el-dialog>
<!--filter-->
<div class="filter">
<el-input prefix-icon="el-icon-search"
placeholder="Search"
class="filter-search"
v-model="filter.keyword"
@change="onSearch">
</el-input>
<div class="right">
<el-button type="success"
icon="el-icon-refresh"
class="refresh"
@click="onRefresh">
Refresh
</el-button>
<el-button type="primary"
v-if="false"
icon="el-icon-plus"
class="add"
@click="onAdd">
Add Spider
</el-button>
</div>
</div>
<!--table list-->
<el-table :data="filteredTableData"
class="table"
height="500"
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white'}"
border>
<template v-for="col in columns">
<el-table-column v-if="col.name === 'type'"
:key="col.name"
:label="col.label"
:sortable="col.sortable"
align="center"
:width="col.width">
<template slot-scope="scope">
<el-tag v-if="scope.row.type === 'scrapy'">Scrapy</el-tag>
<el-tag type="warning" v-else-if="scope.row.type === 'pyspider'">PySpider</el-tag>
<el-tag type="info" v-else-if="scope.row.type === 'webmagic'">WebMagic</el-tag>
<el-tag type="success" v-else>Other</el-tag>
</template>
</el-table-column>
<el-table-column v-else-if="col.name === 'lang'"
:key="col.name"
:label="col.label"
:sortable="col.sortable"
align="center"
:width="col.width">
<template slot-scope="scope">
<el-tag type="warning" v-if="scope.row.lang === 'python'">Python</el-tag>
<el-tag type="warning" v-else-if="scope.row.lang === 'javascript'">JavaScript</el-tag>
<el-tag type="info" v-else-if="scope.row.lang === 'java'">Java</el-tag>
<el-tag type="danger" v-else-if="scope.row.lang === 'go'">Go</el-tag>
<el-tag type="success" v-else>Other</el-tag>
</template>
</el-table-column>
<el-table-column v-else
:key="col.name"
:property="col.name"
:label="col.label"
:sortable="col.sortable"
align="center"
:width="col.width">
</el-table-column>
</template>
<el-table-column label="Action" align="center" width="250">
<template slot-scope="scope">
<el-tooltip content="View" placement="top">
<el-button type="info" icon="el-icon-search" size="mini" @click="onView(scope.row)"></el-button>
</el-tooltip>
<el-tooltip content="Edit" placement="top">
<el-button type="warning" icon="el-icon-edit" size="mini" @click="onView(scope.row)"></el-button>
</el-tooltip>
<el-tooltip content="Remove" placement="top">
<el-button type="danger" icon="el-icon-delete" size="mini" @click="onRemove(scope.row)"></el-button>
</el-tooltip>
<el-tooltip content="Deploy" placement="top">
<el-button type="primary" icon="fa fa-cloud" size="mini" @click="onDeploy(scope.row)"></el-button>
</el-tooltip>
<el-tooltip content="Run" placement="top">
<el-button type="success" icon="fa fa-bug" size="mini" @click="onCrawl(scope.row)"></el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import {
mapState
} from 'vuex'
export default {
name: 'SpiderList',
data () {
// let tableData = []
// for (let i = 0; i < 50; i++) {
// tableData.push({
// spider_name: `Spider ${Math.floor(Math.random() * 100)}`,
// spider_ip: '127.0.0.1:8888',
// 'spider_description': `The ID of the spider is ${Math.random().toString().replace('0.', '')}`,
// status: Math.floor(Math.random() * 100) % 2
// })
// }
return {
isEditMode: false,
dialogVisible: false,
filter: {
keyword: ''
},
// tableData,
columns: [
{ name: 'name', label: 'Name', width: 'auto' },
{ name: 'type', label: 'Spider Type', width: '160', sortable: true },
{ name: 'lang', label: 'Language', width: '160', sortable: true },
{ name: 'status', label: 'Status', width: '160' }
],
spiderFormRules: {
name: [{ required: true, message: 'Required Field', trigger: 'change' }]
}
}
},
computed: {
...mapState('spider', [
'spiderList',
'spiderForm'
]),
filteredTableData () {
return this.spiderList.filter(d => {
if (!this.filter.keyword) return true
for (let i = 0; i < this.columns.length; i++) {
const colName = this.columns[i].name
if (d[colName] && d[colName].toLowerCase().indexOf(this.filter.keyword.toLowerCase()) > -1) {
return true
}
}
return false
})
}
},
methods: {
onSearch (value) {
console.log(value)
},
onAdd () {
this.$store.commit('spider/SET_SPIDER_FORM', {})
this.isEditMode = false
this.dialogVisible = true
},
onRefresh () {
// this.$store.dispatch('spider/getSpiderList')
},
onSubmit () {
const vm = this
const formName = 'spiderForm'
this.$refs[formName].validate((valid) => {
if (valid) {
if (this.isEditMode) {
vm.$store.dispatch('spider/editSpider')
} else {
vm.$store.dispatch('spider/addSpider')
}
vm.dialogVisible = false
} else {
return false
}
})
},
onCancel () {
this.$store.commit('spider/SET_SPIDER_FORM', {})
this.dialogVisible = false
},
onDialogClose () {
this.$store.commit('spider/SET_SPIDER_FORM', {})
this.dialogVisible = false
},
onEdit (row) {
console.log(row)
this.isEditMode = true
this.$store.commit('spider/SET_SPIDER_FORM', row)
this.dialogVisible = true
},
onRemove (row) {
this.$confirm(this.$t('Are you sure to delete this spider?)', this.$t('Notification'), {
confirmButtonText: this.$t('Confirm'),
cancelButtonText: this.$t('Cancel'),
type: 'warning'
}).then(() => {
this.$store.dispatch('spider/deleteSpider', row._id)
.then(() => {
this.$message({
type: 'success',
message: 'Deleted successfully'
})
})
}))
},
onDeploy (row) {
this.$store.dispatch('spider/getSpiderData', row._id)
this.$store.commit('dialogView/SET_DIALOG_VISIBLE', true)
this.$store.commit('dialogView/SET_DIALOG_TYPE', 'spiderDeploy')
},
onCrawl (row) {
this.$store.dispatch('spider/getSpiderData', row._id)
this.$store.commit('dialogView/SET_DIALOG_VISIBLE', true)
this.$store.commit('dialogView/SET_DIALOG_TYPE', 'spiderRun')
},
onView (row) {
this.$router.push(`/spiders/${row._id}`)
}
},
created () {
// this.$store.dispatch('spider/getSpiderList')
}
}
</script>
<style scoped lang="scss">
.el-dialog {
.el-select {
width: 100%;
}
}
.filter {
display: flex;
justify-content: space-between;
.filter-search {
width: 240px;
}
.add {
}
}
.table {
margin-top: 20px;
border-radius: 5px;
.el-button {
padding: 7px;
}
}
.delete-confirm {
background-color: red;
}
</style>

View File

@@ -31,7 +31,7 @@
</el-form>
</el-dialog>
<el-tabs v-model="activeName" @tab-click="tabActiveHandle">
<el-tabs v-model="activeName" @tab-click="tabActiveHandle" type="border-card">
<el-tab-pane :label="$t('General')" name="general">
<el-form :model="userInfo" class="setting-form" ref="setting-form" label-width="200px" :rules="rules"
inline-message>

View File

@@ -399,7 +399,8 @@ export default {
{ name: 'type', label: 'Spider Type', width: '120', sortable: true },
{ name: 'last_status', label: 'Last Status', width: '120' },
{ name: 'last_run_ts', label: 'Last Run', width: '140' },
// { name: 'update_ts', label: 'Update Time', width: '140' },
{ name: 'update_ts', label: 'Update Time', width: '140' },
{ name: 'create_ts', label: 'Create Time', width: '140' },
{ name: 'remark', label: 'Remark', width: '140' }
],
spiderFormRules: {

View File

@@ -10,18 +10,16 @@
<!--./tour-->
<!--tabs-->
<el-tabs v-model="activeTabName" @tab-click="onTabClick" type="card">
<el-tabs v-model="activeTabName" @tab-click="onTabClick" type="border-card">
<el-tab-pane :label="$t('Overview')" name="overview">
<task-overview/>
<task-overview @click-log="activeTabName = 'log'"/>
</el-tab-pane>
<el-tab-pane :label="$t('Log')" name="log">
<el-card>
<log-view :data="taskLog"/>
</el-card>
<log-view/>
</el-tab-pane>
<el-tab-pane :label="$t('Results')" name="results">
<div class="button-group">
<el-button class="btn-download" type="primary" icon="el-icon-download" @click="downloadCSV">
<el-button size="small" class="btn-download" type="primary" icon="el-icon-download" @click="downloadCSV">
{{$t('Download CSV')}}
</el-button>
</div>
@@ -44,7 +42,6 @@ import {
import TaskOverview from '../../components/Overview/TaskOverview'
import GeneralTableView from '../../components/TableView/GeneralTableView'
import LogView from '../../components/ScrollView/LogView'
import request from '../../api/request'
export default {
name: 'TaskDetail',
@@ -57,7 +54,6 @@ export default {
return {
activeTabName: 'overview',
handle: undefined,
taskLog: '',
// tutorial
tourSteps: [
@@ -139,7 +135,8 @@ export default {
...mapState('task', [
'taskForm',
'taskResultsData',
'taskResultsTotalCount'
'taskResultsTotalCount',
'taskLog'
]),
...mapGetters('task', [
'taskResultsColumns'
@@ -188,11 +185,7 @@ export default {
this.$st.sendEv('任务详情', '结果', '下载CSV')
},
getTaskLog () {
if (this.$route.params.id) {
request.get(`/tasks/${this.$route.params.id}/log`).then(response => {
this.taskLog = response.data.data
})
}
this.$store.dispatch('task/getTaskLog', this.$route.params.id)
}
},
created () {

View File

@@ -18,6 +18,8 @@ services:
depends_on:
- mongo
- redis
volumes:
- "/opt/crawlab/log:/var/logs/crawlab" # log persistent 日志持久化
worker:
image: "tikazyq/crawlab:master"
environment:
@@ -30,6 +32,8 @@ services:
depends_on:
- mongo
- redis
volumes:
- "/opt/crawlab/log:/var/logs/crawlab" # log persistent 日志持久化
mongo:
image: mongo:latest
restart: always