Skip to content

Commit 73f690b

Browse files
feat: add System Status page with Celery queue management
- Add dedicated System Status page (/system_status) with superuser-only access, accessible from the navigation menu alongside System Settings - Display Celery worker liveness, pending queue length with human-readable duration formatting, and active task timeout/expiry configuration - Add Purge queue button that POSTs to the new API endpoint and reloads the page on success - Fix get_celery_worker_status() to use app.control.ping() via the pidbox control channel, which works correctly even when the task queue is clogged (previously dispatched a task that would never be picked up) - Add purge_celery_queue() utility using a direct broker connection - Add two new superuser-only REST API endpoints: GET /api/v2/celery/status/ - worker status, queue length, config POST /api/v2/celery/queue/purge/ - purge all pending tasks Both use the same permission guards as SystemSettingsViewSet (IsSuperUser + DjangoModelPermissions against System_Settings) - Add DD_CELERY_TASK_TIME_LIMIT (default 12h), DD_CELERY_TASK_SOFT_TIME_LIMIT (default disabled), and DD_CELERY_TASK_DEFAULT_EXPIRES (default 12h) environment variables to settings.dist.py with explanatory comments - Move celery status rendering from server-side Django view to client-side AJAX so dojo-pro can consume the same API endpoints feat: add Refresh button next to Purge button on System Status page remove plan
1 parent dc100ae commit 73f690b

10 files changed

Lines changed: 246 additions & 68 deletions

File tree

dojo/api_v2/serializers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3051,6 +3051,14 @@ def validate(self, data):
30513051
return data
30523052

30533053

3054+
class CeleryStatusSerializer(serializers.Serializer):
3055+
worker_status = serializers.BooleanField(read_only=True)
3056+
queue_length = serializers.IntegerField(allow_null=True, read_only=True)
3057+
task_time_limit = serializers.IntegerField(allow_null=True, read_only=True)
3058+
task_soft_time_limit = serializers.IntegerField(allow_null=True, read_only=True)
3059+
task_default_expires = serializers.IntegerField(allow_null=True, read_only=True)
3060+
3061+
30543062
class FindingNoteSerializer(serializers.Serializer):
30553063
note_id = serializers.IntegerField()
30563064

dojo/api_v2/views.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from rest_framework.parsers import MultiPartParser
3434
from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated
3535
from rest_framework.response import Response
36+
from rest_framework.views import APIView
3637

3738
import dojo.finding.helper as finding_helper
3839
import dojo.jira_link.helper as jira_helper
@@ -179,9 +180,12 @@
179180
from dojo.utils import (
180181
async_delete,
181182
generate_file_response,
183+
get_celery_queue_length,
184+
get_celery_worker_status,
182185
get_setting,
183186
get_system_setting,
184187
process_tag_notifications,
188+
purge_celery_queue,
185189
)
186190

187191
logger = logging.getLogger(__name__)
@@ -3123,6 +3127,49 @@ def get_queryset(self):
31233127
return System_Settings.objects.all().order_by("id")
31243128

31253129

3130+
@extend_schema(
3131+
responses=serializers.CeleryStatusSerializer,
3132+
summary="Get Celery worker and queue status",
3133+
description=(
3134+
"Returns Celery worker liveness, pending queue length, and the active task "
3135+
"timeout/expiry configuration. Uses the Celery control channel (pidbox) for "
3136+
"worker status so it works correctly even when the task queue is clogged."
3137+
),
3138+
)
3139+
class CeleryStatusView(APIView):
3140+
permission_classes = (permissions.IsSuperUser, DjangoModelPermissions)
3141+
queryset = System_Settings.objects.none()
3142+
3143+
def get(self, request):
3144+
data = {
3145+
"worker_status": get_celery_worker_status(),
3146+
"queue_length": get_celery_queue_length(),
3147+
"task_time_limit": getattr(settings, "CELERY_TASK_TIME_LIMIT", None),
3148+
"task_soft_time_limit": getattr(settings, "CELERY_TASK_SOFT_TIME_LIMIT", None),
3149+
"task_default_expires": getattr(settings, "CELERY_TASK_DEFAULT_EXPIRES", None),
3150+
}
3151+
return Response(serializers.CeleryStatusSerializer(data).data)
3152+
3153+
3154+
@extend_schema(
3155+
request=None,
3156+
responses={200: {"type": "object", "properties": {"purged": {"type": "integer"}}}},
3157+
summary="Purge all pending Celery tasks from the queue",
3158+
description=(
3159+
"Removes all pending tasks from the default Celery queue. Tasks already being "
3160+
"executed by workers are not affected. Note: if deduplication tasks were queued, "
3161+
"you may need to re-run deduplication manually via `python manage.py dedupe`."
3162+
),
3163+
)
3164+
class CeleryQueuePurgeView(APIView):
3165+
permission_classes = (permissions.IsSuperUser, DjangoModelPermissions)
3166+
queryset = System_Settings.objects.none()
3167+
3168+
def post(self, request):
3169+
purged = purge_celery_queue()
3170+
return Response({"purged": purged})
3171+
3172+
31263173
# Authorization: superuser
31273174
@extend_schema_view(**schema_with_prefetch())
31283175
class NotificationsViewSet(

dojo/settings/settings.dist.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@
9090
DD_CELERY_BEAT_SCHEDULE_FILENAME=(str, root("dojo.celery.beat.db")),
9191
DD_CELERY_TASK_SERIALIZER=(str, "pickle"),
9292
DD_CELERY_LOG_LEVEL=(str, "INFO"),
93+
# Hard ceiling on task runtime. When reached, the worker process is sent SIGKILL — no cleanup
94+
# code runs. Always set higher than DD_CELERY_TASK_SOFT_TIME_LIMIT. (0 = disabled, no limit)
95+
DD_CELERY_TASK_TIME_LIMIT=(int, 43200), # default: 12 hours
96+
# Raises SoftTimeLimitExceeded inside the task, giving it a chance to clean up before the hard
97+
# kill. Set a few seconds below DD_CELERY_TASK_TIME_LIMIT so cleanup has time to finish.
98+
# (0 = disabled, no limit)
99+
DD_CELERY_TASK_SOFT_TIME_LIMIT=(int, 0),
100+
# If a task sits in the broker queue for longer than this without being picked up by a worker,
101+
# Celery silently discards it — it is never executed and no exception is raised. Does not
102+
# affect tasks that are already running. (0 = disabled, no limit)
103+
DD_CELERY_TASK_DEFAULT_EXPIRES=(int, 43200), # default: 12 hours
93104
DD_TAG_BULK_ADD_BATCH_SIZE=(int, 1000),
94105
# Tagulous slug truncate unique setting. Set to -1 to use tagulous internal default (5)
95106
DD_TAGULOUS_SLUG_TRUNCATE_UNIQUE=(int, -1),
@@ -1249,6 +1260,13 @@ def saml2_attrib_map_format(din):
12491260
CELERY_TASK_SERIALIZER = env("DD_CELERY_TASK_SERIALIZER")
12501261
CELERY_LOG_LEVEL = env("DD_CELERY_LOG_LEVEL")
12511262

1263+
if env("DD_CELERY_TASK_TIME_LIMIT") > 0:
1264+
CELERY_TASK_TIME_LIMIT = env("DD_CELERY_TASK_TIME_LIMIT")
1265+
if env("DD_CELERY_TASK_SOFT_TIME_LIMIT") > 0:
1266+
CELERY_TASK_SOFT_TIME_LIMIT = env("DD_CELERY_TASK_SOFT_TIME_LIMIT")
1267+
if env("DD_CELERY_TASK_DEFAULT_EXPIRES") > 0:
1268+
CELERY_TASK_DEFAULT_EXPIRES = env("DD_CELERY_TASK_DEFAULT_EXPIRES")
1269+
12521270
if len(env("DD_CELERY_BROKER_TRANSPORT_OPTIONS")) > 0:
12531271
CELERY_BROKER_TRANSPORT_OPTIONS = json.loads(env("DD_CELERY_BROKER_TRANSPORT_OPTIONS"))
12541272

dojo/system_settings/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,9 @@
88
views.SystemSettingsView.as_view(),
99
name="system_settings",
1010
),
11+
re_path(
12+
r"^system_status$",
13+
views.SystemStatusView.as_view(),
14+
name="system_status",
15+
),
1116
]

dojo/system_settings/views.py

Lines changed: 13 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import logging
22

3-
from django.conf import settings
43
from django.contrib import messages
54
from django.core.exceptions import PermissionDenied
65
from django.http import HttpRequest, HttpResponse
@@ -9,7 +8,7 @@
98

109
from dojo.forms import SystemSettingsForm
1110
from dojo.models import System_Settings
12-
from dojo.utils import add_breadcrumb, get_celery_queue_length, get_celery_worker_status
11+
from dojo.utils import add_breadcrumb
1312

1413
logger = logging.getLogger(__name__)
1514

@@ -30,15 +29,10 @@ def get_context(
3029
request: HttpRequest,
3130
) -> dict:
3231
system_settings_obj = self.get_settings_object()
33-
# Set the initial context
34-
context = {
32+
return {
3533
"system_settings_obj": system_settings_obj,
3634
"form": self.get_form(request, system_settings_obj),
3735
}
38-
# Check the status of celery
39-
self.get_celery_status(context)
40-
41-
return context
4236

4337
def get_form(
4438
self,
@@ -95,35 +89,6 @@ def validate_form(
9589
return request, True
9690
return request, False
9791

98-
def get_celery_status(
99-
self,
100-
context: dict,
101-
) -> None:
102-
# Celery needs to be set with the setting: CELERY_RESULT_BACKEND = 'db+sqlite:///dojo.celeryresults.sqlite'
103-
if hasattr(settings, "CELERY_RESULT_BACKEND"):
104-
# Check the status of Celery by sending calling a celery task
105-
context["celery_bool"] = get_celery_worker_status()
106-
107-
if context["celery_bool"]:
108-
context["celery_msg"] = "Celery is processing tasks."
109-
context["celery_status"] = "Running"
110-
else:
111-
context["celery_msg"] = "Celery does not appear to be up and running. Please ensure celery is running."
112-
context["celery_status"] = "Not Running"
113-
114-
q_len = get_celery_queue_length()
115-
if q_len is None:
116-
context["celery_q_len"] = " It is not possible to identify number of waiting tasks."
117-
elif q_len:
118-
context["celery_q_len"] = f"{q_len} tasks are waiting to be proccessed."
119-
else:
120-
context["celery_q_len"] = "No task is waiting to be proccessed."
121-
122-
else:
123-
context["celery_bool"] = False
124-
context["celery_msg"] = "Celery needs to have the setting CELERY_RESULT_BACKEND = 'db+sqlite:///dojo.celeryresults.sqlite' set in settings.py."
125-
context["celery_status"] = "Unknown"
126-
12792
def get_template(self) -> str:
12893
return "dojo/system_settings.html"
12994

@@ -148,9 +113,19 @@ def post(
148113
self.permission_check(request)
149114
# Set up the initial context
150115
context = self.get_context(request)
151-
# Check the status of celery
152116
request, _ = self.validate_form(request, context)
153117
# Add some breadcrumbs
154118
add_breadcrumb(title="System settings", top_level=False, request=request)
155119
# Render the page
156120
return render(request, self.get_template(), context)
121+
122+
123+
class SystemStatusView(View):
124+
def get(
125+
self,
126+
request: HttpRequest,
127+
) -> HttpResponse:
128+
if not request.user.is_superuser:
129+
raise PermissionDenied
130+
add_breadcrumb(title="System status", top_level=False, request=request)
131+
return render(request, "dojo/system_status.html")

dojo/templates/base.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,11 @@
613613
{% trans "System Settings" %}
614614
</a>
615615
</li>
616+
<li>
617+
<a href="{% url 'system_status' %}">
618+
{% trans "System Status" %}
619+
</a>
620+
</li>
616621
{% endif %}
617622
{% if "dojo.view_tool_configuration"|has_configuration_permission:request %}
618623
<li>

dojo/templates/dojo/system_settings.html

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,6 @@
1010

1111
{% block content %}
1212
{{ block.super }}
13-
{% block status %}
14-
<div class="row">
15-
<h3> System Status </h3>
16-
<br>
17-
<div id="test-strategy" class="col-md-4">
18-
<div class="panel panel-default">
19-
<div class="panel-heading">
20-
{% if celery_bool %}
21-
<h4> Celery <span class="label label-success">{{celery_status}}</span> </h4>
22-
{% else %}
23-
<h4> Celery <span class="label label-danger">{{celery_status}}</span> </h4>
24-
{% endif %}
25-
</div>
26-
<div class="panel-body text-left">
27-
{{celery_msg}}
28-
</div>
29-
<div class="panel-body text-left">
30-
{{celery_q_len}}
31-
</div>
32-
</div>
33-
</div>
34-
</div>
35-
{% endblock status %}
3613
<hr>
3714
{% block settings %}
3815
<div class="row">
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
{% extends "base.html" %}
2+
{% load static %}
3+
4+
{% block content %}
5+
{{ block.super }}
6+
<div class="row">
7+
<h3>System Status</h3>
8+
<br>
9+
<div id="celery-status-panel" class="col-md-5">
10+
<div class="panel panel-default">
11+
<div class="panel-heading">
12+
<h4>Celery <span id="celery-status-badge" class="label label-default">Loading...</span></h4>
13+
</div>
14+
<div class="panel-body text-left" id="celery-status-msg"></div>
15+
<div class="panel-body text-left">
16+
<span id="celery-queue-msg"></span>
17+
<div style="margin-top: 8px;">
18+
<button id="purge-queue-btn" class="btn btn-danger btn-xs" disabled title="Loading...">
19+
Purge queue
20+
</button>
21+
<button id="refresh-status-btn" class="btn btn-default btn-xs" style="margin-left: 6px;">
22+
<span class="glyphicon glyphicon-refresh"></span> Refresh
23+
</button>
24+
</div>
25+
<p class="text-muted" style="margin-top: 8px; font-size: 0.9em">
26+
<strong>Note:</strong> Purging the queue removes pending tasks that have not yet been executed,
27+
including deduplication tasks. If deduplication tasks were in the queue, you may need to
28+
re-run deduplication manually using the <code>dedupe</code> management command:
29+
<code>python manage.py dedupe</code>
30+
</p>
31+
</div>
32+
<div class="panel-body text-left">
33+
<table class="table table-condensed" style="margin-bottom: 0">
34+
<thead><tr><th>Setting</th><th>Value</th></tr></thead>
35+
<tbody id="celery-config-tbody"></tbody>
36+
<tfoot>
37+
<tr>
38+
<td colspan="2" class="text-muted" style="font-size: 0.9em; border-top: 1px solid #ddd; padding-top: 6px">
39+
Read-only. Configured via environment variables:
40+
<code>DD_CELERY_TASK_TIME_LIMIT</code>,
41+
<code>DD_CELERY_TASK_SOFT_TIME_LIMIT</code>,
42+
<code>DD_CELERY_TASK_DEFAULT_EXPIRES</code>
43+
</td>
44+
</tr>
45+
</tfoot>
46+
</table>
47+
</div>
48+
</div>
49+
</div>
50+
</div>
51+
{% endblock content %}
52+
53+
{% block postscript %}
54+
{{ block.super }}
55+
<script>
56+
(function() {
57+
var purgeUrl = "{% url 'celery_queue_purge_api' %}";
58+
var statusUrl = "{% url 'celery_status_api' %}";
59+
60+
function renderCeleryStatus(data) {
61+
var badge = $('#celery-status-badge');
62+
if (data.worker_status) {
63+
badge.text('Running').removeClass('label-default label-danger').addClass('label-success');
64+
$('#celery-status-msg').text('Celery is processing tasks.');
65+
} else {
66+
badge.text('Not Running').removeClass('label-default label-success').addClass('label-danger');
67+
$('#celery-status-msg').text('Celery does not appear to be running.');
68+
}
69+
70+
var qLen = data.queue_length;
71+
if (qLen === null) {
72+
$('#celery-queue-msg').text('It is not possible to identify the number of waiting tasks.');
73+
$('#purge-queue-btn').prop('disabled', true).attr('title', 'Broker unreachable');
74+
} else {
75+
$('#celery-queue-msg').text(qLen + ' task(s) waiting to be processed.');
76+
$('#purge-queue-btn').prop('disabled', false).removeAttr('title');
77+
}
78+
79+
function humanDuration(seconds) {
80+
if (seconds === null) return '<em>Not set</em>';
81+
var d = Math.floor(seconds / 86400);
82+
var h = Math.floor((seconds % 86400) / 3600);
83+
var m = Math.floor((seconds % 3600) / 60);
84+
var s = seconds % 60;
85+
var parts = [];
86+
if (d) parts.push(d + 'd');
87+
if (h) parts.push(h + 'h');
88+
if (m) parts.push(m + 'm');
89+
if (s || parts.length === 0) parts.push(s + 's');
90+
return parts.join(' ') + ' <span class="text-muted">(' + seconds + 's)</span>';
91+
}
92+
93+
var cfgRows = [
94+
['Task time limit (hard kill)', data.task_time_limit],
95+
['Task soft time limit', data.task_soft_time_limit],
96+
['Default task expiry (queue)', data.task_default_expires],
97+
];
98+
var tbody = $('#celery-config-tbody').empty();
99+
cfgRows.forEach(function(row) {
100+
tbody.append('<tr><td>' + row[0] + '</td><td>' + humanDuration(row[1]) + '</td></tr>');
101+
});
102+
}
103+
104+
function loadCeleryStatus() {
105+
$.get(statusUrl).done(renderCeleryStatus).fail(function() {
106+
$('#celery-status-badge').text('Error').removeClass('label-default label-success').addClass('label-danger');
107+
$('#celery-status-msg').text('Failed to load Celery status.');
108+
});
109+
}
110+
111+
$(function() {
112+
loadCeleryStatus();
113+
114+
$('#refresh-status-btn').on('click', loadCeleryStatus);
115+
116+
$('#purge-queue-btn').on('click', function() {
117+
if (!confirm('Purge all pending tasks from the queue? This cannot be undone.')) return;
118+
$.ajax({
119+
url: purgeUrl,
120+
method: 'POST',
121+
headers: {'X-CSRFToken': '{{ csrf_token }}'},
122+
}).done(function(data) {
123+
location.reload();
124+
}).fail(function() {
125+
alert('Purge failed. Check server logs.');
126+
loadCeleryStatus();
127+
});
128+
});
129+
});
130+
})();
131+
</script>
132+
{% endblock %}

0 commit comments

Comments
 (0)