A 任务计划增加执行历史查看功能

pull/103/head
vapao 2020-05-28 08:30:54 +08:00
parent 3dce00995e
commit b08a3e7000
9 changed files with 168 additions and 28 deletions

View File

@ -7,6 +7,27 @@ from apps.account.models import User
import json
class History(models.Model, ModelMixin):
STATUS = (
(0, '成功'),
(1, '异常'),
(2, '失败'),
)
task_id = models.IntegerField()
status = models.SmallIntegerField(choices=STATUS)
run_time = models.CharField(max_length=20)
output = models.TextField()
def to_list(self):
tmp = super().to_dict(selects=('id', 'status', 'run_time'))
tmp['status_alias'] = self.get_status_display()
return tmp
class Meta:
db_table = 'task_histories'
ordering = ('-id',)
class Task(models.Model, ModelMixin):
TRIGGERS = (
('date', '一次性'),
@ -14,11 +35,6 @@ class Task(models.Model, ModelMixin):
('cron', 'UNIX cron'),
('interval', '普通间隔')
)
STATUS = (
(0, '成功'),
(1, '异常'),
(2, '失败'),
)
name = models.CharField(max_length=50)
type = models.CharField(max_length=50)
command = models.TextField()
@ -27,9 +43,7 @@ class Task(models.Model, ModelMixin):
trigger_args = models.CharField(max_length=255)
is_active = models.BooleanField(default=False)
desc = models.CharField(max_length=255, null=True)
latest_status = models.SmallIntegerField(choices=STATUS, null=True)
latest_run_time = models.CharField(max_length=20, null=True)
latest_output = models.TextField(null=True)
latest = models.ForeignKey(History, on_delete=models.PROTECT, null=True)
created_at = models.CharField(max_length=20, default=human_datetime)
created_by = models.ForeignKey(User, models.PROTECT, related_name='+')
@ -39,7 +53,9 @@ class Task(models.Model, ModelMixin):
def to_dict(self, *args, **kwargs):
tmp = super().to_dict(*args, **kwargs)
tmp['targets'] = json.loads(self.targets)
tmp['latest_status_alias'] = self.get_latest_status_display()
tmp['latest_status'] = self.latest.status if self.latest else None
tmp['latest_run_time'] = self.latest.run_time if self.latest else None
tmp['latest_status_alias'] = self.latest.get_status_display() if self.latest else None
if self.trigger == 'cron':
tmp['trigger_args'] = json.loads(self.trigger_args)
return tmp

View File

@ -9,9 +9,10 @@ from apscheduler.events import EVENT_SCHEDULER_SHUTDOWN, EVENT_JOB_MAX_INSTANCES
from django_redis import get_redis_connection
from django.utils.functional import SimpleLazyObject
from django.db import close_old_connections
from apps.schedule.models import Task
from apps.schedule.models import Task, History
from apps.notify.models import Notify
from apps.schedule.executors import dispatch
from apps.schedule.utils import auto_clean_schedule_history
from apps.alarm.utils import auto_clean_records
from django.conf import settings
from libs import AttrDict, human_datetime
@ -63,17 +64,20 @@ class Scheduler:
score = 0
for item in event.retval:
score += 1 if item[1] else 0
Task.objects.filter(pk=event.job_id).update(
latest_status=2 if score == len(event.retval) else 1 if score else 0,
latest_run_time=human_datetime(event.scheduled_run_time),
latest_output=json.dumps(event.retval)
history = History.objects.create(
task_id=event.job_id,
status=2 if score == len(event.retval) else 1 if score else 0,
run_time=human_datetime(event.scheduled_run_time),
output=json.dumps(event.retval)
)
Task.objects.filter(pk=event.job_id).update(latest=history)
if score != 0 and time.time() - counter.get(event.job_id, 0) > 3600:
counter[event.job_id] = time.time()
Notify.make_notify('schedule', '1', f'{obj.name} - 执行失败', '请在任务计划中查看失败详情')
def _init_builtin_jobs(self):
self.scheduler.add_job(auto_clean_records, 'cron', hour=0, minute=0)
self.scheduler.add_job(auto_clean_schedule_history, 'cron', hour=0, minute=0)
def _init(self):
self.scheduler.start()

View File

@ -1,12 +1,12 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the MIT License.
from django.conf.urls import url
from django.urls import path
from .views import *
urlpatterns = [
url(r'^$', Schedule.as_view()),
path('<int:t_id>/', ScheduleInfo.as_view()),
path('', Schedule.as_view()),
path('<int:h_id>/', ScheduleInfo.as_view()),
path('<int:t_id>/history/', HistoryView.as_view()),
]

View File

@ -0,0 +1,13 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the MIT License.
from apps.schedule.models import Task, History
def auto_clean_schedule_history():
for task in Task.objects.all():
try:
record = History.objects.filter(task_id=task.id)[50]
History.objects.filter(task_id=task.id, id__lt=record.id).delete()
except IndexError:
pass

View File

@ -3,7 +3,8 @@
# Released under the MIT License.
from django.views.generic import View
from django_redis import get_redis_connection
from apps.schedule.models import Task
from apscheduler.triggers.cron import CronTrigger
from apps.schedule.models import Task, History
from apps.host.models import Host
from django.conf import settings
from libs import json_response, JsonParser, Argument, human_datetime
@ -29,6 +30,15 @@ class Schedule(View):
).parse(request.body)
if error is None:
form.targets = json.dumps(form.targets)
if form.trigger == 'cron':
args = json.loads(form.trigger_args)['rule'].split()
if len(args) != 5:
return json_response(error='无效的执行规则,请更正后再试')
minute, hour, day, month, week = args
try:
CronTrigger(minute=minute, hour=hour, day=day, month=month, week=week)
except ValueError:
return json_response(error='无效的执行规则,请更正后再试')
if form.id:
Task.objects.filter(pk=form.id).update(
updated_at=human_datetime(),
@ -73,16 +83,44 @@ class Schedule(View):
if task.is_active:
return json_response(error='该任务在运行中,请先停止任务再尝试删除')
task.delete()
History.objects.filter(task_id=task.id).delete()
return json_response(error=error)
class ScheduleInfo(View):
class HistoryView(View):
def get(self, request, t_id):
task = Task.objects.filter(pk=t_id).first()
outputs = json.loads(task.latest_output)
h_id = request.GET.get('id')
if h_id:
return json_response(self.fetch_detail(h_id))
histories = History.objects.filter(task_id=t_id)
return json_response([x.to_list() for x in histories])
def fetch_detail(self, h_id):
record = History.objects.filter(pk=h_id).first()
outputs = json.loads(record.output)
host_ids = (x[0] for x in outputs if isinstance(x[0], int))
hosts_info = {x.id: x.name for x in Host.objects.filter(id__in=host_ids)}
data = {'run_time': task.latest_run_time, 'success': 0, 'failure': 0, 'duration': 0, 'outputs': []}
data = {'run_time': record.run_time, 'success': 0, 'failure': 0, 'duration': 0, 'outputs': []}
for h_id, code, duration, out in outputs:
key = 'success' if code == 0 else 'failure'
data[key] += 1
data['duration'] += duration
data['outputs'].append({
'name': hosts_info.get(h_id, '本机'),
'code': code,
'duration': duration,
'output': out})
data['duration'] = f"{data['duration'] / len(outputs):.3f}"
return data
class ScheduleInfo(View):
def get(self, request, h_id):
history = History.objects.filter(pk=h_id).first()
outputs = json.loads(history.output)
host_ids = (x[0] for x in outputs if isinstance(x[0], int))
hosts_info = {x.id: x.name for x in Host.objects.filter(id__in=host_ids)}
data = {'run_time': history.run_time, 'success': 0, 'failure': 0, 'duration': 0, 'outputs': []}
for h_id, code, duration, out in outputs:
key = 'success' if code == 0 else 'failure'
data[key] += 1

View File

@ -39,13 +39,13 @@ class ComForm extends React.Component {
_parse_args = (trigger) => {
switch (trigger) {
case 'date':
return this.state.args['date'].format('YYYY-MM-DD HH:mm:ss');
return moment(this.state.args['date']).format('YYYY-MM-DD HH:mm:ss');
case 'cron':
const {rule, start, stop} = this.state.args['cron'];
return JSON.stringify({
rule,
start: start ? start.format('YYYY-MM-DD HH:mm:ss') : null,
stop: stop ? stop.format('YYYY-MM-DD HH:mm:ss') : null
start: start ? moment(start).format('YYYY-MM-DD HH:mm:ss') : null,
stop: stop ? moment(stop).format('YYYY-MM-DD HH:mm:ss') : null
});
default:
return this.state.args[trigger];

View File

@ -0,0 +1,57 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the MIT License.
*/
import React from 'react';
import { observer } from 'mobx-react';
import { Modal, Table, Tag } from 'antd';
import { LinkButton } from 'components';
import { http } from 'libs';
import store from './store';
@observer
class Record extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: true,
records: []
}
}
componentDidMount() {
http.get(`/api/schedule/${store.record.id}/history/`)
.then(res => this.setState({records: res}))
.finally(() => this.setState({loading: false}))
}
colors = ['green', 'orange', 'red'];
columns = [{
title: '执行时间',
dataIndex: 'run_time'
}, {
title: '执行状态',
render: info => <Tag color={this.colors[info['status']]}>{info['status_alias']}</Tag>
}, {
title: '操作',
render: info => <LinkButton onClick={() => store.showInfo(info)}>详情</LinkButton>
}];
render() {
return (
<Modal
visible
width={800}
maskClosable={false}
title={`任务执行记录 - ${store.record.name}`}
onCancel={() => store.recordVisible = false}
footer={null}>
<Table rowKey="id" columns={this.columns} dataSource={this.state.records} loading={this.state.loading}/>
</Modal>
)
}
}
export default Record

View File

@ -11,6 +11,7 @@ import http from 'libs/http';
import store from './store';
import { LinkButton } from "components";
import Info from './Info';
import Record from './Record';
@observer
class ComTable extends React.Component {
@ -25,7 +26,10 @@ class ComTable extends React.Component {
<Menu.Item>
<LinkButton auth="schedule.schedule.edit" onClick={() => this.handleActive(info)}>{info.is_active ? '禁用' : '激活'}</LinkButton>
</Menu.Item>
<Menu.Divider />
<Menu.Item>
<LinkButton onClick={() => store.showRecord(info)}>历史</LinkButton>
</Menu.Item>
<Menu.Divider/>
<Menu.Item>
<LinkButton auth="schedule.schedule.del" onClick={() => this.handleDelete(info)}>删除</LinkButton>
</Menu.Item>
@ -68,7 +72,7 @@ class ComTable extends React.Component {
width: 180,
render: info => (
<span>
<LinkButton disabled={!info['latest_run_time']} onClick={() => store.showInfo(info)}>详情</LinkButton>
<LinkButton disabled={!info['latest_run_time']} onClick={() => store.showInfo(info, true)}>详情</LinkButton>
<Divider type="vertical"/>
<LinkButton auth="schedule.schedule.edit" onClick={() => store.showForm(info)}>编辑</LinkButton>
<Divider type="vertical"/>
@ -134,6 +138,7 @@ class ComTable extends React.Component {
<Table rowKey="id" loading={store.isFetching} dataSource={data} columns={this.columns}/>
{store.formVisible && <ComForm/>}
{store.infoVisible && <Info/>}
{store.recordVisible && <Record/>}
</React.Fragment>
)
}

View File

@ -15,6 +15,7 @@ class Store {
@observable isFetching = false;
@observable formVisible = false;
@observable infoVisible = false;
@observable recordVisible = false;
@observable f_status;
@observable f_name;
@ -40,8 +41,14 @@ class Store {
this.record = info
};
showInfo = (info = {}) => {
showInfo = (info = {}, isTask) => {
const record = isTask ? {id: info['latest_id']} : info;
this.infoVisible = true;
this.record = record
};
showRecord = (info) => {
this.recordVisible = true;
this.record = info
};