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 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): class Task(models.Model, ModelMixin):
TRIGGERS = ( TRIGGERS = (
('date', '一次性'), ('date', '一次性'),
@ -14,11 +35,6 @@ class Task(models.Model, ModelMixin):
('cron', 'UNIX cron'), ('cron', 'UNIX cron'),
('interval', '普通间隔') ('interval', '普通间隔')
) )
STATUS = (
(0, '成功'),
(1, '异常'),
(2, '失败'),
)
name = models.CharField(max_length=50) name = models.CharField(max_length=50)
type = models.CharField(max_length=50) type = models.CharField(max_length=50)
command = models.TextField() command = models.TextField()
@ -27,9 +43,7 @@ class Task(models.Model, ModelMixin):
trigger_args = models.CharField(max_length=255) trigger_args = models.CharField(max_length=255)
is_active = models.BooleanField(default=False) is_active = models.BooleanField(default=False)
desc = models.CharField(max_length=255, null=True) desc = models.CharField(max_length=255, null=True)
latest_status = models.SmallIntegerField(choices=STATUS, null=True) latest = models.ForeignKey(History, on_delete=models.PROTECT, null=True)
latest_run_time = models.CharField(max_length=20, null=True)
latest_output = models.TextField(null=True)
created_at = models.CharField(max_length=20, default=human_datetime) created_at = models.CharField(max_length=20, default=human_datetime)
created_by = models.ForeignKey(User, models.PROTECT, related_name='+') created_by = models.ForeignKey(User, models.PROTECT, related_name='+')
@ -39,7 +53,9 @@ class Task(models.Model, ModelMixin):
def to_dict(self, *args, **kwargs): def to_dict(self, *args, **kwargs):
tmp = super().to_dict(*args, **kwargs) tmp = super().to_dict(*args, **kwargs)
tmp['targets'] = json.loads(self.targets) 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': if self.trigger == 'cron':
tmp['trigger_args'] = json.loads(self.trigger_args) tmp['trigger_args'] = json.loads(self.trigger_args)
return tmp 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_redis import get_redis_connection
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from django.db import close_old_connections 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.notify.models import Notify
from apps.schedule.executors import dispatch from apps.schedule.executors import dispatch
from apps.schedule.utils import auto_clean_schedule_history
from apps.alarm.utils import auto_clean_records from apps.alarm.utils import auto_clean_records
from django.conf import settings from django.conf import settings
from libs import AttrDict, human_datetime from libs import AttrDict, human_datetime
@ -63,17 +64,20 @@ class Scheduler:
score = 0 score = 0
for item in event.retval: for item in event.retval:
score += 1 if item[1] else 0 score += 1 if item[1] else 0
Task.objects.filter(pk=event.job_id).update( history = History.objects.create(
latest_status=2 if score == len(event.retval) else 1 if score else 0, task_id=event.job_id,
latest_run_time=human_datetime(event.scheduled_run_time), status=2 if score == len(event.retval) else 1 if score else 0,
latest_output=json.dumps(event.retval) 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: if score != 0 and time.time() - counter.get(event.job_id, 0) > 3600:
counter[event.job_id] = time.time() counter[event.job_id] = time.time()
Notify.make_notify('schedule', '1', f'{obj.name} - 执行失败', '请在任务计划中查看失败详情') Notify.make_notify('schedule', '1', f'{obj.name} - 执行失败', '请在任务计划中查看失败详情')
def _init_builtin_jobs(self): def _init_builtin_jobs(self):
self.scheduler.add_job(auto_clean_records, 'cron', hour=0, minute=0) 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): def _init(self):
self.scheduler.start() self.scheduler.start()

View File

@ -1,12 +1,12 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com> # Copyright: (c) <spug.dev@gmail.com>
# Released under the MIT License. # Released under the MIT License.
from django.conf.urls import url
from django.urls import path from django.urls import path
from .views import * from .views import *
urlpatterns = [ urlpatterns = [
url(r'^$', Schedule.as_view()), path('', Schedule.as_view()),
path('<int:t_id>/', ScheduleInfo.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. # Released under the MIT License.
from django.views.generic import View from django.views.generic import View
from django_redis import get_redis_connection 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 apps.host.models import Host
from django.conf import settings from django.conf import settings
from libs import json_response, JsonParser, Argument, human_datetime from libs import json_response, JsonParser, Argument, human_datetime
@ -29,6 +30,15 @@ class Schedule(View):
).parse(request.body) ).parse(request.body)
if error is None: if error is None:
form.targets = json.dumps(form.targets) 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: if form.id:
Task.objects.filter(pk=form.id).update( Task.objects.filter(pk=form.id).update(
updated_at=human_datetime(), updated_at=human_datetime(),
@ -73,16 +83,44 @@ class Schedule(View):
if task.is_active: if task.is_active:
return json_response(error='该任务在运行中,请先停止任务再尝试删除') return json_response(error='该任务在运行中,请先停止任务再尝试删除')
task.delete() task.delete()
History.objects.filter(task_id=task.id).delete()
return json_response(error=error) return json_response(error=error)
class ScheduleInfo(View): class HistoryView(View):
def get(self, request, t_id): def get(self, request, t_id):
task = Task.objects.filter(pk=t_id).first() h_id = request.GET.get('id')
outputs = json.loads(task.latest_output) 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)) 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)} 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: for h_id, code, duration, out in outputs:
key = 'success' if code == 0 else 'failure' key = 'success' if code == 0 else 'failure'
data[key] += 1 data[key] += 1

View File

@ -39,13 +39,13 @@ class ComForm extends React.Component {
_parse_args = (trigger) => { _parse_args = (trigger) => {
switch (trigger) { switch (trigger) {
case 'date': 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': case 'cron':
const {rule, start, stop} = this.state.args['cron']; const {rule, start, stop} = this.state.args['cron'];
return JSON.stringify({ return JSON.stringify({
rule, rule,
start: start ? start.format('YYYY-MM-DD HH:mm:ss') : null, start: start ? moment(start).format('YYYY-MM-DD HH:mm:ss') : null,
stop: stop ? stop.format('YYYY-MM-DD HH:mm:ss') : null stop: stop ? moment(stop).format('YYYY-MM-DD HH:mm:ss') : null
}); });
default: default:
return this.state.args[trigger]; 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 store from './store';
import { LinkButton } from "components"; import { LinkButton } from "components";
import Info from './Info'; import Info from './Info';
import Record from './Record';
@observer @observer
class ComTable extends React.Component { class ComTable extends React.Component {
@ -25,7 +26,10 @@ class ComTable extends React.Component {
<Menu.Item> <Menu.Item>
<LinkButton auth="schedule.schedule.edit" onClick={() => this.handleActive(info)}>{info.is_active ? '禁用' : '激活'}</LinkButton> <LinkButton auth="schedule.schedule.edit" onClick={() => this.handleActive(info)}>{info.is_active ? '禁用' : '激活'}</LinkButton>
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Item>
<LinkButton onClick={() => store.showRecord(info)}>历史</LinkButton>
</Menu.Item>
<Menu.Divider/>
<Menu.Item> <Menu.Item>
<LinkButton auth="schedule.schedule.del" onClick={() => this.handleDelete(info)}>删除</LinkButton> <LinkButton auth="schedule.schedule.del" onClick={() => this.handleDelete(info)}>删除</LinkButton>
</Menu.Item> </Menu.Item>
@ -68,7 +72,7 @@ class ComTable extends React.Component {
width: 180, width: 180,
render: info => ( render: info => (
<span> <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"/> <Divider type="vertical"/>
<LinkButton auth="schedule.schedule.edit" onClick={() => store.showForm(info)}>编辑</LinkButton> <LinkButton auth="schedule.schedule.edit" onClick={() => store.showForm(info)}>编辑</LinkButton>
<Divider type="vertical"/> <Divider type="vertical"/>
@ -134,6 +138,7 @@ class ComTable extends React.Component {
<Table rowKey="id" loading={store.isFetching} dataSource={data} columns={this.columns}/> <Table rowKey="id" loading={store.isFetching} dataSource={data} columns={this.columns}/>
{store.formVisible && <ComForm/>} {store.formVisible && <ComForm/>}
{store.infoVisible && <Info/>} {store.infoVisible && <Info/>}
{store.recordVisible && <Record/>}
</React.Fragment> </React.Fragment>
) )
} }

View File

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