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