From bff5ee605a2b3df8f488123867b1779e9efdec62 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 15:43:00 -0400 Subject: [PATCH] Ensure correct ordering by elapsed time --- netbox/core/api/views.py | 7 +++++++ netbox/core/models/jobs.py | 20 ++++++++++++-------- netbox/core/tables/jobs.py | 15 ++++++++------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py index 7d5e21f20..70a1a6258 100644 --- a/netbox/core/api/views.py +++ b/netbox/core/api/views.py @@ -1,6 +1,7 @@ from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ +from django_filters.rest_framework import DjangoFilterBackend from django_rq.queues import get_redis_connection from django_rq.settings import get_queues_list from django_rq.utils import get_statistics @@ -19,6 +20,7 @@ from core.jobs import SyncDataSourceJob from core.models import * from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired +from netbox.api.filter_backends import OrderingFilter from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import LimitOffsetListPagination from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet @@ -71,6 +73,11 @@ class JobViewSet(NetBoxReadOnlyModelViewSet): queryset = Job.objects.all() serializer_class = serializers.JobSerializer filterset_class = filtersets.JobFilterSet + filter_backends = (DjangoFilterBackend, OrderingFilter) + # Order by elapsed time for jobs which are still running, matching the jobs table in the UI + ordering_expressions = { + 'execution_time': Job.elapsed_time_expression(), + } class ObjectChangeViewSet(NetBoxReadOnlyModelViewSet): diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 3d7dd08b7..c45b38770 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -11,6 +11,8 @@ from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import MinValueValidator from django.db import models, transaction +from django.db.models import ExpressionWrapper, F +from django.db.models.functions import Coalesce, Now from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ @@ -194,14 +196,16 @@ class Job(models.Model): return timezone.now() - self.started return None - @property - def duration(self): - if self.execution_time is None: - return None - - minutes, seconds = divmod(self.execution_time.total_seconds(), 60) - - return f"{int(minutes)} minutes, {seconds:.2f} seconds" + @staticmethod + def elapsed_time_expression(): + """ + A queryset expression mirroring the `elapsed_time` property, for use in ordering and + filtering. Resolves to null for jobs which have not yet started. + """ + return Coalesce( + 'execution_time', + ExpressionWrapper(Now() - F('started'), output_field=models.DurationField()), + ) def delete(self, *args, **kwargs): # Use the stored queue name, or fall back to get_queue_for_model for legacy jobs diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index bf1504739..afb63e764 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -1,5 +1,4 @@ import django_tables2 as tables -from django.db.models import F from django.utils.html import conditional_escape, format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -79,7 +78,7 @@ class JobTable(NetBoxTable): return self.default value = humanize_duration(duration) - if record.execution_time is None: + if not record.completed: # The job is still running, so distinguish its (provisional) elapsed time from a final one return format_html( '{}', _('Still running'), value @@ -91,13 +90,15 @@ class JobTable(NetBoxTable): # Export the raw number of seconds rather than the humanized rendering if (duration := record.elapsed_time) is None: return None - return max(duration.total_seconds(), 0) + return round(max(duration.total_seconds(), 0), 3) def order_execution_time(self, queryset, is_descending): - # Jobs with no recorded execution time are sorted last irrespective of the sort direction - field = F('execution_time') - ordering = field.desc(nulls_last=True) if is_descending else field.asc(nulls_last=True) - return queryset.order_by(ordering), True + # Order by the value the column actually displays, so that a long-running job is not sorted + # as though it had no execution time. Jobs which never started sort last in either + # direction, and pk breaks ties to keep pagination stable. + elapsed_time = Job.elapsed_time_expression() + ordering = elapsed_time.desc(nulls_last=True) if is_descending else elapsed_time.asc(nulls_last=True) + return queryset.order_by(ordering, 'pk'), True class JobLogEntryTable(BaseTable):