diff --git a/spug_api/apps/schedule/models.py b/spug_api/apps/schedule/models.py index aa79805..720b205 100644 --- a/spug_api/apps/schedule/models.py +++ b/spug_api/apps/schedule/models.py @@ -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 diff --git a/spug_api/apps/schedule/scheduler.py b/spug_api/apps/schedule/scheduler.py index badab4d..d11707d 100644 --- a/spug_api/apps/schedule/scheduler.py +++ b/spug_api/apps/schedule/scheduler.py @@ -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() diff --git a/spug_api/apps/schedule/urls.py b/spug_api/apps/schedule/urls.py index 7f6a220..63e824d 100644 --- a/spug_api/apps/schedule/urls.py +++ b/spug_api/apps/schedule/urls.py @@ -1,12 +1,12 @@ # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) # 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('/', ScheduleInfo.as_view()), + path('', Schedule.as_view()), + path('/', ScheduleInfo.as_view()), + path('/history/', HistoryView.as_view()), ] diff --git a/spug_api/apps/schedule/utils.py b/spug_api/apps/schedule/utils.py new file mode 100644 index 0000000..933bf53 --- /dev/null +++ b/spug_api/apps/schedule/utils.py @@ -0,0 +1,13 @@ +# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug +# Copyright: (c) +# 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 diff --git a/spug_api/apps/schedule/views.py b/spug_api/apps/schedule/views.py index 03c802c..b451b33 100644 --- a/spug_api/apps/schedule/views.py +++ b/spug_api/apps/schedule/views.py @@ -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 diff --git a/spug_web/src/pages/schedule/Form.js b/spug_web/src/pages/schedule/Form.js index 8897f3c..a45a6b4 100644 --- a/spug_web/src/pages/schedule/Form.js +++ b/spug_web/src/pages/schedule/Form.js @@ -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]; diff --git a/spug_web/src/pages/schedule/Record.js b/spug_web/src/pages/schedule/Record.js new file mode 100644 index 0000000..5f48335 --- /dev/null +++ b/spug_web/src/pages/schedule/Record.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * 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 => {info['status_alias']} + }, { + title: '操作', + render: info => store.showInfo(info)}>详情 + }]; + + render() { + return ( + store.recordVisible = false} + footer={null}> + + + ) + } +} + +export default Record \ No newline at end of file diff --git a/spug_web/src/pages/schedule/Table.js b/spug_web/src/pages/schedule/Table.js index ae05a83..a750a0a 100644 --- a/spug_web/src/pages/schedule/Table.js +++ b/spug_web/src/pages/schedule/Table.js @@ -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 { this.handleActive(info)}>{info.is_active ? '禁用' : '激活'} - + + store.showRecord(info)}>历史 + + this.handleDelete(info)}>删除 @@ -68,7 +72,7 @@ class ComTable extends React.Component { width: 180, render: info => ( - store.showInfo(info)}>详情 + store.showInfo(info, true)}>详情 store.showForm(info)}>编辑 @@ -134,6 +138,7 @@ class ComTable extends React.Component {
{store.formVisible && } {store.infoVisible && } + {store.recordVisible && } ) } diff --git a/spug_web/src/pages/schedule/store.js b/spug_web/src/pages/schedule/store.js index 10668a1..c85a29e 100644 --- a/spug_web/src/pages/schedule/store.js +++ b/spug_web/src/pages/schedule/store.js @@ -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 };