django-simpletask5 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_simpletask5/__init__.py +7 -0
- django_simpletask5/admin.py +205 -0
- django_simpletask5/apps.py +8 -0
- django_simpletask5/core/__init__.py +0 -0
- django_simpletask5/core/cronjob_registry.py +96 -0
- django_simpletask5/core/defaults.py +24 -0
- django_simpletask5/core/executor_scanner.py +82 -0
- django_simpletask5/core/lock.py +24 -0
- django_simpletask5/core/message_queue.py +102 -0
- django_simpletask5/core/publisher.py +93 -0
- django_simpletask5/core/signals.py +120 -0
- django_simpletask5/core/worker_registry.py +134 -0
- django_simpletask5/dashboards.py +274 -0
- django_simpletask5/executors/__init__.py +0 -0
- django_simpletask5/executors/archive.py +24 -0
- django_simpletask5/executors/base.py +10 -0
- django_simpletask5/executors/bash_script.py +61 -0
- django_simpletask5/executors/loader.py +17 -0
- django_simpletask5/executors/ping_pong.py +12 -0
- django_simpletask5/executors/python_script.py +42 -0
- django_simpletask5/executors/retry_timeout.py +32 -0
- django_simpletask5/executors/simple_request.py +47 -0
- django_simpletask5/executors/status_check.py +40 -0
- django_simpletask5/management/__init__.py +0 -0
- django_simpletask5/management/commands/__init__.py +0 -0
- django_simpletask5/management/commands/django_simpletask_crontab.py +169 -0
- django_simpletask5/management/commands/django_simpletask_executor.py +286 -0
- django_simpletask5/management/commands/django_simpletask_sync_cronjobs.py +14 -0
- django_simpletask5/migrations/0001_initial.py +283 -0
- django_simpletask5/migrations/__init__.py +0 -0
- django_simpletask5/models.py +274 -0
- django_simpletask5/services/__init__.py +0 -0
- django_simpletask5/services/archive.py +198 -0
- django_simpletask5-0.1.0.dist-info/METADATA +251 -0
- django_simpletask5-0.1.0.dist-info/RECORD +38 -0
- django_simpletask5-0.1.0.dist-info/WHEEL +5 -0
- django_simpletask5-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_simpletask5-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
from django.db.models.signals import post_save, pre_save
|
|
4
|
+
from django.dispatch import receiver
|
|
5
|
+
|
|
6
|
+
from django_simpletask5.models import Task
|
|
7
|
+
from django_simpletask5.core.publisher import create_and_publish, _model_to_dict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _DeleteIntercepted(Exception):
|
|
11
|
+
def __init__(self, instance):
|
|
12
|
+
self.instance = instance
|
|
13
|
+
super().__init__('Delete intercepted, TaskExecution created instead')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_pre_save_data = threading.local()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@receiver(pre_save)
|
|
20
|
+
def handle_task_pre_save(sender, instance, **kwargs):
|
|
21
|
+
if not _is_task_subclass(sender):
|
|
22
|
+
return
|
|
23
|
+
if instance.pk and not getattr(instance, '_skip_pre_save_snapshot', False):
|
|
24
|
+
try:
|
|
25
|
+
old = sender.objects.get(pk=instance.pk)
|
|
26
|
+
_pre_save_data.data = _model_to_dict(old)
|
|
27
|
+
except sender.DoesNotExist:
|
|
28
|
+
_pre_save_data.data = None
|
|
29
|
+
else:
|
|
30
|
+
_pre_save_data.data = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@receiver(post_save)
|
|
34
|
+
def handle_task_post_save(sender, instance, created, **kwargs):
|
|
35
|
+
if not _is_task_subclass(sender):
|
|
36
|
+
return
|
|
37
|
+
if created:
|
|
38
|
+
create_and_publish(
|
|
39
|
+
task=instance,
|
|
40
|
+
trigger_event='create',
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
old_data = getattr(_pre_save_data, 'data', None)
|
|
44
|
+
new_data = _model_to_dict(instance)
|
|
45
|
+
if old_data:
|
|
46
|
+
changed_fields = _get_changed_fields(old_data, new_data)
|
|
47
|
+
else:
|
|
48
|
+
changed_fields = list(new_data.keys())
|
|
49
|
+
if not changed_fields:
|
|
50
|
+
return
|
|
51
|
+
if not _should_trigger_update(instance, changed_fields):
|
|
52
|
+
return
|
|
53
|
+
create_and_publish(
|
|
54
|
+
task=instance,
|
|
55
|
+
trigger_event='update',
|
|
56
|
+
extra_context={
|
|
57
|
+
'old_data': old_data or {},
|
|
58
|
+
'new_data': new_data,
|
|
59
|
+
'changed_fields': changed_fields,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_task_subclass(sender):
|
|
65
|
+
if not issubclass(sender, Task):
|
|
66
|
+
return False
|
|
67
|
+
if sender._meta.abstract:
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _should_trigger_update(instance, update_fields):
|
|
73
|
+
whitelist = instance.get_trigger_update_fields()
|
|
74
|
+
blacklist = instance.get_trigger_update_blacklist()
|
|
75
|
+
|
|
76
|
+
if whitelist is not None:
|
|
77
|
+
if update_fields:
|
|
78
|
+
return any(f in whitelist for f in update_fields)
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
if blacklist is not None:
|
|
82
|
+
if update_fields:
|
|
83
|
+
return not all(f in blacklist for f in update_fields)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _get_changed_field_names(old_data, new_data):
|
|
90
|
+
changed = []
|
|
91
|
+
for key in new_data:
|
|
92
|
+
if key in old_data:
|
|
93
|
+
old_val = old_data[key]
|
|
94
|
+
new_val = new_data[key]
|
|
95
|
+
if old_val != new_val:
|
|
96
|
+
try:
|
|
97
|
+
from decimal import Decimal
|
|
98
|
+
if Decimal(str(old_val)) == Decimal(str(new_val)):
|
|
99
|
+
continue
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
changed.append(key)
|
|
103
|
+
return changed
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_changed_fields(old, new):
|
|
107
|
+
changed = []
|
|
108
|
+
for key in new:
|
|
109
|
+
if key in old:
|
|
110
|
+
old_val = old[key]
|
|
111
|
+
new_val = new[key]
|
|
112
|
+
if old_val != new_val:
|
|
113
|
+
try:
|
|
114
|
+
from decimal import Decimal
|
|
115
|
+
if Decimal(str(old_val)) == Decimal(str(new_val)):
|
|
116
|
+
continue
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
changed.append(key)
|
|
120
|
+
return changed
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
WORKER_TTL = 300
|
|
13
|
+
|
|
14
|
+
_REDIS_UNAVAILABLE = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_redis_client():
|
|
18
|
+
import redis as _redis
|
|
19
|
+
lock_config = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_CONFIG', {})
|
|
20
|
+
opts = lock_config.get('global_lock_engine_options', {})
|
|
21
|
+
return _redis.Redis(
|
|
22
|
+
host=opts.get('host', '127.0.0.1'),
|
|
23
|
+
port=opts.get('port', 6379),
|
|
24
|
+
db=opts.get('db', 0),
|
|
25
|
+
password=opts.get('password', None) or None,
|
|
26
|
+
decode_responses=True,
|
|
27
|
+
socket_connect_timeout=3,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _safe_redis_call(fn, default=None, log_level=logging.WARNING):
|
|
32
|
+
global _REDIS_UNAVAILABLE
|
|
33
|
+
try:
|
|
34
|
+
result = fn()
|
|
35
|
+
if _REDIS_UNAVAILABLE:
|
|
36
|
+
_REDIS_UNAVAILABLE = False
|
|
37
|
+
logger.info('Redis connection restored')
|
|
38
|
+
return result
|
|
39
|
+
except Exception as e:
|
|
40
|
+
if not _REDIS_UNAVAILABLE:
|
|
41
|
+
_REDIS_UNAVAILABLE = True
|
|
42
|
+
logger.log(log_level, 'Redis operation failed: %s', e)
|
|
43
|
+
return default
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _worker_key(pid, tid):
|
|
47
|
+
return f'simpletask:worker:{pid}.{tid}'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _workers_pattern():
|
|
51
|
+
return 'simpletask:worker:*'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register_worker(queue_name, services=None, pid=None, tid=None):
|
|
55
|
+
pid = pid or os.getpid()
|
|
56
|
+
tid = tid or threading.get_ident()
|
|
57
|
+
key = _worker_key(pid, tid)
|
|
58
|
+
client = _get_redis_client()
|
|
59
|
+
info = {
|
|
60
|
+
'pid': str(pid),
|
|
61
|
+
'tid': str(tid),
|
|
62
|
+
'hostname': socket.gethostname(),
|
|
63
|
+
'queue': queue_name,
|
|
64
|
+
'services': json.dumps(services or []),
|
|
65
|
+
'status': 'idle',
|
|
66
|
+
'current_execution_id': '',
|
|
67
|
+
'started_at': str(time.time()),
|
|
68
|
+
'updated_at': str(time.time()),
|
|
69
|
+
}
|
|
70
|
+
_safe_redis_call(lambda: (client.hset(key, mapping=info), client.expire(key, WORKER_TTL)) and None)
|
|
71
|
+
return key
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def unregister_worker(pid=None, tid=None):
|
|
75
|
+
pid = pid or os.getpid()
|
|
76
|
+
tid = tid or threading.get_ident()
|
|
77
|
+
key = _worker_key(pid, tid)
|
|
78
|
+
client = _get_redis_client()
|
|
79
|
+
_safe_redis_call(lambda: client.delete(key))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def update_worker_status(status, execution_id='', pid=None, tid=None):
|
|
83
|
+
pid = pid or os.getpid()
|
|
84
|
+
tid = tid or threading.get_ident()
|
|
85
|
+
key = _worker_key(pid, tid)
|
|
86
|
+
client = _get_redis_client()
|
|
87
|
+
_safe_redis_call(lambda: (client.hset(key, 'status', status), client.hset(key, 'current_execution_id', execution_id or ''), client.hset(key, 'updated_at', str(time.time())), client.expire(key, WORKER_TTL)) and None)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def heartbeat_worker(pid=None, tid=None):
|
|
91
|
+
pid = pid or os.getpid()
|
|
92
|
+
tid = tid or threading.get_ident()
|
|
93
|
+
key = _worker_key(pid, tid)
|
|
94
|
+
client = _get_redis_client()
|
|
95
|
+
_safe_redis_call(lambda: (client.hset(key, 'updated_at', str(time.time())), client.expire(key, WORKER_TTL)) and None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_all_workers():
|
|
99
|
+
client = _get_redis_client()
|
|
100
|
+
keys = _safe_redis_call(lambda: client.keys(_workers_pattern()), default=[])
|
|
101
|
+
if not keys:
|
|
102
|
+
return []
|
|
103
|
+
workers = []
|
|
104
|
+
for key in keys:
|
|
105
|
+
data = _safe_redis_call(lambda k=key: client.hgetall(k), default={})
|
|
106
|
+
if data:
|
|
107
|
+
data['services'] = json.loads(data.get('services', '[]'))
|
|
108
|
+
data['redis_key'] = key
|
|
109
|
+
workers.append(data)
|
|
110
|
+
return workers
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_workers_by_queue(queue_name):
|
|
114
|
+
return [w for w in get_all_workers() if w.get('queue') == queue_name]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_worker_stats():
|
|
118
|
+
workers = get_all_workers()
|
|
119
|
+
total = len(workers)
|
|
120
|
+
by_queue = {}
|
|
121
|
+
by_status = {}
|
|
122
|
+
for w in workers:
|
|
123
|
+
q = w.get('queue', 'unknown')
|
|
124
|
+
s = w.get('status', 'unknown')
|
|
125
|
+
by_queue.setdefault(q, 0)
|
|
126
|
+
by_queue[q] += 1
|
|
127
|
+
by_status.setdefault(s, 0)
|
|
128
|
+
by_status[s] += 1
|
|
129
|
+
return {
|
|
130
|
+
'total': total,
|
|
131
|
+
'by_queue': by_queue,
|
|
132
|
+
'by_status': by_status,
|
|
133
|
+
'workers': workers,
|
|
134
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from datetime import date, timedelta
|
|
2
|
+
|
|
3
|
+
from django.db.models import Count
|
|
4
|
+
from django.utils.translation import gettext_lazy as _
|
|
5
|
+
|
|
6
|
+
from django_admin_dashboards.base import Dashboard, Layout, CardComponent, ChartComponent, TableComponent
|
|
7
|
+
|
|
8
|
+
from django_simpletask5.models import TaskExecution, CronJob, TaskExecutionArchive
|
|
9
|
+
from django_simpletask5.core.worker_registry import get_worker_stats as _get_worker_stats
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskDashboard(Dashboard):
|
|
13
|
+
title = _("Task Manager Dashboard")
|
|
14
|
+
auto_refresh = 60
|
|
15
|
+
|
|
16
|
+
class Media:
|
|
17
|
+
css = {
|
|
18
|
+
"all": (
|
|
19
|
+
"remixicon/remixicon.css",
|
|
20
|
+
"admin/css/vendor/select2/select2.min.css",
|
|
21
|
+
"django_admin_dashboards/css/dashboard.css",
|
|
22
|
+
"django_simpletask5/css/dashboard.css",
|
|
23
|
+
),
|
|
24
|
+
}
|
|
25
|
+
js = (
|
|
26
|
+
"django_admin_dashboards/js/chart.umd.js",
|
|
27
|
+
"admin/js/vendor/jquery/jquery.min.js",
|
|
28
|
+
"admin/js/vendor/select2/select2.full.min.js",
|
|
29
|
+
"admin/js/jquery.init.js",
|
|
30
|
+
"django_admin_dashboards/js/dashboard.js",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def get_layout(self):
|
|
34
|
+
layout = Layout(columns=12)
|
|
35
|
+
|
|
36
|
+
today = date.today()
|
|
37
|
+
|
|
38
|
+
total_today = TaskExecution.objects.filter(
|
|
39
|
+
created_at__date=today
|
|
40
|
+
).count()
|
|
41
|
+
success_today = TaskExecution.objects.filter(
|
|
42
|
+
created_at__date=today, status="success"
|
|
43
|
+
).count()
|
|
44
|
+
pending = TaskExecution.objects.filter(status="pending").count()
|
|
45
|
+
running = TaskExecution.objects.filter(status="running").count()
|
|
46
|
+
active_cronjobs = CronJob.objects.filter(is_active=True).count()
|
|
47
|
+
total_cronjobs = CronJob.objects.count()
|
|
48
|
+
archives_count = TaskExecutionArchive.objects.count()
|
|
49
|
+
failed_today = TaskExecution.objects.filter(
|
|
50
|
+
created_at__date=today, status="failed"
|
|
51
|
+
).count()
|
|
52
|
+
|
|
53
|
+
worker_stats = _get_worker_stats()
|
|
54
|
+
worker_total = worker_stats["total"]
|
|
55
|
+
worker_idle = worker_stats["by_status"].get("idle", 0)
|
|
56
|
+
worker_running_count = worker_stats["by_status"].get("running", 0)
|
|
57
|
+
|
|
58
|
+
success_rate = 0
|
|
59
|
+
if total_today > 0:
|
|
60
|
+
success_rate = round(success_today / total_today * 100)
|
|
61
|
+
|
|
62
|
+
layout.add_row([
|
|
63
|
+
(CardComponent(
|
|
64
|
+
title=_("Today Executions"),
|
|
65
|
+
value=total_today,
|
|
66
|
+
icon_class="ri-flashlight-line",
|
|
67
|
+
color="primary",
|
|
68
|
+
link="../django_simpletask5/taskexecution/?created_at__date__gte=" + today.isoformat(),
|
|
69
|
+
), 3),
|
|
70
|
+
(CardComponent(
|
|
71
|
+
title=_("Success Rate"),
|
|
72
|
+
value=f"{success_rate}%",
|
|
73
|
+
change=f"{success_today}/{total_today}",
|
|
74
|
+
trend="up" if success_rate >= 80 else "down",
|
|
75
|
+
color="success" if success_rate >= 80 else "danger",
|
|
76
|
+
icon_class="ri-checkbox-circle-line",
|
|
77
|
+
), 3),
|
|
78
|
+
(CardComponent(
|
|
79
|
+
title=_("Workers"),
|
|
80
|
+
value=worker_total,
|
|
81
|
+
values=[worker_idle, worker_running_count],
|
|
82
|
+
value_labels=[_("Idle"), _("Running")],
|
|
83
|
+
color="info",
|
|
84
|
+
icon_class="ri-server-line",
|
|
85
|
+
), 3),
|
|
86
|
+
(CardComponent(
|
|
87
|
+
title=_("Cronjobs"),
|
|
88
|
+
value=active_cronjobs,
|
|
89
|
+
values=[active_cronjobs, total_cronjobs - active_cronjobs],
|
|
90
|
+
value_labels=[_("Active"), _("Inactive")],
|
|
91
|
+
links=[
|
|
92
|
+
"../django_simpletask5/cronjob/?is_active__exact=1",
|
|
93
|
+
"../django_simpletask5/cronjob/?is_active__exact=0",
|
|
94
|
+
],
|
|
95
|
+
color="info",
|
|
96
|
+
icon_class="ri-calendar-check-line",
|
|
97
|
+
), 3),
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
layout.add_row([
|
|
101
|
+
(CardComponent(
|
|
102
|
+
title=_("Pending / Running"),
|
|
103
|
+
value=pending,
|
|
104
|
+
values=[pending, running],
|
|
105
|
+
value_labels=[_("Pending"), _("Running")],
|
|
106
|
+
links=[
|
|
107
|
+
"../django_simpletask5/taskexecution/?status__exact=pending",
|
|
108
|
+
"../django_simpletask5/taskexecution/?status__exact=running",
|
|
109
|
+
],
|
|
110
|
+
color="warning",
|
|
111
|
+
icon_class="ri-timer-line",
|
|
112
|
+
), 3),
|
|
113
|
+
(CardComponent(
|
|
114
|
+
title=_("Failed Today"),
|
|
115
|
+
value=failed_today,
|
|
116
|
+
icon_class="ri-error-warning-line",
|
|
117
|
+
color="danger",
|
|
118
|
+
link="../django_simpletask5/taskexecution/?status__exact=failed&created_at__date__gte=" + today.isoformat(),
|
|
119
|
+
), 3),
|
|
120
|
+
(CardComponent(
|
|
121
|
+
title=_("Archives"),
|
|
122
|
+
value=archives_count,
|
|
123
|
+
icon_class="ri-archive-line",
|
|
124
|
+
color="secondary",
|
|
125
|
+
link="../django_simpletask5/taskexecutionarchive/",
|
|
126
|
+
), 3),
|
|
127
|
+
(CardComponent(
|
|
128
|
+
title=_("Retries"),
|
|
129
|
+
value=TaskExecution.objects.filter(status="retry").count(),
|
|
130
|
+
icon_class="ri-refresh-line",
|
|
131
|
+
color="warning",
|
|
132
|
+
link="../django_simpletask5/taskexecution/?status__exact=retry",
|
|
133
|
+
), 3),
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
status_counts = TaskExecution.objects.values("status").annotate(
|
|
137
|
+
count=Count("execution_id")
|
|
138
|
+
)
|
|
139
|
+
status_labels = []
|
|
140
|
+
status_data = []
|
|
141
|
+
status_colors = []
|
|
142
|
+
color_map = {
|
|
143
|
+
"pending": "#ffc107",
|
|
144
|
+
"running": "#0d6efd",
|
|
145
|
+
"success": "#198754",
|
|
146
|
+
"failed": "#dc3545",
|
|
147
|
+
"retry": "#fd7e14",
|
|
148
|
+
"timeout": "#6c757d",
|
|
149
|
+
}
|
|
150
|
+
label_map = {
|
|
151
|
+
"pending": _("Pending"),
|
|
152
|
+
"running": _("Running"),
|
|
153
|
+
"success": _("Success"),
|
|
154
|
+
"failed": _("Failed"),
|
|
155
|
+
"retry": _("Retry"),
|
|
156
|
+
"timeout": _("Timeout"),
|
|
157
|
+
}
|
|
158
|
+
for item in status_counts:
|
|
159
|
+
status_labels.append(str(label_map.get(item["status"], item["status"])))
|
|
160
|
+
status_data.append(item["count"])
|
|
161
|
+
status_colors.append(color_map.get(item["status"], "#adb5bd"))
|
|
162
|
+
|
|
163
|
+
layout.add_row([
|
|
164
|
+
(ChartComponent(
|
|
165
|
+
title=_("Execution Status Distribution"),
|
|
166
|
+
chart_type="doughnut",
|
|
167
|
+
data={
|
|
168
|
+
"labels": status_labels,
|
|
169
|
+
"datasets": [{
|
|
170
|
+
"data": status_data,
|
|
171
|
+
"backgroundColor": status_colors,
|
|
172
|
+
}],
|
|
173
|
+
},
|
|
174
|
+
options={"responsive": True, "maintainAspectRatio": False},
|
|
175
|
+
height=350,
|
|
176
|
+
), 6),
|
|
177
|
+
(ChartComponent(
|
|
178
|
+
title=_("Daily Executions (Last 30 Days)"),
|
|
179
|
+
chart_type="bar",
|
|
180
|
+
data=self._daily_executions_chart(),
|
|
181
|
+
options={"responsive": True, "maintainAspectRatio": False, "scales": {"x": {"stacked": True}, "y": {"stacked": True}}},
|
|
182
|
+
height=350,
|
|
183
|
+
), 6),
|
|
184
|
+
])
|
|
185
|
+
|
|
186
|
+
recent_failed = TaskExecution.objects.filter(
|
|
187
|
+
status__in=["failed", "timeout"]
|
|
188
|
+
).order_by("-created_at")[:10]
|
|
189
|
+
|
|
190
|
+
failed_table_data = []
|
|
191
|
+
for ex in recent_failed:
|
|
192
|
+
failed_table_data.append([
|
|
193
|
+
str(ex.execution_id)[:8],
|
|
194
|
+
ex.executor_class,
|
|
195
|
+
ex.trigger_event,
|
|
196
|
+
ex.status,
|
|
197
|
+
ex.created_at.strftime("%m-%d %H:%M"),
|
|
198
|
+
])
|
|
199
|
+
|
|
200
|
+
layout.add_row([
|
|
201
|
+
(TableComponent(
|
|
202
|
+
title=_("Recent Failed / Timeout Executions"),
|
|
203
|
+
columns=[_("ID"), _("Executor"), _("Event"), _("Status"), _("Time")],
|
|
204
|
+
data=failed_table_data,
|
|
205
|
+
), 6),
|
|
206
|
+
(TableComponent(
|
|
207
|
+
title=_("Recent Executions"),
|
|
208
|
+
columns=[_("ID"), _("Executor"), _("Event"), _("Status"), _("Time")],
|
|
209
|
+
data=[
|
|
210
|
+
[
|
|
211
|
+
str(ex.execution_id)[:8],
|
|
212
|
+
ex.executor_class,
|
|
213
|
+
ex.trigger_event,
|
|
214
|
+
ex.status,
|
|
215
|
+
ex.created_at.strftime("%m-%d %H:%M"),
|
|
216
|
+
]
|
|
217
|
+
for ex in TaskExecution.objects.order_by("-created_at")[:10]
|
|
218
|
+
],
|
|
219
|
+
), 6),
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
worker_rows = []
|
|
223
|
+
for w in worker_stats["workers"]:
|
|
224
|
+
worker_rows.append([
|
|
225
|
+
w.get("hostname", "?"),
|
|
226
|
+
w.get("queue", "?"),
|
|
227
|
+
w.get("status", "?"),
|
|
228
|
+
w.get("current_execution_id", "")[:8] or "-",
|
|
229
|
+
w.get("services", "-") if isinstance(w.get("services"), str) else ",".join(w.get("services", [])) or "-",
|
|
230
|
+
])
|
|
231
|
+
|
|
232
|
+
layout.add_row([
|
|
233
|
+
(TableComponent(
|
|
234
|
+
title=_("Active Workers"),
|
|
235
|
+
columns=[_("Hostname"), _("Queue"), _("Status"), _("Current Execution"), _("Services")],
|
|
236
|
+
data=worker_rows,
|
|
237
|
+
), 12),
|
|
238
|
+
])
|
|
239
|
+
|
|
240
|
+
return layout
|
|
241
|
+
|
|
242
|
+
def _daily_executions_chart(self):
|
|
243
|
+
today = date.today()
|
|
244
|
+
labels = []
|
|
245
|
+
success_data = []
|
|
246
|
+
failed_data = []
|
|
247
|
+
for i in range(29, -1, -1):
|
|
248
|
+
d = today - timedelta(days=i)
|
|
249
|
+
labels.append(d.strftime("%m-%d"))
|
|
250
|
+
success_data.append(
|
|
251
|
+
TaskExecution.objects.filter(
|
|
252
|
+
created_at__date=d, status="success"
|
|
253
|
+
).count()
|
|
254
|
+
)
|
|
255
|
+
failed_data.append(
|
|
256
|
+
TaskExecution.objects.filter(
|
|
257
|
+
created_at__date=d, status__in=["failed", "timeout"]
|
|
258
|
+
).count()
|
|
259
|
+
)
|
|
260
|
+
return {
|
|
261
|
+
"labels": labels,
|
|
262
|
+
"datasets": [
|
|
263
|
+
{
|
|
264
|
+
"label": str(_("Success")),
|
|
265
|
+
"data": success_data,
|
|
266
|
+
"backgroundColor": "#198754",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"label": str(_("Failed/Timeout")),
|
|
270
|
+
"data": failed_data,
|
|
271
|
+
"backgroundColor": "#dc3545",
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from django.utils import timezone
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
|
|
7
|
+
from django_simpletask5.models import TaskExecution
|
|
8
|
+
from django_simpletask5.executors.base import BaseExecutor
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ArchiveExecutor(BaseExecutor):
|
|
14
|
+
def execute(self, execution: TaskExecution) -> str | None:
|
|
15
|
+
from django_simpletask5.services.archive import archive_executions
|
|
16
|
+
|
|
17
|
+
yesterday = timezone.now().date() - timedelta(days=1)
|
|
18
|
+
count = archive_executions(yesterday)
|
|
19
|
+
|
|
20
|
+
return json.dumps({
|
|
21
|
+
'archived_count': count,
|
|
22
|
+
'archive_date': yesterday.isoformat(),
|
|
23
|
+
'success': True,
|
|
24
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django_simpletask5.models import TaskExecution
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BaseExecutor:
|
|
5
|
+
def execute(self, execution: TaskExecution) -> str | None:
|
|
6
|
+
raise NotImplementedError('Subclasses must implement execute()')
|
|
7
|
+
|
|
8
|
+
@classmethod
|
|
9
|
+
def get_parameter_schema(cls):
|
|
10
|
+
return []
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
|
|
7
|
+
from django_simpletask5.models import TaskExecution
|
|
8
|
+
from django_simpletask5.executors.base import BaseExecutor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BashScriptExecutor(BaseExecutor):
|
|
12
|
+
@classmethod
|
|
13
|
+
def get_parameter_schema(cls):
|
|
14
|
+
return [
|
|
15
|
+
{'name': 'script', 'type': 'text', 'required': False, 'default': '', 'description': 'Shell 脚本内容'},
|
|
16
|
+
{'name': 'script_path', 'type': 'string', 'required': False, 'default': '', 'description': 'Shell 脚本文件路径'},
|
|
17
|
+
{'name': 'args', 'type': 'array', 'required': False, 'default': [], 'description': '脚本参数列表'},
|
|
18
|
+
{'name': 'env', 'type': 'object', 'required': False, 'default': {}, 'description': '环境变量 JSON 对象'},
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def execute(self, execution: TaskExecution) -> str | None:
|
|
22
|
+
params = execution.get_context_dict()
|
|
23
|
+
script = params.get('script', '')
|
|
24
|
+
script_path = params.get('script_path', '')
|
|
25
|
+
args = params.get('args', [])
|
|
26
|
+
env = params.get('env', {})
|
|
27
|
+
|
|
28
|
+
if script_path:
|
|
29
|
+
script_whitelist = getattr(settings, 'DJANGO_SIMPLETASK_SCRIPT_WHITELIST', [])
|
|
30
|
+
if script_whitelist:
|
|
31
|
+
abs_path = os.path.abspath(script_path)
|
|
32
|
+
allowed = any(abs_path.startswith(os.path.abspath(p)) for p in script_whitelist)
|
|
33
|
+
if not allowed:
|
|
34
|
+
raise Exception('Script path not in whitelist')
|
|
35
|
+
with open(script_path, 'r') as f:
|
|
36
|
+
script = f.read()
|
|
37
|
+
|
|
38
|
+
if not script:
|
|
39
|
+
raise Exception('No script provided')
|
|
40
|
+
|
|
41
|
+
cmd = ['bash', '-c', script] + list(args)
|
|
42
|
+
merged_env = os.environ.copy()
|
|
43
|
+
merged_env.update(env)
|
|
44
|
+
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
cmd,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
timeout=execution.timeout_seconds or 300,
|
|
50
|
+
env=merged_env,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if result.returncode != 0:
|
|
54
|
+
raise Exception(result.stderr[:2000])
|
|
55
|
+
|
|
56
|
+
return json.dumps({
|
|
57
|
+
'stdout': result.stdout[:2000],
|
|
58
|
+
'stderr': result.stderr[:2000],
|
|
59
|
+
'return_code': result.returncode,
|
|
60
|
+
'success': True,
|
|
61
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_executor(executor_class_path):
|
|
8
|
+
if not executor_class_path or '.' not in executor_class_path:
|
|
9
|
+
raise ImportError(f'Cannot load executor {executor_class_path}: invalid path')
|
|
10
|
+
try:
|
|
11
|
+
module_path, class_name = executor_class_path.rsplit('.', 1)
|
|
12
|
+
module = importlib.import_module(module_path)
|
|
13
|
+
executor_cls = getattr(module, class_name)
|
|
14
|
+
return executor_cls()
|
|
15
|
+
except (ImportError, AttributeError) as e:
|
|
16
|
+
logger.exception('Failed to load executor %s', executor_class_path)
|
|
17
|
+
raise ImportError(f'Cannot load executor {executor_class_path}: {e}')
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django_simpletask5.models import TaskExecution
|
|
4
|
+
from django_simpletask5.executors.base import BaseExecutor
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PingPongExecutor(BaseExecutor):
|
|
10
|
+
def execute(self, execution: TaskExecution) -> str | None:
|
|
11
|
+
logger.debug('PingPongExecutor executing execution %s', execution.pk)
|
|
12
|
+
return 'pong'
|