Skip to content

Commit d7d2f78

Browse files
feat(admin): enhance celery queue status UI
- Split status display into separate Redis Broker and Celery Worker badges - Add loading spinners on page load and refresh for all status indicators - Add per-task queue breakdown table behind a 'View Details' button (O(N) scan with warning); shows task name, count, oldest/newest queue position, and expiry timestamps with human-readable time-left - Add per-row purge button to remove all queued tasks by task name - Add global queue purge and per-task purge API endpoints - Move Celery settings table into its own panel section
1 parent bdbbb73 commit d7d2f78

5 files changed

Lines changed: 300 additions & 13 deletions

File tree

dojo/api_v2/serializers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3104,12 +3104,24 @@ def validate(self, data):
31043104

31053105
class CeleryStatusSerializer(serializers.Serializer):
31063106
worker_status = serializers.BooleanField(read_only=True)
3107+
broker_status = serializers.BooleanField(read_only=True)
31073108
queue_length = serializers.IntegerField(allow_null=True, read_only=True)
31083109
task_time_limit = serializers.IntegerField(allow_null=True, read_only=True)
31093110
task_soft_time_limit = serializers.IntegerField(allow_null=True, read_only=True)
31103111
task_default_expires = serializers.IntegerField(allow_null=True, read_only=True)
31113112

31123113

3114+
class CeleryQueueTaskDetailSerializer(serializers.Serializer):
3115+
task_name = serializers.CharField(read_only=True)
3116+
count = serializers.IntegerField(read_only=True)
3117+
oldest_position = serializers.IntegerField(read_only=True)
3118+
newest_position = serializers.IntegerField(read_only=True)
3119+
oldest_eta = serializers.CharField(allow_null=True, read_only=True)
3120+
newest_eta = serializers.CharField(allow_null=True, read_only=True)
3121+
earliest_expires = serializers.CharField(allow_null=True, read_only=True)
3122+
latest_expires = serializers.CharField(allow_null=True, read_only=True)
3123+
3124+
31133125
class FindingNoteSerializer(serializers.Serializer):
31143126
note_id = serializers.IntegerField()
31153127

dojo/api_v2/views.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,12 +184,14 @@
184184
from dojo.utils import (
185185
async_delete,
186186
generate_file_response,
187+
get_celery_queue_details,
187188
get_celery_queue_length,
188189
get_celery_worker_status,
189190
get_setting,
190191
get_system_setting,
191192
process_tag_notifications,
192193
purge_celery_queue,
194+
purge_celery_queue_by_task_name,
193195
)
194196

195197
logger = logging.getLogger(__name__)
@@ -3278,9 +3280,11 @@ class CeleryStatusView(APIView):
32783280
queryset = System_Settings.objects.none()
32793281

32803282
def get(self, request):
3283+
queue_length = get_celery_queue_length()
32813284
data = {
32823285
"worker_status": get_celery_worker_status(),
3283-
"queue_length": get_celery_queue_length(),
3286+
"broker_status": queue_length is not None,
3287+
"queue_length": queue_length,
32843288
"task_time_limit": getattr(settings, "CELERY_TASK_TIME_LIMIT", None),
32853289
"task_soft_time_limit": getattr(settings, "CELERY_TASK_SOFT_TIME_LIMIT", None),
32863290
"task_default_expires": getattr(settings, "CELERY_TASK_DEFAULT_EXPIRES", None),
@@ -3307,6 +3311,45 @@ def post(self, request):
33073311
return Response({"purged": purged})
33083312

33093313

3314+
@extend_schema(
3315+
responses=serializers.CeleryQueueTaskDetailSerializer(many=True),
3316+
summary="Get per-task breakdown of the Celery queue",
3317+
description=(
3318+
"Scans every message in the queue (O(N)) and returns task name, count, and "
3319+
"oldest/newest queue positions. May be slow for large queues."
3320+
),
3321+
)
3322+
class CeleryQueueDetailsView(APIView):
3323+
permission_classes = (permissions.IsSuperUser, DjangoModelPermissions)
3324+
queryset = System_Settings.objects.none()
3325+
3326+
def get(self, request):
3327+
details = get_celery_queue_details()
3328+
if details is None:
3329+
return Response({"error": "Unable to read queue details."}, status=503)
3330+
return Response(serializers.CeleryQueueTaskDetailSerializer(details, many=True).data)
3331+
3332+
3333+
@extend_schema(
3334+
request={"application/json": {"type": "object", "properties": {"task_name": {"type": "string"}}, "required": ["task_name"]}},
3335+
responses={200: {"type": "object", "properties": {"purged": {"type": "integer"}}}},
3336+
summary="Purge all queued tasks with a given task name",
3337+
description="Removes all pending tasks matching the given task name from the default Celery queue.",
3338+
)
3339+
class CeleryQueueTaskPurgeView(APIView):
3340+
permission_classes = (permissions.IsSuperUser, DjangoModelPermissions)
3341+
queryset = System_Settings.objects.none()
3342+
3343+
def post(self, request):
3344+
task_name = request.data.get("task_name", "").strip()
3345+
if not task_name:
3346+
return Response({"error": "task_name is required."}, status=400)
3347+
purged = purge_celery_queue_by_task_name(task_name)
3348+
if purged is None:
3349+
return Response({"error": "Unable to purge tasks."}, status=503)
3350+
return Response({"purged": purged})
3351+
3352+
33103353
# Authorization: superuser
33113354
@extend_schema_view(**schema_with_prefetch())
33123355
class NotificationsViewSet(

dojo/templates/dojo/system_status.html

Lines changed: 171 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,43 @@ <h3>System Status</h3>
99
<div id="celery-status-panel" class="col-md-5">
1010
<div class="panel panel-default">
1111
<div class="panel-heading">
12-
<h4>Celery <span id="celery-status-badge" class="label label-default">Loading...</span></h4>
12+
<h4>Celery Status</h4>
1313
</div>
14-
<div class="panel-body text-left" id="celery-status-msg"></div>
1514
<div class="panel-body text-left">
16-
<span id="celery-queue-msg"></span>
15+
<table class="table table-condensed" style="margin-bottom: 0">
16+
<tbody>
17+
<tr>
18+
<td>Redis Broker</td>
19+
<td>
20+
<i id="broker-loading" class="fa-solid fa-spinner fa-spin text-muted"></i>
21+
<span id="broker-status-badge" class="label label-default" style="display:none"></span>
22+
</td>
23+
</tr>
24+
<tr>
25+
<td>Celery Worker</td>
26+
<td>
27+
<i id="worker-loading" class="fa-solid fa-spinner fa-spin text-muted"></i>
28+
<span id="worker-status-badge" class="label label-default" style="display:none"></span>
29+
</td>
30+
</tr>
31+
</tbody>
32+
</table>
33+
</div>
34+
<div class="panel-body text-left">
35+
<span id="celery-queue-msg"><i class="fa-solid fa-spinner fa-spin text-muted"></i></span>
1736
<div style="margin-top: 8px;">
1837
<button id="purge-queue-btn" class="btn btn-danger btn-xs" disabled title="Loading...">
1938
Purge queue
2039
</button>
2140
<button id="refresh-status-btn" class="btn btn-default btn-xs" style="margin-left: 6px;">
2241
<span class="glyphicon glyphicon-refresh"></span> Refresh
2342
</button>
43+
<button id="view-details-btn" class="btn btn-default btn-xs" disabled title="Loading..." style="margin-left: 6px;">
44+
View Details
45+
</button>
46+
<span class="text-muted" style="font-size: 0.85em; margin-left: 6px;">
47+
Inspects every message in the queue &mdash; may be slow for large queues.
48+
</span>
2449
</div>
2550
<p class="text-muted" style="margin-top: 8px; font-size: 0.9em">
2651
<strong>Note:</strong> Purging the queue removes pending tasks that have not yet been executed,
@@ -29,6 +54,32 @@ <h4>Celery <span id="celery-status-badge" class="label label-default">Loading...
2954
<code>python manage.py dedupe</code>
3055
</p>
3156
</div>
57+
<div class="panel-body text-left">
58+
<div id="queue-details-container" style="display: none;">
59+
<table class="table table-condensed table-bordered table-hover" style="margin-bottom: 0; font-size: 0.9em;">
60+
<thead>
61+
<tr>
62+
<th>#</th>
63+
<th>Task name</th>
64+
<th>Count</th>
65+
<th>Oldest (queue pos.)</th>
66+
<th>Newest (queue pos.)</th>
67+
<th>Expires</th>
68+
<th></th>
69+
</tr>
70+
</thead>
71+
<tbody id="queue-details-tbody"></tbody>
72+
</table>
73+
<p class="text-muted" style="font-size: 0.8em; margin-top: 4px;">
74+
Position 1 = oldest task in queue. ETA shown in parentheses when set.
75+
</p>
76+
</div>
77+
</div>
78+
</div>
79+
<div class="panel panel-default">
80+
<div class="panel-heading">
81+
<h4>Celery Settings</h4>
82+
</div>
3283
<div class="panel-body text-left">
3384
<table class="table table-condensed" style="margin-bottom: 0">
3485
<thead><tr><th>Setting</th><th>Value</th></tr></thead>
@@ -56,24 +107,32 @@ <h4>Celery <span id="celery-status-badge" class="label label-default">Loading...
56107
(function() {
57108
var purgeUrl = "{% url 'celery_queue_purge_api' %}";
58109
var statusUrl = "{% url 'celery_status_api' %}";
110+
var detailsUrl = "{% url 'celery_queue_details_api' %}";
111+
var taskPurgeUrl = "{% url 'celery_queue_task_purge_api' %}";
59112

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.');
113+
function setStatusBadge(loadingId, badgeId, isOk, okText, failText) {
114+
$('#' + loadingId).hide();
115+
var badge = $('#' + badgeId).show();
116+
if (isOk) {
117+
badge.text(okText).removeClass('label-default label-danger').addClass('label-success');
65118
} 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.');
119+
badge.text(failText).removeClass('label-default label-success').addClass('label-danger');
68120
}
121+
}
122+
123+
function renderCeleryStatus(data) {
124+
setStatusBadge('broker-loading', 'broker-status-badge', data.broker_status, 'Reachable', 'Unreachable');
125+
setStatusBadge('worker-loading', 'worker-status-badge', data.worker_status, 'Running', 'Not Running');
69126

70127
var qLen = data.queue_length;
71128
if (qLen === null) {
72129
$('#celery-queue-msg').text('It is not possible to identify the number of waiting tasks.');
73130
$('#purge-queue-btn').prop('disabled', true).attr('title', 'Broker unreachable');
131+
$('#view-details-btn').prop('disabled', true).attr('title', 'Broker unreachable');
74132
} else {
75133
$('#celery-queue-msg').text(qLen + ' task(s) waiting to be processed.');
76134
$('#purge-queue-btn').prop('disabled', false).removeAttr('title');
135+
$('#view-details-btn').prop('disabled', false).removeAttr('title');
77136
}
78137

79138
function humanDuration(seconds) {
@@ -101,13 +160,78 @@ <h4>Celery <span id="celery-status-badge" class="label label-default">Loading...
101160
});
102161
}
103162

163+
function resetToLoadingState() {
164+
['broker-loading', 'worker-loading'].forEach(function(id) { $('#' + id).show(); });
165+
['broker-status-badge', 'worker-status-badge'].forEach(function(id) { $('#' + id).hide(); });
166+
$('#celery-queue-msg').html('<i class="fa-solid fa-spinner fa-spin text-muted"></i>');
167+
$('#purge-queue-btn').prop('disabled', true).attr('title', 'Loading...');
168+
$('#view-details-btn').prop('disabled', true).attr('title', 'Loading...');
169+
}
170+
104171
function loadCeleryStatus() {
172+
resetToLoadingState();
105173
$.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.');
174+
['broker-loading', 'worker-loading'].forEach(function(id) { $('#' + id).hide(); });
175+
setStatusBadge('broker-loading', 'broker-status-badge', false, '', 'Error');
176+
setStatusBadge('worker-loading', 'worker-status-badge', false, '', 'Error');
177+
$('#celery-queue-msg').text('Failed to load Celery status.');
108178
});
109179
}
110180

181+
var MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
182+
function formatExpiry(isoStr) {
183+
if (!isoStr) return '—';
184+
var d = new Date(isoStr);
185+
var hh = ('0' + d.getHours()).slice(-2);
186+
var mm = ('0' + d.getMinutes()).slice(-2);
187+
var ss = ('0' + d.getSeconds()).slice(-2);
188+
return d.getDate() + ' ' + MONTHS[d.getMonth()] + ' ' + hh + ':' + mm + ':' + ss;
189+
}
190+
191+
function timeLeft(isoStr) {
192+
if (!isoStr) return '';
193+
var ms = new Date(isoStr) - Date.now();
194+
if (ms <= 0) return ' <span class="label label-danger">expired</span>';
195+
var s = Math.floor(ms / 1000);
196+
var parts = [];
197+
var d = Math.floor(s / 86400); if (d) { parts.push(d + 'd'); s -= d * 86400; }
198+
var h = Math.floor(s / 3600); if (h) { parts.push(h + 'h'); s -= h * 3600; }
199+
var m = Math.floor(s / 60); if (m) { parts.push(('0' + m).slice(-2) + 'm'); s -= m * 60; }
200+
if (!parts.length) parts.push(s + 's');
201+
return ' <span class="text-muted">(' + parts.join(' ') + ' left)</span>';
202+
}
203+
204+
function renderQueueDetails(tasks) {
205+
var tbody = $('#queue-details-tbody').empty();
206+
if (tasks.length === 0) {
207+
tbody.append('<tr><td colspan="7" class="text-muted text-center">Queue is empty.</td></tr>');
208+
} else {
209+
tasks.forEach(function(t, idx) {
210+
var oldest = '#' + t.oldest_position + (t.oldest_eta ? ' <span class="text-muted">(' + t.oldest_eta + ')</span>' : '');
211+
var newest = '#' + t.newest_position + (t.newest_eta ? ' <span class="text-muted">(' + t.newest_eta + ')</span>' : '');
212+
var expires = '—';
213+
if (t.earliest_expires || t.latest_expires) {
214+
expires = '<span class="text-muted">Oldest:</span> ' + formatExpiry(t.earliest_expires) + timeLeft(t.earliest_expires) +
215+
'<br><span class="text-muted">Newest:</span> ' + formatExpiry(t.latest_expires) + timeLeft(t.latest_expires);
216+
}
217+
var safeTaskName = $('<span>').text(t.task_name).html();
218+
var purgeBtn = '<button class="btn btn-danger btn-xs task-purge-btn" data-task-name="' + safeTaskName + '">Purge</button>';
219+
tbody.append(
220+
'<tr>' +
221+
'<td>' + (idx + 1) + '</td>' +
222+
'<td><code>' + safeTaskName + '</code></td>' +
223+
'<td>' + t.count + '</td>' +
224+
'<td>' + oldest + '</td>' +
225+
'<td>' + newest + '</td>' +
226+
'<td>' + expires + '</td>' +
227+
'<td>' + purgeBtn + '</td>' +
228+
'</tr>'
229+
);
230+
});
231+
}
232+
$('#queue-details-container').show();
233+
}
234+
111235
$(function() {
112236
loadCeleryStatus();
113237

@@ -126,6 +250,41 @@ <h4>Celery <span id="celery-status-badge" class="label label-default">Loading...
126250
loadCeleryStatus();
127251
});
128252
});
253+
254+
$('#queue-details-tbody').on('click', '.task-purge-btn', function() {
255+
var btn = $(this);
256+
var taskName = btn.data('task-name');
257+
if (!confirm('Purge all pending "' + taskName + '" tasks from the queue? This cannot be undone.')) return;
258+
btn.prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin"></i>');
259+
$.ajax({
260+
url: taskPurgeUrl,
261+
method: 'POST',
262+
contentType: 'application/json',
263+
data: JSON.stringify({task_name: taskName}),
264+
headers: {'X-CSRFToken': '{{ csrf_token }}'},
265+
}).done(function(data) {
266+
btn.closest('tr').fadeOut(300, function() { $(this).remove(); });
267+
var remaining = parseInt($('#celery-queue-msg').text()) - data.purged;
268+
if (!isNaN(remaining)) {
269+
$('#celery-queue-msg').text(remaining + ' task(s) waiting to be processed.');
270+
}
271+
}).fail(function() {
272+
alert('Purge failed. Check server logs.');
273+
btn.prop('disabled', false).text('Purge');
274+
});
275+
});
276+
277+
$('#view-details-btn').on('click', function() {
278+
var btn = $(this);
279+
btn.prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin"></i> Loading...');
280+
$.get(detailsUrl).done(function(data) {
281+
renderQueueDetails(data);
282+
btn.prop('disabled', false).text('View Details');
283+
}).fail(function() {
284+
alert('Failed to load queue details. Check server logs.');
285+
btn.prop('disabled', false).text('View Details');
286+
});
287+
});
129288
});
130289
})();
131290
</script>

dojo/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
AnnouncementViewSet,
1616
AppAnalysisViewSet,
1717
BurpRawRequestResponseViewSet,
18+
CeleryQueueDetailsView,
1819
CeleryQueuePurgeView,
20+
CeleryQueueTaskPurgeView,
1921
CeleryStatusView,
2022
ConfigurationPermissionViewSet,
2123
CredentialsMappingViewSet,
@@ -246,6 +248,8 @@
246248
re_path(r"^{}api/v2/user_profile/".format(get_system_setting("url_prefix")), UserProfileView.as_view(), name="user_profile"),
247249
re_path(r"^{}api/v2/celery/status/$".format(get_system_setting("url_prefix")), CeleryStatusView.as_view(), name="celery_status_api"),
248250
re_path(r"^{}api/v2/celery/queue/purge/$".format(get_system_setting("url_prefix")), CeleryQueuePurgeView.as_view(), name="celery_queue_purge_api"),
251+
re_path(r"^{}api/v2/celery/queue/details/$".format(get_system_setting("url_prefix")), CeleryQueueDetailsView.as_view(), name="celery_queue_details_api"),
252+
re_path(r"^{}api/v2/celery/queue/task/purge/$".format(get_system_setting("url_prefix")), CeleryQueueTaskPurgeView.as_view(), name="celery_queue_task_purge_api"),
249253
]
250254

251255
if hasattr(settings, "API_TOKENS_ENABLED") and hasattr(settings, "API_TOKEN_AUTH_ENDPOINT_ENABLED"):

0 commit comments

Comments
 (0)