mirror of https://github.com/openspug/spug
A 任务计划增加执行历史查看功能
parent
3dce00995e
commit
b08a3e7000
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()),
|
||||
]
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue