From 19bdc9c7f7689832d311e1071c3b2db1715bcdff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 14:48:18 -0400 Subject: [PATCH 01/22] Rearrange filter form field groups --- netbox/core/forms/filtersets.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 56a0861ea..998564498 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -75,12 +75,14 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): model = Job fieldsets = ( FieldSet('q', 'filter_id'), - FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')), + FieldSet('object_type_id', 'status', 'queue_name', 'user', name=_('Attributes')), FieldSet( 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', - 'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation') + 'started__after', name=_('Scheduling') + ), + FieldSet( + 'completed__before', 'completed__after', 'execution_time__gte', 'execution_time__lte', name=_('Execution'), ), - FieldSet('execution_time__gte', 'execution_time__lte', name=_('Execution')), ) object_type_id = ContentTypeChoiceField( label=_('Object Type'), From 09cd3f2dfdce22ae486543e4052dd2e2df5b0a36 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 14:49:50 -0400 Subject: [PATCH 02/22] Populate execution_time for existing jobs --- .../migrations/0025_add_job_execution_time.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/netbox/core/migrations/0025_add_job_execution_time.py b/netbox/core/migrations/0025_add_job_execution_time.py index f8c58d9ed..baf4be931 100644 --- a/netbox/core/migrations/0025_add_job_execution_time.py +++ b/netbox/core/migrations/0025_add_job_execution_time.py @@ -1,4 +1,15 @@ from django.db import migrations, models +from django.db.models import DurationField, ExpressionWrapper, F + + +def populate_execution_time(apps, schema_editor): + """ + Populate execution_time for existing jobs which have both a start and completion time recorded. + """ + Job = apps.get_model("core", "Job") + Job.objects.filter(started__isnull=False, completed__isnull=False).update( + execution_time=ExpressionWrapper(F("completed") - F("started"), output_field=DurationField()) + ) class Migration(migrations.Migration): @@ -13,4 +24,8 @@ class Migration(migrations.Migration): name="execution_time", field=models.DurationField(blank=True, editable=False, null=True), ), + migrations.RunPython( + code=populate_execution_time, + reverse_code=migrations.RunPython.noop, + ), ] From 011eb6da14bd49caeb9ac96bc7bf50ea26df79c1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 14:55:18 -0400 Subject: [PATCH 03/22] Improve table rendering --- netbox/core/models/jobs.py | 22 ++++--- netbox/core/tables/jobs.py | 37 +++++++++-- netbox/core/tests/test_models.py | 51 +++++++++++++++ netbox/core/tests/test_tables.py | 108 ++++++++++++++++++++++++++++++- netbox/netbox/tables/columns.py | 16 ++--- 5 files changed, 210 insertions(+), 24 deletions(-) diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 36e412c54..3d7dd08b7 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -182,18 +182,24 @@ class Job(models.Model): _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) ) + @property + def elapsed_time(self): + """ + The job's recorded execution time, or the time elapsed so far if it is still running. + Returns None for a job which has not yet started. + """ + if self.execution_time is not None: + return self.execution_time + if self.started and not self.completed: + return timezone.now() - self.started + return None + @property def duration(self): - if not self.completed: + if self.execution_time is None: return None - start_time = self.started or self.created - - if not start_time: - return None - - duration = self.completed - start_time - minutes, seconds = divmod(duration.total_seconds(), 60) + minutes, seconds = divmod(self.execution_time.total_seconds(), 60) return f"{int(minutes)} minutes, {seconds:.2f} seconds" diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index caf4903e0..bf1504739 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -1,5 +1,6 @@ import django_tables2 as tables -from django.utils.html import conditional_escape +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 _ @@ -45,8 +46,10 @@ class JobTable(NetBoxTable): completed = columns.DateTimeColumn( verbose_name=_('Completed'), ) - execution_time = tables.Column( + execution_time = columns.DurationColumn( verbose_name=_('Execution Time'), + # Render running jobs (which have no recorded execution time yet) rather than the placeholder + empty_values=(), ) queue_name = tables.Column( verbose_name=_('Queue'), @@ -62,17 +65,39 @@ class JobTable(NetBoxTable): model = Job fields = ( 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started', - 'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id', 'execution_time' + 'completed', 'execution_time', 'user', 'queue_name', 'log_entries', 'error', 'job_id', ) default_columns = ( - 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', + 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'execution_time', 'user', ) def render_log_entries(self, value): return len(value) - def render_execution_time(self, value): - return humanize_duration(value) + def render_execution_time(self, record): + if (duration := record.elapsed_time) is None: + return self.default + + value = humanize_duration(duration) + if record.execution_time is None: + # The job is still running, so distinguish its (provisional) elapsed time from a final one + return format_html( + '{}', _('Still running'), value + ) + + return value + + def value_execution_time(self, record): + # 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) + + 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 class JobLogEntryTable(BaseTable): diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index d4465c436..e19fbdb3f 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -381,3 +381,54 @@ class JobTestCase(TestCase): job.terminate(status=JobStatusChoices.STATUS_COMPLETED) self.assertIsNone(job.execution_time) + + @patch('core.models.jobs.job_end') + def test_elapsed_time_returns_execution_time_once_completed(self, mock_job_end): + """ + For a completed job, elapsed_time should return the recorded execution_time. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + job.started = timezone.now() - timedelta(seconds=90) + job.save() + + job.terminate(status=JobStatusChoices.STATUS_COMPLETED) + + self.assertEqual(job.elapsed_time, job.execution_time) + + def test_elapsed_time_of_running_job(self): + """ + A running job has no execution_time yet, so elapsed_time should report the time elapsed + since it started. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + job.started = timezone.now() - timedelta(seconds=90) + job.save() + + self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING) + self.assertIsNone(job.execution_time) + self.assertGreaterEqual(job.elapsed_time, timedelta(seconds=90)) + self.assertLess(job.elapsed_time, timedelta(seconds=120)) + + def test_elapsed_time_none_when_never_started(self): + """ + A job which has not started has no elapsed time to report. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + + self.assertIsNone(job.started) + self.assertIsNone(job.elapsed_time) + + @patch('core.models.jobs.job_end') + def test_duration_derives_from_execution_time(self, mock_job_end): + """ + The duration property should be rendered from the recorded execution_time, and should be + null for a job which never started. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + job.execution_time = timedelta(seconds=90) + self.assertEqual(job.duration, '1 minutes, 30.00 seconds') + + # A job terminated without ever starting has no execution time, and thus no duration + unstarted = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + unstarted.terminate(status=JobStatusChoices.STATUS_ERRORED) + self.assertIsNone(unstarted.duration) diff --git a/netbox/core/tests/test_tables.py b/netbox/core/tests/test_tables.py index c938fd057..5727d1a76 100644 --- a/netbox/core/tests/test_tables.py +++ b/netbox/core/tests/test_tables.py @@ -1,4 +1,11 @@ -from core.models import ObjectChange +import uuid +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from core.choices import JobStatusChoices +from core.models import Job, ObjectChange from core.tables import * from utilities.testing import TableTestCases @@ -15,6 +22,105 @@ class JobTableTestCase(TableTestCases.StandardTableTestCase): table = JobTable +class JobExecutionTimeColumnTestCase(TestCase): + """ + Test the rendering, export, and ordering behavior of JobTable's execution_time column. + """ + @classmethod + def setUpTestData(cls): + now = timezone.now() + Job.objects.bulk_create(( + Job( + name='completed-90s', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED, + started=now - timedelta(seconds=90), completed=now, execution_time=timedelta(seconds=90), + ), + Job( + name='completed-subsecond', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED, + started=now - timedelta(milliseconds=430), completed=now, + execution_time=timedelta(milliseconds=430), + ), + Job( + name='completed-long', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED, + started=now - timedelta(days=2, hours=3), completed=now, + execution_time=timedelta(days=2, hours=3), + ), + Job( + name='negative', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED, + started=now, completed=now, execution_time=timedelta(seconds=-5), + ), + Job( + name='running', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_RUNNING, + started=now - timedelta(minutes=5), + ), + Job(name='pending', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_PENDING), + )) + + def _render(self, name): + table = JobTable(Job.objects.filter(name=name)) + table.columns.show('execution_time') + return str(next(iter(table.rows)).get_cell('execution_time')) + + def test_render_completed_job(self): + self.assertEqual(self._render('completed-90s'), '1m 30s') + self.assertEqual(self._render('completed-long'), '2d 3h') + + def test_render_subsecond_job(self): + # Sub-second jobs report millisecond precision rather than reading as zero + self.assertEqual(self._render('completed-subsecond'), '0.43s') + + def test_render_negative_execution_time(self): + # A negative stored value (e.g. from clock skew) never renders as negative + self.assertEqual(self._render('negative'), '0s') + + def test_render_running_job(self): + """ + A running job has no recorded execution time, so the column shows the time elapsed so far, + visually distinguished from a completed job's final value. + """ + rendered = self._render('running') + self.assertIn('5m', rendered) + self.assertIn('text-primary', rendered) + + def test_render_job_never_started(self): + table = JobTable(Job.objects.filter(name='pending')) + table.columns.show('execution_time') + row = next(iter(table.rows)) + self.assertEqual(str(row.get_cell('execution_time')), table.default) + + def test_export_value_is_raw_seconds(self): + table = JobTable(Job.objects.filter(name='completed-90s')) + table.columns.show('execution_time') + rows = list(table.as_values()) + index = rows[0].index('Execution Time') + self.assertEqual(rows[1][index], 90.0) + + def test_export_value_of_job_never_started(self): + table = JobTable(Job.objects.filter(name='pending')) + table.columns.show('execution_time') + rows = list(table.as_values()) + index = rows[0].index('Execution Time') + self.assertIsNone(rows[1][index]) + + def test_ordering_sorts_nulls_last(self): + """ + Jobs with no recorded execution time must sort last in both directions, so that sorting by + execution time does not bury the longest-running jobs behind pending ones. + """ + recorded = ['negative', 'completed-subsecond', 'completed-90s', 'completed-long'] + unrecorded = {'running', 'pending'} + + for descending, expected in ( + (False, recorded), + (True, list(reversed(recorded))), + ): + with self.subTest(descending=descending): + table = JobTable(Job.objects.all()) + queryset, _modified = table.columns['execution_time'].order(Job.objects.all(), descending) + names = list(queryset.values_list('name', flat=True)) + self.assertEqual(names[:len(recorded)], expected) + self.assertEqual(set(names[len(recorded):]), unrecorded) + + class ObjectChangeTableTestCase(TableTestCases.StandardTableTestCase): table = ObjectChangeTable queryset_sources = [ diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 359c79c6d..bde929776 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -1,5 +1,6 @@ import zoneinfo from dataclasses import dataclass +from datetime import timedelta from urllib.parse import quote import django_tables2 as tables @@ -22,6 +23,7 @@ from extras.choices import CustomFieldTypeChoices from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model from utilities.request import get_safe_request_context +from utilities.string import humanize_duration from utilities.templatetags.builtins.filters import render_markdown from utilities.validators import url_scheme_is_allowed from utilities.views import get_action_url @@ -112,17 +114,13 @@ class DateTimeColumn(tables.Column): class DurationColumn(tables.Column): """ - Express a duration of time (in minutes) in a human-friendly format. Example: 437 minutes becomes "7h 17m" + Express a duration of time in a human-friendly format. Accepts either a timedelta or a count of + minutes. Example: 437 minutes becomes "7h 17m" """ def render(self, value): - ret = '' - if days := value // 1440: - ret += f'{days}d ' - if hours := value % 1440 // 60: - ret += f'{hours}h ' - if minutes := value % 60: - ret += f'{minutes}m' - return ret.strip() + if not isinstance(value, timedelta): + value = timedelta(minutes=value) + return humanize_duration(value) def value(self, value): return value From 736fd38e08e27e58c24a500c76a61e614372a994 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 14:56:56 -0400 Subject: [PATCH 04/22] Clarify model documentation --- docs/models/core/job.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models/core/job.md b/docs/models/core/job.md index 43ba0d660..e462b5620 100644 --- a/docs/models/core/job.md +++ b/docs/models/core/job.md @@ -30,7 +30,7 @@ The date and time at which the job completed (if complete). ### Execution Time -The amount of time the job spent executing, calculated as the difference between its start and completion times. This is populated only once a started job has completed. +The amount of time the job spent executing, calculated as the difference between its start and completion times. This is populated only once a started job has completed; while a job is still running, NetBox displays the time elapsed since it started instead. ### User From cd7fdf22679077d18673543dcd881fad9cda7e6e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 14:57:14 -0400 Subject: [PATCH 05/22] Display the duration for running scripts --- .../templates/extras/htmx/script_result.html | 10 +++++++--- netbox/utilities/string.py | 18 +++++++++++++---- netbox/utilities/templatetags/helpers.py | 17 ++++++++++++++++ netbox/utilities/tests/test_string.py | 20 ++++++++++++++++--- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index 3844c7857..fcd2319c5 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -11,9 +11,13 @@ {% else %} {% trans "Created" %}: {{ job.created|isodatetime }} {% endif %} - {% if job.completed %} - {% trans "Duration" %}: {{ job.duration }} - {% endif %} + {# For a running job this reflects the time elapsed so far; the container refreshes every 5s #} + {% with execution_time=job.elapsed_time|humanize_duration %} + {% if execution_time %} + {% trans "Execution time" %}: + {{ execution_time }} + {% endif %} + {% endwith %} {% badge job.get_status_display job.get_status_color %}

{% if job.completed %} diff --git a/netbox/utilities/string.py b/netbox/utilities/string.py index f19e1d313..75769993b 100644 --- a/netbox/utilities/string.py +++ b/netbox/utilities/string.py @@ -11,15 +11,25 @@ __all__ = ( def humanize_duration(value): """ - Express a timedelta in a human-friendly format. Example: 1h 5m 23s. Returns an empty string - for None; zero-duration timedeltas render as "0s". + Express a timedelta in a human-friendly format. Example: 1h 5m 23s. Sub-second durations are + rendered with millisecond precision (e.g. 0.43s). Returns an empty string for None; zero and + negative durations render as "0s". """ if value is None: return '' + # Negative durations (which can result from clock skew) are clamped to zero + total_seconds = max(value.total_seconds(), 0) + + # Render sub-second durations with millisecond precision, as rounding them to whole seconds + # would report every short-lived duration as zero. Trailing zeros are stripped. + if 0 < total_seconds < 1: + milliseconds = f'{total_seconds:.3f}'.rstrip('0').rstrip('.') + if milliseconds != '0': + return f'{milliseconds}s' + # Round to whole seconds and decompose - total_seconds = int(value.total_seconds()) - days, remainder = divmod(total_seconds, 86400) + days, remainder = divmod(int(total_seconds), 86400) hours, remainder = divmod(remainder, 3600) minutes, seconds = divmod(remainder, 60) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 9e7d22034..d9fcad060 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -17,6 +17,7 @@ from netbox.ui.attrs import ( ) from utilities.forms import TableConfigForm, get_selected_values from utilities.forms.mixins import FORM_FIELD_LOOKUPS +from utilities.string import humanize_duration from utilities.views import get_action_url, get_viewname __all__ = ( @@ -31,6 +32,7 @@ __all__ = ( 'get_item', 'get_key', 'humanize_disk_capacity', + 'humanize_duration_filter', 'humanize_ram_capacity', 'humanize_speed', 'icon_from_status', @@ -211,6 +213,21 @@ def _format_speed(speed, divisor, unit): return f'{whole}.{fraction} {unit}' +@register.filter('humanize_duration') +def humanize_duration_filter(value): + """ + Express a timedelta in a human-friendly format, always using the largest appropriate units. + Sub-second durations are rendered with millisecond precision. + + Examples: + + timedelta(seconds=90) => "1m 30s" + timedelta(hours=1, minutes=5, seconds=23) => "1h 5m 23s" + timedelta(milliseconds=430) => "0.43s" + """ + return humanize_duration(value) + + @register.filter() def humanize_speed(speed): """ diff --git a/netbox/utilities/tests/test_string.py b/netbox/utilities/tests/test_string.py index 620ee730d..2a377ec98 100644 --- a/netbox/utilities/tests/test_string.py +++ b/netbox/utilities/tests/test_string.py @@ -28,6 +28,20 @@ class HumanizeDurationTest(TestCase): def test_whole_minute_omits_seconds(self): self.assertEqual(humanize_duration(timedelta(minutes=2)), '2m') - def test_sub_second_rounds_down_to_zero(self): - # Fractional seconds are truncated; a sub-second duration reads as 0s. - self.assertEqual(humanize_duration(timedelta(milliseconds=500)), '0s') + def test_sub_second_renders_decimal(self): + # Sub-second durations retain millisecond precision, with trailing zeros stripped. + self.assertEqual(humanize_duration(timedelta(milliseconds=500)), '0.5s') + self.assertEqual(humanize_duration(timedelta(milliseconds=430)), '0.43s') + self.assertEqual(humanize_duration(timedelta(milliseconds=4)), '0.004s') + + def test_sub_millisecond_rounds_to_zero(self): + # Anything below a millisecond has no decimal representation, so it reads as 0s. + self.assertEqual(humanize_duration(timedelta(microseconds=400)), '0s') + + def test_fractional_seconds_truncated_above_one_second(self): + self.assertEqual(humanize_duration(timedelta(seconds=1, milliseconds=999)), '1s') + + def test_negative_duration_clamped_to_zero(self): + # A negative duration (e.g. resulting from clock skew) never renders as negative. + self.assertEqual(humanize_duration(timedelta(seconds=-1.5)), '0s') + self.assertEqual(humanize_duration(timedelta(days=-2)), '0s') From d873876fed8fb27e32729791e492e1fc2cece600 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 15:37:07 -0400 Subject: [PATCH 06/22] Batch migration updates --- .../migrations/0025_add_job_execution_time.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/netbox/core/migrations/0025_add_job_execution_time.py b/netbox/core/migrations/0025_add_job_execution_time.py index baf4be931..049bc7ab2 100644 --- a/netbox/core/migrations/0025_add_job_execution_time.py +++ b/netbox/core/migrations/0025_add_job_execution_time.py @@ -1,15 +1,28 @@ from django.db import migrations, models from django.db.models import DurationField, ExpressionWrapper, F +BATCH_SIZE = 5000 + def populate_execution_time(apps, schema_editor): """ Populate execution_time for existing jobs which have both a start and completion time recorded. + Updates are performed in batches, as installations which retain job history indefinitely can + accumulate a very large number of rows. """ Job = apps.get_model("core", "Job") - Job.objects.filter(started__isnull=False, completed__isnull=False).update( - execution_time=ExpressionWrapper(F("completed") - F("started"), output_field=DurationField()) - ) + queryset = Job.objects.filter(started__isnull=False, completed__isnull=False) + execution_time = ExpressionWrapper(F("completed") - F("started"), output_field=DurationField()) + + last_pk = 0 + while True: + pks = list( + queryset.filter(pk__gt=last_pk).order_by("pk").values_list("pk", flat=True)[:BATCH_SIZE] + ) + if not pks: + break + Job.objects.filter(pk__in=pks).update(execution_time=execution_time) + last_pk = pks[-1] class Migration(migrations.Migration): From 0088ebae8c61dade03c770fa727b879a5bab069e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 15:41:33 -0400 Subject: [PATCH 07/22] Introduce custom OrderingFilter to control NULLs & include tiebreaker --- netbox/netbox/api/filter_backends.py | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 netbox/netbox/api/filter_backends.py diff --git a/netbox/netbox/api/filter_backends.py b/netbox/netbox/api/filter_backends.py new file mode 100644 index 000000000..b64e1c503 --- /dev/null +++ b/netbox/netbox/api/filter_backends.py @@ -0,0 +1,34 @@ +from django.db.models import F +from rest_framework import filters + +__all__ = ( + 'OrderingFilter', +) + + +class OrderingFilter(filters.OrderingFilter): + """ + Extends DRF's OrderingFilter to sort null values last irrespective of the sort direction, and to + append a stable tiebreaker so that paginating through tied rows cannot skip or repeat them. + (PostgreSQL sorts nulls first when ordering descending, which pushes rows with no value to the + top of a descending sort.) + + A viewset may map a field name to a query expression via `ordering_expressions` to order by + something other than the named column; this is used where the value presented to the user is + computed rather than stored. + """ + def filter_queryset(self, request, queryset, view): + if not (ordering := self.get_ordering(request, queryset, view)): + return queryset + + expressions = getattr(view, 'ordering_expressions', {}) + terms = [] + for term in ordering: + if descending := term.startswith('-'): + term = term[1:] + expression = expressions[term] if term in expressions else F(term) + terms.append( + expression.desc(nulls_last=True) if descending else expression.asc(nulls_last=True) + ) + + return queryset.order_by(*terms, 'pk') From bff5ee605a2b3df8f488123867b1779e9efdec62 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 15:43:00 -0400 Subject: [PATCH 08/22] 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): From 93bf49b1ec66d69edf758832fd0bbb75908327b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 15:46:18 -0400 Subject: [PATCH 09/22] Show job elapsed time --- netbox/core/ui/panels.py | 2 +- netbox/templates/core/job/attrs/elapsed_time.html | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 netbox/templates/core/job/attrs/elapsed_time.html diff --git a/netbox/core/ui/panels.py b/netbox/core/ui/panels.py index 13e21ef79..c7edc4868 100644 --- a/netbox/core/ui/panels.py +++ b/netbox/core/ui/panels.py @@ -62,7 +62,7 @@ class JobSchedulingPanel(panels.ObjectAttributesPanel): scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html') started = attrs.DateTimeAttr('started') completed = attrs.DateTimeAttr('completed') - execution_time = attrs.DurationAttr('execution_time') + elapsed_time = attrs.TemplatedAttr('elapsed_time', template_name='core/job/attrs/elapsed_time.html') queue = attrs.TextAttr('queue_name', label=_('Queue')) diff --git a/netbox/templates/core/job/attrs/elapsed_time.html b/netbox/templates/core/job/attrs/elapsed_time.html new file mode 100644 index 000000000..742d903ba --- /dev/null +++ b/netbox/templates/core/job/attrs/elapsed_time.html @@ -0,0 +1,8 @@ +{% load helpers %} +{% load i18n %} +{% if object.completed %} + {{ value|humanize_duration }} +{% else %} + {# The job is still running, so its elapsed time is provisional #} + {{ value|humanize_duration }} +{% endif %} From 9a888a62fd421947c936dbb042e35392b79c8243 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 15:46:37 -0400 Subject: [PATCH 10/22] Additional review feedback --- netbox/core/tests/test_api.py | 26 ++++++++++-- netbox/core/tests/test_models.py | 41 ++++++++++++++----- netbox/core/tests/test_tables.py | 36 +++++++++++----- netbox/core/tests/test_views.py | 33 ++++++++++++++- .../extras/management/commands/runscript.py | 3 +- .../extras/tests/test_management_commands.py | 7 ++-- netbox/netbox/tables/columns.py | 3 ++ netbox/utilities/string.py | 12 +++--- netbox/utilities/tests/test_string.py | 6 ++- 9 files changed, 130 insertions(+), 37 deletions(-) diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index c043e2948..75eb4acb5 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -212,7 +212,7 @@ class JobTestCase( ) def test_list_objects_by_execution_time(self): - """The Job list endpoint supports filtering and ordering by execution_time.""" + """The Job list endpoint supports filtering by execution_time.""" self.add_permissions('core.view_job') url = reverse('core-api:job-list') @@ -221,10 +221,30 @@ class JobTestCase( self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['count'], 1) - # Ordering by execution_time should be accepted (NULLs sort to one end) + def test_ordering_by_execution_time(self): + """ + Ordering by execution_time must place jobs with no execution time last in both directions, + and must rank a running job by its elapsed time (matching the jobs table in the UI). + """ + self.add_permissions('core.view_job') + url = reverse('core-api:job-list') + + # 'Job 2' is running; give it a start time so it has an elapsed time exceeding Job 3's 90s + Job.objects.filter(name='Job 2').update(started=timezone.now() - timezone.timedelta(hours=1)) + + response = self.client.get(f'{url}?ordering=-execution_time', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual( + [job['name'] for job in response.data['results']], + ['Job 2', 'Job 3', 'Job 1'], + ) + response = self.client.get(f'{url}?ordering=execution_time', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(response.data['count'], 3) + self.assertEqual( + [job['name'] for job in response.data['results']], + ['Job 3', 'Job 2', 'Job 1'], + ) class BackgroundTaskTestCase(RQQueueTestMixin, TestCase): diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index e19fbdb3f..bf1f169a1 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -418,17 +418,36 @@ class JobTestCase(TestCase): self.assertIsNone(job.started) self.assertIsNone(job.elapsed_time) - @patch('core.models.jobs.job_end') - def test_duration_derives_from_execution_time(self, mock_job_end): + def test_elapsed_time_expression_matches_property(self): """ - The duration property should be rendered from the recorded execution_time, and should be - null for a job which never started. + The elapsed_time_expression() queryset expression should agree with the elapsed_time + property for completed, running, and never-started jobs. """ - job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - job.execution_time = timedelta(seconds=90) - self.assertEqual(job.duration, '1 minutes, 30.00 seconds') + completed = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + completed.started = timezone.now() - timedelta(seconds=90) + completed.completed = timezone.now() + completed.execution_time = timedelta(seconds=90) + completed.status = JobStatusChoices.STATUS_COMPLETED + completed.save() - # A job terminated without ever starting has no execution time, and thus no duration - unstarted = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - unstarted.terminate(status=JobStatusChoices.STATUS_ERRORED) - self.assertIsNone(unstarted.duration) + running = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + running.started = timezone.now() - timedelta(minutes=5) + running.save() + + pending = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + pending.status = JobStatusChoices.STATUS_PENDING + pending.save() + + annotated = { + job.pk: job + for job in Job.objects.annotate(elapsed=Job.elapsed_time_expression()) + } + + self.assertEqual(annotated[completed.pk].elapsed, timedelta(seconds=90)) + self.assertIsNone(annotated[pending.pk].elapsed) + # The running job's elapsed time is computed at query time, so compare approximately + self.assertAlmostEqual( + annotated[running.pk].elapsed.total_seconds(), + running.elapsed_time.total_seconds(), + delta=5, + ) diff --git a/netbox/core/tests/test_tables.py b/netbox/core/tests/test_tables.py index 5727d1a76..bb75446a8 100644 --- a/netbox/core/tests/test_tables.py +++ b/netbox/core/tests/test_tables.py @@ -101,24 +101,40 @@ class JobExecutionTimeColumnTestCase(TestCase): index = rows[0].index('Execution Time') self.assertIsNone(rows[1][index]) - def test_ordering_sorts_nulls_last(self): + def test_ordering_matches_displayed_values(self): """ - Jobs with no recorded execution time must sort last in both directions, so that sorting by - execution time does not bury the longest-running jobs behind pending ones. + Sorting must order by the value the column displays — which for a running job is its elapsed + time, not a null — so that a long-running job is not buried. Jobs which never started sort + last in both directions. """ - recorded = ['negative', 'completed-subsecond', 'completed-90s', 'completed-long'] - unrecorded = {'running', 'pending'} + # 'running' has been going 5 minutes, so it sorts between the 90s and 2d3h jobs + ascending = ['negative', 'completed-subsecond', 'completed-90s', 'running', 'completed-long'] for descending, expected in ( - (False, recorded), - (True, list(reversed(recorded))), + (False, ascending), + (True, list(reversed(ascending))), ): with self.subTest(descending=descending): table = JobTable(Job.objects.all()) - queryset, _modified = table.columns['execution_time'].order(Job.objects.all(), descending) + queryset, modified = table.columns['execution_time'].order(Job.objects.all(), descending) + self.assertTrue(modified) names = list(queryset.values_list('name', flat=True)) - self.assertEqual(names[:len(recorded)], expected) - self.assertEqual(set(names[len(recorded):]), unrecorded) + self.assertEqual(names, expected + ['pending']) + + def test_ordering_breaks_ties_on_pk(self): + """ + Tied rows need a stable secondary sort, or paginating through them can skip or repeat rows. + """ + Job.objects.bulk_create( + Job(name=f'tied-{i}', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_PENDING) + for i in range(4) + ) + table = JobTable(Job.objects.all()) + queryset, _modified = table.columns['execution_time'].order(Job.objects.filter( + name__startswith='tied-' + ), True) + pks = list(queryset.values_list('pk', flat=True)) + self.assertEqual(pks, sorted(pks)) class ObjectChangeTableTestCase(TableTestCases.StandardTableTestCase): diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index ad89d002a..fde439571 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -1,7 +1,7 @@ import json import urllib.parse import uuid -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -151,6 +151,37 @@ class JobTestCase( ] ) + def test_execution_time_on_detail_view(self): + """ + The job detail view must present execution time consistently with the jobs list: the recorded + value for a completed job, the elapsed time (visually distinguished) for a running one, and a + placeholder for a job which never started. + """ + self.add_permissions('core.view_job') + now = timezone.now() + + completed = Job.objects.get(name='Job 3') + completed.started = now - timedelta(seconds=90) + completed.completed = now + completed.execution_time = timedelta(seconds=90) + completed.save() + response = self.client.get(completed.get_absolute_url()) + self.assertHttpStatus(response, 200) + self.assertIn('1m 30s', str(response.content)) + + running = Job.objects.get(name='Job 2') + running.started = now - timedelta(hours=2) + running.save() + response = self.client.get(running.get_absolute_url()) + self.assertHttpStatus(response, 200) + content = str(response.content) + self.assertIn('2h', content) + self.assertIn('Still running', content) + + pending = Job.objects.get(name='Job 1') + response = self.client.get(pending.get_absolute_url()) + self.assertHttpStatus(response, 200) + class JobLogViewTestCase(TestCase): user_permissions = ( diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index ef3a46fac..60948058f 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -9,6 +9,7 @@ from extras.jobs import ScriptJob from extras.scripts import get_module_and_script from users.models import User from utilities.request import NetBoxFakeRequest +from utilities.string import humanize_duration class Command(BaseCommand): @@ -106,4 +107,4 @@ class Command(BaseCommand): commit=commit, ) - logger.info(f"Script completed in {job.duration}") + logger.info(f"Script completed in {humanize_duration(job.elapsed_time)}") diff --git a/netbox/extras/tests/test_management_commands.py b/netbox/extras/tests/test_management_commands.py index afc2f8d73..968d48760 100644 --- a/netbox/extras/tests/test_management_commands.py +++ b/netbox/extras/tests/test_management_commands.py @@ -1,3 +1,4 @@ +from datetime import timedelta from io import BytesIO, StringIO from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -273,7 +274,7 @@ class RunScriptTestCase(TestCase): return form script_obj = SimpleNamespace(python_class=TestScript) - job = SimpleNamespace(duration='0 seconds') + job = SimpleNamespace(elapsed_time=timedelta(0)) with ( patch( @@ -358,7 +359,7 @@ class RunScriptTestCase(TestCase): return form script_obj = SimpleNamespace(python_class=TestScript) - job = SimpleNamespace(duration='0 seconds') + job = SimpleNamespace(elapsed_time=timedelta(0)) with ( patch( @@ -398,7 +399,7 @@ class RunScriptTestCase(TestCase): return form script_obj = SimpleNamespace(python_class=TestScript) - job = SimpleNamespace(duration='0 seconds') + job = SimpleNamespace(elapsed_time=timedelta(0)) with ( patch( diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index bde929776..a4e302105 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -119,6 +119,9 @@ class DurationColumn(tables.Column): """ def render(self, value): if not isinstance(value, timedelta): + if not value: + # A zero count of minutes renders as empty rather than "0s" + return '' value = timedelta(minutes=value) return humanize_duration(value) diff --git a/netbox/utilities/string.py b/netbox/utilities/string.py index 75769993b..404c34f50 100644 --- a/netbox/utilities/string.py +++ b/netbox/utilities/string.py @@ -11,9 +11,9 @@ __all__ = ( def humanize_duration(value): """ - Express a timedelta in a human-friendly format. Example: 1h 5m 23s. Sub-second durations are - rendered with millisecond precision (e.g. 0.43s). Returns an empty string for None; zero and - negative durations render as "0s". + Express a timedelta in a human-friendly format. Example: 1h 5m 23s. Durations of a second or + more are rounded to whole seconds; shorter durations are rounded to the millisecond (e.g. + 0.43s). Returns an empty string for None; zero and negative durations render as "0s". """ if value is None: return '' @@ -21,15 +21,15 @@ def humanize_duration(value): # Negative durations (which can result from clock skew) are clamped to zero total_seconds = max(value.total_seconds(), 0) - # Render sub-second durations with millisecond precision, as rounding them to whole seconds - # would report every short-lived duration as zero. Trailing zeros are stripped. + # Render sub-second durations to the millisecond, as rounding them to whole seconds would + # report every short-lived duration as zero. Trailing zeros are stripped. if 0 < total_seconds < 1: milliseconds = f'{total_seconds:.3f}'.rstrip('0').rstrip('.') if milliseconds != '0': return f'{milliseconds}s' # Round to whole seconds and decompose - days, remainder = divmod(int(total_seconds), 86400) + days, remainder = divmod(round(total_seconds), 86400) hours, remainder = divmod(remainder, 3600) minutes, seconds = divmod(remainder, 60) diff --git a/netbox/utilities/tests/test_string.py b/netbox/utilities/tests/test_string.py index 2a377ec98..c789ef23f 100644 --- a/netbox/utilities/tests/test_string.py +++ b/netbox/utilities/tests/test_string.py @@ -38,8 +38,10 @@ class HumanizeDurationTest(TestCase): # Anything below a millisecond has no decimal representation, so it reads as 0s. self.assertEqual(humanize_duration(timedelta(microseconds=400)), '0s') - def test_fractional_seconds_truncated_above_one_second(self): - self.assertEqual(humanize_duration(timedelta(seconds=1, milliseconds=999)), '1s') + def test_fractional_seconds_rounded_above_one_second(self): + self.assertEqual(humanize_duration(timedelta(seconds=1, milliseconds=999)), '2s') + self.assertEqual(humanize_duration(timedelta(seconds=1, milliseconds=100)), '1s') + self.assertEqual(humanize_duration(timedelta(seconds=59, milliseconds=600)), '1m') def test_negative_duration_clamped_to_zero(self): # A negative duration (e.g. resulting from clock skew) never renders as negative. From 17b2017e6bc0b83582137d83c0266f4b054c8cda Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 15:58:13 -0400 Subject: [PATCH 11/22] Drop DurationAttr --- netbox/netbox/ui/attrs.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index bb4959466..93811bce5 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _ from netbox.config import get_config from netbox.ui.utils import build_coords_url, is_coordinate_map_url from utilities.data import resolve_attr_path -from utilities.string import humanize_duration __all__ = ( 'AddressAttr', @@ -16,7 +15,6 @@ __all__ = ( 'DateTimeAttr', 'DiameterAttr', 'DistanceAttr', - 'DurationAttr', 'FlowRateAttr', 'GPSCoordinatesAttr', 'GenericForeignKeyAttr', @@ -566,15 +564,6 @@ class TimezoneAttr(ObjectAttribute): template_name = 'ui/attrs/timezone.html' -class DurationAttr(TextAttr): - """ - A duration (timedelta) value, rendered in a human-friendly format (e.g. 1h 5m 23s). - """ - def get_value(self, obj): - value = resolve_attr_path(obj, self.accessor) - return humanize_duration(value) or None - - class TemplatedAttr(ObjectAttribute): """ Renders an attribute using a custom template. From a633e80804a0df3052adc2f627193d3bb2aba0de Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 16:16:01 -0400 Subject: [PATCH 12/22] Revert OrderingFilter --- netbox/core/api/views.py | 7 ------ netbox/core/tests/test_api.py | 26 +++------------------ netbox/netbox/api/filter_backends.py | 34 ---------------------------- 3 files changed, 3 insertions(+), 64 deletions(-) delete mode 100644 netbox/netbox/api/filter_backends.py diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py index 70a1a6258..7d5e21f20 100644 --- a/netbox/core/api/views.py +++ b/netbox/core/api/views.py @@ -1,7 +1,6 @@ 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 @@ -20,7 +19,6 @@ 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 @@ -73,11 +71,6 @@ 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/tests/test_api.py b/netbox/core/tests/test_api.py index 75eb4acb5..c043e2948 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -212,7 +212,7 @@ class JobTestCase( ) def test_list_objects_by_execution_time(self): - """The Job list endpoint supports filtering by execution_time.""" + """The Job list endpoint supports filtering and ordering by execution_time.""" self.add_permissions('core.view_job') url = reverse('core-api:job-list') @@ -221,30 +221,10 @@ class JobTestCase( self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['count'], 1) - def test_ordering_by_execution_time(self): - """ - Ordering by execution_time must place jobs with no execution time last in both directions, - and must rank a running job by its elapsed time (matching the jobs table in the UI). - """ - self.add_permissions('core.view_job') - url = reverse('core-api:job-list') - - # 'Job 2' is running; give it a start time so it has an elapsed time exceeding Job 3's 90s - Job.objects.filter(name='Job 2').update(started=timezone.now() - timezone.timedelta(hours=1)) - - response = self.client.get(f'{url}?ordering=-execution_time', **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual( - [job['name'] for job in response.data['results']], - ['Job 2', 'Job 3', 'Job 1'], - ) - + # Ordering by execution_time should be accepted (NULLs sort to one end) response = self.client.get(f'{url}?ordering=execution_time', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual( - [job['name'] for job in response.data['results']], - ['Job 3', 'Job 2', 'Job 1'], - ) + self.assertEqual(response.data['count'], 3) class BackgroundTaskTestCase(RQQueueTestMixin, TestCase): diff --git a/netbox/netbox/api/filter_backends.py b/netbox/netbox/api/filter_backends.py deleted file mode 100644 index b64e1c503..000000000 --- a/netbox/netbox/api/filter_backends.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.db.models import F -from rest_framework import filters - -__all__ = ( - 'OrderingFilter', -) - - -class OrderingFilter(filters.OrderingFilter): - """ - Extends DRF's OrderingFilter to sort null values last irrespective of the sort direction, and to - append a stable tiebreaker so that paginating through tied rows cannot skip or repeat them. - (PostgreSQL sorts nulls first when ordering descending, which pushes rows with no value to the - top of a descending sort.) - - A viewset may map a field name to a query expression via `ordering_expressions` to order by - something other than the named column; this is used where the value presented to the user is - computed rather than stored. - """ - def filter_queryset(self, request, queryset, view): - if not (ordering := self.get_ordering(request, queryset, view)): - return queryset - - expressions = getattr(view, 'ordering_expressions', {}) - terms = [] - for term in ordering: - if descending := term.startswith('-'): - term = term[1:] - expression = expressions[term] if term in expressions else F(term) - terms.append( - expression.desc(nulls_last=True) if descending else expression.asc(nulls_last=True) - ) - - return queryset.order_by(*terms, 'pk') From bab3ccd2161f33f91bcf91344300b8667f0be40d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 17:09:59 -0400 Subject: [PATCH 13/22] Keep the job completion filters alongside the other scheduling fields started__* and completed__* are two halves of the same time range, so splitting them across the Scheduling and Execution field sets made a run window awkward to filter. Execution now holds only execution_time, and the grouping matches JobSchedulingPanel on the detail view. Co-Authored-By: Claude Opus 5 --- netbox/core/forms/filtersets.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 998564498..747e1bbee 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -78,11 +78,9 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): FieldSet('object_type_id', 'status', 'queue_name', 'user', name=_('Attributes')), FieldSet( 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', - 'started__after', name=_('Scheduling') - ), - FieldSet( - 'completed__before', 'completed__after', 'execution_time__gte', 'execution_time__lte', name=_('Execution'), + 'started__after', 'completed__before', 'completed__after', name=_('Scheduling') ), + FieldSet('execution_time__gte', 'execution_time__lte', name=_('Execution')), ) object_type_id = ContentTypeChoiceField( label=_('Object Type'), From 5346dab1c3f3d6075f400c46eef1e1da0f02a827 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 17:10:13 -0400 Subject: [PATCH 14/22] Move the execution_time backfill into its own non-atomic migration Batching the backfill bounded statement size but not lock duration: sharing a transaction with the AddField meant the ACCESS EXCLUSIVE lock from ALTER TABLE was held for the whole run, which is exactly the case the batching was meant to help. 0025 goes back to adding the column only, and the backfill moves to 0026 with atomic = False so the lock is released first. The backfill now also skips rows which already have a value, making it idempotent and letting an interrupted run simply be resumed. As a separate migration it additionally reaches installations which had already applied 0025, rather than silently leaving their historical jobs unpopulated. Co-Authored-By: Claude Opus 5 --- .../migrations/0025_add_job_execution_time.py | 28 ----------- .../0026_populate_job_execution_time.py | 46 +++++++++++++++++++ 2 files changed, 46 insertions(+), 28 deletions(-) create mode 100644 netbox/core/migrations/0026_populate_job_execution_time.py diff --git a/netbox/core/migrations/0025_add_job_execution_time.py b/netbox/core/migrations/0025_add_job_execution_time.py index 049bc7ab2..f8c58d9ed 100644 --- a/netbox/core/migrations/0025_add_job_execution_time.py +++ b/netbox/core/migrations/0025_add_job_execution_time.py @@ -1,28 +1,4 @@ from django.db import migrations, models -from django.db.models import DurationField, ExpressionWrapper, F - -BATCH_SIZE = 5000 - - -def populate_execution_time(apps, schema_editor): - """ - Populate execution_time for existing jobs which have both a start and completion time recorded. - Updates are performed in batches, as installations which retain job history indefinitely can - accumulate a very large number of rows. - """ - Job = apps.get_model("core", "Job") - queryset = Job.objects.filter(started__isnull=False, completed__isnull=False) - execution_time = ExpressionWrapper(F("completed") - F("started"), output_field=DurationField()) - - last_pk = 0 - while True: - pks = list( - queryset.filter(pk__gt=last_pk).order_by("pk").values_list("pk", flat=True)[:BATCH_SIZE] - ) - if not pks: - break - Job.objects.filter(pk__in=pks).update(execution_time=execution_time) - last_pk = pks[-1] class Migration(migrations.Migration): @@ -37,8 +13,4 @@ class Migration(migrations.Migration): name="execution_time", field=models.DurationField(blank=True, editable=False, null=True), ), - migrations.RunPython( - code=populate_execution_time, - reverse_code=migrations.RunPython.noop, - ), ] diff --git a/netbox/core/migrations/0026_populate_job_execution_time.py b/netbox/core/migrations/0026_populate_job_execution_time.py new file mode 100644 index 000000000..e74182024 --- /dev/null +++ b/netbox/core/migrations/0026_populate_job_execution_time.py @@ -0,0 +1,46 @@ +from django.db import migrations +from django.db.models import DurationField, ExpressionWrapper, F + +BATCH_SIZE = 5000 + + +def populate_execution_time(apps, schema_editor): + """ + Populate execution_time for existing jobs which have both a start and completion time recorded. + Updates are performed in batches, as installations which retain job history indefinitely can + accumulate a very large number of rows. Rows which already have a value are skipped, so that an + interrupted run can simply be resumed. + """ + Job = apps.get_model("core", "Job") + queryset = Job.objects.filter( + started__isnull=False, completed__isnull=False, execution_time__isnull=True + ) + execution_time = ExpressionWrapper(F("completed") - F("started"), output_field=DurationField()) + + last_pk = 0 + while True: + pks = list( + queryset.filter(pk__gt=last_pk).order_by("pk").values_list("pk", flat=True)[:BATCH_SIZE] + ) + if not pks: + break + Job.objects.filter(pk__in=pks).update(execution_time=execution_time) + last_pk = pks[-1] + + +class Migration(migrations.Migration): + # The backfill is deliberately kept out of the migration which adds the column, so that the + # ACCESS EXCLUSIVE lock taken by ALTER TABLE is not held for its duration. Running without a + # wrapping transaction is what allows the batching above to bound the work actually held open. + atomic = False + + dependencies = [ + ("core", "0025_add_job_execution_time"), + ] + + operations = [ + migrations.RunPython( + code=populate_execution_time, + reverse_code=migrations.RunPython.noop, + ), + ] From f427e61063ad5a7c8269fb76ab87a403261cad54 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 17:10:26 -0400 Subject: [PATCH 15/22] Fix elapsed_time_expression() for jobs completed without an execution time The expression coalesced to Now() - started with no regard for whether the job had finished, so a row with both started and completed set but a null execution_time resolved to an ever-growing interval, while the elapsed_time property returned None for the same row. Sorting the jobs table descending by execution time therefore ranked those rows above every real value. Gate the live branch on completed__isnull=True so the expression agrees with the property. Co-Authored-By: Claude Opus 5 --- netbox/core/models/jobs.py | 14 +++++++++++--- netbox/core/tests/test_models.py | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index c45b38770..ca7439128 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -11,7 +11,7 @@ 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 import Case, ExpressionWrapper, F, When from django.db.models.functions import Coalesce, Now from django.urls import reverse from django.utils import timezone @@ -200,11 +200,19 @@ class Job(models.Model): 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. + filtering. Resolves to null for jobs which have not yet started, and for jobs which have + completed without recording an execution time. """ return Coalesce( 'execution_time', - ExpressionWrapper(Now() - F('started'), output_field=models.DurationField()), + # Only a job which has yet to complete accrues elapsed time + Case( + When( + completed__isnull=True, + then=ExpressionWrapper(Now() - F('started'), output_field=models.DurationField()), + ), + output_field=models.DurationField(), + ), ) def delete(self, *args, **kwargs): diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index bf1f169a1..ce275364b 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -418,6 +418,20 @@ class JobTestCase(TestCase): self.assertIsNone(job.started) self.assertIsNone(job.elapsed_time) + def test_elapsed_time_none_when_completed_without_execution_time(self): + """ + A job which completed without recording an execution time (e.g. one predating the field) + has no elapsed time to report; it must not accrue time indefinitely. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + job.started = timezone.now() - timedelta(seconds=90) + job.completed = timezone.now() + job.status = JobStatusChoices.STATUS_COMPLETED + job.save() + + self.assertIsNone(job.execution_time) + self.assertIsNone(job.elapsed_time) + def test_elapsed_time_expression_matches_property(self): """ The elapsed_time_expression() queryset expression should agree with the elapsed_time @@ -430,6 +444,14 @@ class JobTestCase(TestCase): completed.status = JobStatusChoices.STATUS_COMPLETED completed.save() + # A job which completed without recording an execution time must resolve to null, rather + # than to an ever-growing interval since it started + unrecorded = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + unrecorded.started = timezone.now() - timedelta(seconds=90) + unrecorded.completed = timezone.now() + unrecorded.status = JobStatusChoices.STATUS_COMPLETED + unrecorded.save() + running = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) running.started = timezone.now() - timedelta(minutes=5) running.save() @@ -444,6 +466,7 @@ class JobTestCase(TestCase): } self.assertEqual(annotated[completed.pk].elapsed, timedelta(seconds=90)) + self.assertIsNone(annotated[unrecorded.pk].elapsed) self.assertIsNone(annotated[pending.pk].elapsed) # The running job's elapsed time is computed at query time, so compare approximately self.assertAlmostEqual( From 26b5eb8a8380a762d4c6f1a24295aa2d06e0b9ec Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 17:10:38 -0400 Subject: [PATCH 16/22] Label the job detail panel attribute "Execution Time" Renaming the attribute to elapsed_time changed its auto-derived label to "Elapsed time", disagreeing with the list column, the filter form, the API field and the model docs. The derived label is also built at runtime before being passed to gettext, so it would never have been extracted into the message catalog. An explicit label addresses both. Co-Authored-By: Claude Opus 5 --- netbox/core/tests/test_views.py | 5 ++++- netbox/core/ui/panels.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index fde439571..a6479c090 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -167,7 +167,10 @@ class JobTestCase( completed.save() response = self.client.get(completed.get_absolute_url()) self.assertHttpStatus(response, 200) - self.assertIn('1m 30s', str(response.content)) + content = str(response.content) + self.assertIn('1m 30s', content) + # The panel must use the same label as the list column, filter form, and API field + self.assertIn('Execution Time', content) running = Job.objects.get(name='Job 2') running.started = now - timedelta(hours=2) diff --git a/netbox/core/ui/panels.py b/netbox/core/ui/panels.py index c7edc4868..79d872b2a 100644 --- a/netbox/core/ui/panels.py +++ b/netbox/core/ui/panels.py @@ -62,7 +62,11 @@ class JobSchedulingPanel(panels.ObjectAttributesPanel): scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html') started = attrs.DateTimeAttr('started') completed = attrs.DateTimeAttr('completed') - elapsed_time = attrs.TemplatedAttr('elapsed_time', template_name='core/job/attrs/elapsed_time.html') + elapsed_time = attrs.TemplatedAttr( + 'elapsed_time', + label=_('Execution Time'), + template_name='core/job/attrs/elapsed_time.html', + ) queue = attrs.TextAttr('queue_name', label=_('Queue')) From c9185e1eb756c284f4966495c92eed1b3167a42d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 17:10:51 -0400 Subject: [PATCH 17/22] Revert the DurationColumn extension and export execution_time verbatim JobTable defines both render_execution_time() and value_execution_time(), so django-tables2 never invoked DurationColumn for that column and the new timedelta branch was unreachable and untested. Restore the column to its minutes-only form and use a plain Column, which is what the table was effectively getting anyway. The export path also passed through the render path's clamping, so an anomalous negative execution_time was normalized to zero in the one output intended for analysis, and a running job's provisional elapsed time was indistinguishable from a completed job's final value. Export the recorded value verbatim and leave the still-running distinction to the UI. Co-Authored-By: Claude Opus 5 --- netbox/core/tables/jobs.py | 10 ++++--- netbox/core/tests/test_tables.py | 46 +++++++++++++++++++++++--------- netbox/netbox/tables/columns.py | 19 +++++++------ 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index afb63e764..e3a7c2b74 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -45,7 +45,7 @@ class JobTable(NetBoxTable): completed = columns.DateTimeColumn( verbose_name=_('Completed'), ) - execution_time = columns.DurationColumn( + execution_time = tables.Column( verbose_name=_('Execution Time'), # Render running jobs (which have no recorded execution time yet) rather than the placeholder empty_values=(), @@ -87,10 +87,12 @@ class JobTable(NetBoxTable): return value def value_execution_time(self, record): - # Export the raw number of seconds rather than the humanized rendering - if (duration := record.elapsed_time) is None: + # Export the recorded execution time verbatim, as a raw number of seconds. A running job's + # provisional elapsed time is deliberately omitted, as is the clamping of anomalous negative + # values applied when rendering: an export is intended for analysis. + if record.execution_time is None: return None - return round(max(duration.total_seconds(), 0), 3) + return round(record.execution_time.total_seconds(), 3) def order_execution_time(self, queryset, is_descending): # Order by the value the column actually displays, so that a long-running job is not sorted diff --git a/netbox/core/tests/test_tables.py b/netbox/core/tests/test_tables.py index bb75446a8..91a1572d5 100644 --- a/netbox/core/tests/test_tables.py +++ b/netbox/core/tests/test_tables.py @@ -48,6 +48,10 @@ class JobExecutionTimeColumnTestCase(TestCase): name='negative', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED, started=now, completed=now, execution_time=timedelta(seconds=-5), ), + Job( + name='unrecorded', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED, + started=now - timedelta(seconds=90), completed=now, + ), Job( name='running', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_RUNNING, started=now - timedelta(minutes=5), @@ -87,28 +91,46 @@ class JobExecutionTimeColumnTestCase(TestCase): row = next(iter(table.rows)) self.assertEqual(str(row.get_cell('execution_time')), table.default) - def test_export_value_is_raw_seconds(self): - table = JobTable(Job.objects.filter(name='completed-90s')) + def test_render_job_completed_without_execution_time(self): + # A completed job with no recorded execution time shows the placeholder, rather than + # accruing time since it started + table = JobTable(Job.objects.filter(name='unrecorded')) + table.columns.show('execution_time') + row = next(iter(table.rows)) + self.assertEqual(str(row.get_cell('execution_time')), table.default) + + def _export_value(self, name): + table = JobTable(Job.objects.filter(name=name)) table.columns.show('execution_time') rows = list(table.as_values()) - index = rows[0].index('Execution Time') - self.assertEqual(rows[1][index], 90.0) + return rows[1][rows[0].index('Execution Time')] + + def test_export_value_is_raw_seconds(self): + self.assertEqual(self._export_value('completed-90s'), 90.0) def test_export_value_of_job_never_started(self): - table = JobTable(Job.objects.filter(name='pending')) - table.columns.show('execution_time') - rows = list(table.as_values()) - index = rows[0].index('Execution Time') - self.assertIsNone(rows[1][index]) + self.assertIsNone(self._export_value('pending')) + + def test_export_value_of_running_job(self): + # Only the recorded execution time is exported; a running job has none yet + self.assertIsNone(self._export_value('running')) + + def test_export_value_is_not_clamped(self): + # An anomalous negative value is clamped when rendered, but exported verbatim so that it + # remains visible to analysis + self.assertEqual(self._export_value('negative'), -5.0) def test_ordering_matches_displayed_values(self): """ Sorting must order by the value the column displays — which for a running job is its elapsed - time, not a null — so that a long-running job is not buried. Jobs which never started sort - last in both directions. + time, not a null — so that a long-running job is not buried. Jobs with no elapsed time to + display sort last in both directions, in pk order. """ # 'running' has been going 5 minutes, so it sorts between the 90s and 2d3h jobs ascending = ['negative', 'completed-subsecond', 'completed-90s', 'running', 'completed-long'] + # Neither a job which never started nor one which completed without recording an execution + # time has a value to sort by + no_value = ['unrecorded', 'pending'] for descending, expected in ( (False, ascending), @@ -119,7 +141,7 @@ class JobExecutionTimeColumnTestCase(TestCase): queryset, modified = table.columns['execution_time'].order(Job.objects.all(), descending) self.assertTrue(modified) names = list(queryset.values_list('name', flat=True)) - self.assertEqual(names, expected + ['pending']) + self.assertEqual(names, expected + no_value) def test_ordering_breaks_ties_on_pk(self): """ diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index a4e302105..359c79c6d 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -1,6 +1,5 @@ import zoneinfo from dataclasses import dataclass -from datetime import timedelta from urllib.parse import quote import django_tables2 as tables @@ -23,7 +22,6 @@ from extras.choices import CustomFieldTypeChoices from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model from utilities.request import get_safe_request_context -from utilities.string import humanize_duration from utilities.templatetags.builtins.filters import render_markdown from utilities.validators import url_scheme_is_allowed from utilities.views import get_action_url @@ -114,16 +112,17 @@ class DateTimeColumn(tables.Column): class DurationColumn(tables.Column): """ - Express a duration of time in a human-friendly format. Accepts either a timedelta or a count of - minutes. Example: 437 minutes becomes "7h 17m" + Express a duration of time (in minutes) in a human-friendly format. Example: 437 minutes becomes "7h 17m" """ def render(self, value): - if not isinstance(value, timedelta): - if not value: - # A zero count of minutes renders as empty rather than "0s" - return '' - value = timedelta(minutes=value) - return humanize_duration(value) + ret = '' + if days := value // 1440: + ret += f'{days}d ' + if hours := value % 1440 // 60: + ret += f'{hours}h ' + if minutes := value % 60: + ret += f'{minutes}m' + return ret.strip() def value(self, value): return value From 4d3871009c06c832ec2937d26cb1ceff251ea6a3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 19:37:15 -0400 Subject: [PATCH 18/22] Retain Job.duration as a deprecated property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job.duration has been public since 3.4 and is reachable from user-authored export templates as well as plugins, so removing it outright was a silent breaking change. Restore the original implementation verbatim — including the fallback to `created` when a job never started, and the preformatted string — so existing templates keep working, and warn on access. Planned for removal in v5.0, matching the rack legacy fields. Note that elapsed_time deliberately does not reproduce the `created` fallback: measuring from creation conflates queue wait time with execution time, which is what the new field is meant to record. Co-Authored-By: Claude Opus 5 --- docs/models/core/job.md | 3 ++ netbox/core/models/jobs.py | 43 ++++++++++++++++++++++++---- netbox/core/tests/test_models.py | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/docs/models/core/job.md b/docs/models/core/job.md index e462b5620..ad073f888 100644 --- a/docs/models/core/job.md +++ b/docs/models/core/job.md @@ -32,6 +32,9 @@ The date and time at which the job completed (if complete). The amount of time the job spent executing, calculated as the difference between its start and completion times. This is populated only once a started job has completed; while a job is still running, NetBox displays the time elapsed since it started instead. +!!! warning "The duration property is deprecated" + The job model's `duration` property, which returned a preformatted string such as `5 minutes, 3.00 seconds`, has been **deprecated** and is planned for removal in NetBox v5.0. Export templates and plugins should reference `elapsed_time` instead, which returns a duration rather than a string and which also reports progress for a job that is still running. + ### User The user who created the job. diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index ca7439128..82830d51c 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -1,6 +1,8 @@ import logging import uuid +import warnings from dataclasses import asdict +from datetime import timedelta from functools import partial import django_rq @@ -184,17 +186,48 @@ class Job(models.Model): _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) ) + @property + def duration(self): + """ + Deprecated: use `elapsed_time` instead, which reports a timedelta and also covers jobs which + are still running. Retained for the benefit of existing export templates and plugins. + """ + warnings.warn( + "Job.duration is deprecated and will be removed in NetBox v5.0; use Job.elapsed_time " + "instead.", + DeprecationWarning, + stacklevel=2, + ) + + if not self.completed: + return None + + start_time = self.started or self.created + + if not start_time: + return None + + duration = self.completed - start_time + minutes, seconds = divmod(duration.total_seconds(), 60) + + return f'{int(minutes)} minutes, {seconds:.2f} seconds' + @property def elapsed_time(self): """ The job's recorded execution time, or the time elapsed so far if it is still running. - Returns None for a job which has not yet started. + Returns None for a job which has not yet started. As this is the value NetBox displays, an + anomalous negative duration (which can result from clock skew) is clamped to zero; the + stored `execution_time` is left as recorded. """ if self.execution_time is not None: - return self.execution_time - if self.started and not self.completed: - return timezone.now() - self.started - return None + elapsed = self.execution_time + elif self.started and not self.completed: + elapsed = timezone.now() - self.started + else: + return None + + return max(elapsed, timedelta()) @staticmethod def elapsed_time_expression(): diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index ce275364b..2f8c21d6c 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -418,6 +418,55 @@ class JobTestCase(TestCase): self.assertIsNone(job.started) self.assertIsNone(job.elapsed_time) + def test_elapsed_time_clamps_negative_execution_time(self): + """ + elapsed_time is the value NetBox displays, so an anomalous negative duration (e.g. resulting + from clock skew) is floored at zero. The stored execution_time is left as recorded, so that + the anomaly remains visible to the API and to exports. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + job.started = timezone.now() + job.completed = timezone.now() + job.execution_time = timedelta(seconds=-5) + job.status = JobStatusChoices.STATUS_COMPLETED + job.save() + + self.assertEqual(job.elapsed_time, timedelta()) + self.assertEqual(job.execution_time, timedelta(seconds=-5)) + + def test_duration_is_deprecated(self): + """ + Job.duration is retained for existing export templates and plugins, but must warn. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + job.started = timezone.now() - timedelta(seconds=90) + job.completed = job.started + timedelta(seconds=90) + job.status = JobStatusChoices.STATUS_COMPLETED + job.save() + + with self.assertWarns(DeprecationWarning): + self.assertEqual(job.duration, '1 minutes, 30.00 seconds') + + def test_duration_falls_back_to_created(self): + """ + The deprecated property's original behavior must be preserved: a job which completed without + ever starting reports its duration relative to creation. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + job.started = None + job.completed = job.created + timedelta(seconds=30) + job.status = JobStatusChoices.STATUS_ERRORED + job.save() + + with self.assertWarns(DeprecationWarning): + self.assertEqual(job.duration, '0 minutes, 30.00 seconds') + + def test_duration_none_when_not_completed(self): + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + + with self.assertWarns(DeprecationWarning): + self.assertIsNone(job.duration) + def test_elapsed_time_none_when_completed_without_execution_time(self): """ A job which completed without recording an execution time (e.g. one predating the field) From 7f91228fbf5b2e4f165ad529a29ba2b6a0fcc397 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 19:38:03 -0400 Subject: [PATCH 19/22] Document that execution time sorts and filters differently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jobs list sorts by the displayed value, so a running job orders by how long it has been going, while execution_time__gte/__lte match only the recorded column — a long-running job can therefore top a descending sort yet be excluded by a filter on the same attribute. Keeping the filters on the stored column is deliberate: the filterset is shared with the REST API, where matching against a live, clock-dependent value would make results non-reproducible. Document the distinction, along with the export's use of the recorded value, rather than reconciling them. Co-Authored-By: Claude Opus 5 --- docs/models/core/job.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/models/core/job.md b/docs/models/core/job.md index ad073f888..c3fe939a9 100644 --- a/docs/models/core/job.md +++ b/docs/models/core/job.md @@ -35,6 +35,9 @@ The amount of time the job spent executing, calculated as the difference between !!! warning "The duration property is deprecated" The job model's `duration` property, which returned a preformatted string such as `5 minutes, 3.00 seconds`, has been **deprecated** and is planned for removal in NetBox v5.0. Export templates and plugins should reference `elapsed_time` instead, which returns a duration rather than a string and which also reports progress for a job that is still running. +!!! note "Filtering and sorting behave differently" + Filtering on execution time matches only the recorded value, so a job which is still running is never returned: it has no execution time yet. Sorting the jobs list by the **Execution Time** column instead orders by the value displayed, which for a running job is the time elapsed so far. A long-running job therefore appears near the top when sorting in descending order, but is excluded by a filter on the same attribute. Exports likewise carry only the recorded value, in seconds. + ### User The user who created the job. From feaa8698a08945657e9fbe57fdf1e4dd8216d29c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 19:38:15 -0400 Subject: [PATCH 20/22] Move the negative-duration clamp out of humanize_duration() humanize_duration() is a general-purpose helper, newly exposed as a template filter, so clamping negatives inside it made every present and future caller suppress the exact symptom of clock skew. It now renders a negative duration with a leading minus sign, which also fixes the nonsensical output the divmod decomposition previously produced for one (e.g. "-1d 23h 59m 55s"). The floor moves to Job.elapsed_time, which is the value NetBox displays and covers the list, the detail panel, the script result view and runscript in one place. The stored execution_time is untouched, so the API and exports still surface the anomaly. Also renames the sub-second branch's variable, which held a value in seconds rather than milliseconds. Co-Authored-By: Claude Opus 5 --- netbox/utilities/string.py | 26 ++++++++++++++++-------- netbox/utilities/templatetags/helpers.py | 4 +++- netbox/utilities/tests/test_string.py | 18 ++++++++++++---- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/netbox/utilities/string.py b/netbox/utilities/string.py index 404c34f50..dd4ace10c 100644 --- a/netbox/utilities/string.py +++ b/netbox/utilities/string.py @@ -13,23 +13,26 @@ def humanize_duration(value): """ Express a timedelta in a human-friendly format. Example: 1h 5m 23s. Durations of a second or more are rounded to whole seconds; shorter durations are rounded to the millisecond (e.g. - 0.43s). Returns an empty string for None; zero and negative durations render as "0s". + 0.43s). A negative duration is rendered with a leading minus sign, so that an anomalous value + remains recognizable as one. Returns an empty string for None; zero renders as "0s". """ if value is None: return '' - # Negative durations (which can result from clock skew) are clamped to zero - total_seconds = max(value.total_seconds(), 0) + total_seconds = value.total_seconds() + magnitude = abs(total_seconds) # Render sub-second durations to the millisecond, as rounding them to whole seconds would # report every short-lived duration as zero. Trailing zeros are stripped. - if 0 < total_seconds < 1: - milliseconds = f'{total_seconds:.3f}'.rstrip('0').rstrip('.') - if milliseconds != '0': - return f'{milliseconds}s' + if 0 < magnitude < 1: + rendered = f'{magnitude:.3f}'.rstrip('0').rstrip('.') + # A magnitude below a millisecond has no representation here, so fall through to "0s". + # Rounding up to a whole second (e.g. 0.9996) likewise falls through, to "1s". + if rendered not in ('0', '1'): + return f'-{rendered}s' if total_seconds < 0 else f'{rendered}s' # Round to whole seconds and decompose - days, remainder = divmod(round(total_seconds), 86400) + days, remainder = divmod(round(magnitude), 86400) hours, remainder = divmod(remainder, 3600) minutes, seconds = divmod(remainder, 60) @@ -42,7 +45,12 @@ def humanize_duration(value): ret += f'{minutes}m ' if seconds or not ret: ret += f'{seconds}s' - return ret.strip() + ret = ret.strip() + + # Zero carries no sign, however the original value was signed + if total_seconds < 0 and ret != '0s': + ret = f'-{ret}' + return ret def enum_key(value): diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index d9fcad060..6ad608094 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -217,13 +217,15 @@ def _format_speed(speed, divisor, unit): def humanize_duration_filter(value): """ Express a timedelta in a human-friendly format, always using the largest appropriate units. - Sub-second durations are rendered with millisecond precision. + Sub-second durations are rendered with millisecond precision. A negative duration is rendered + with a leading minus sign rather than being suppressed. Examples: timedelta(seconds=90) => "1m 30s" timedelta(hours=1, minutes=5, seconds=23) => "1h 5m 23s" timedelta(milliseconds=430) => "0.43s" + timedelta(seconds=-5) => "-5s" """ return humanize_duration(value) diff --git a/netbox/utilities/tests/test_string.py b/netbox/utilities/tests/test_string.py index c789ef23f..66ef5b082 100644 --- a/netbox/utilities/tests/test_string.py +++ b/netbox/utilities/tests/test_string.py @@ -43,7 +43,17 @@ class HumanizeDurationTest(TestCase): self.assertEqual(humanize_duration(timedelta(seconds=1, milliseconds=100)), '1s') self.assertEqual(humanize_duration(timedelta(seconds=59, milliseconds=600)), '1m') - def test_negative_duration_clamped_to_zero(self): - # A negative duration (e.g. resulting from clock skew) never renders as negative. - self.assertEqual(humanize_duration(timedelta(seconds=-1.5)), '0s') - self.assertEqual(humanize_duration(timedelta(days=-2)), '0s') + def test_negative_duration_retains_sign(self): + # A negative duration is anomalous, so it is rendered as such rather than suppressed here. + # Callers which need a floor of zero (e.g. Job.elapsed_time) apply one themselves. + self.assertEqual(humanize_duration(timedelta(seconds=-5)), '-5s') + self.assertEqual(humanize_duration(timedelta(seconds=-1.5)), '-2s') + self.assertEqual(humanize_duration(timedelta(days=-2)), '-2d') + self.assertEqual(humanize_duration(timedelta(milliseconds=-430)), '-0.43s') + + def test_negative_duration_rounding_to_zero_carries_no_sign(self): + self.assertEqual(humanize_duration(timedelta(microseconds=-400)), '0s') + + def test_sub_second_rounding_up_to_one_second(self): + # A magnitude which rounds up to a whole second reads as "1s", not "1.0s" + self.assertEqual(humanize_duration(timedelta(seconds=0.9996)), '1s') From aed86db7e9eef7958e1fa0ead36f0eda3e598d0c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Aug 2026 13:16:16 -0400 Subject: [PATCH 21/22] Revert implementation of elapsed_time for running jobs --- docs/models/core/job.md | 8 +- netbox/core/models/jobs.py | 53 +------ netbox/core/tables/jobs.py | 37 +---- netbox/core/tests/test_models.py | 142 ------------------ netbox/core/tests/test_tables.py | 104 ++----------- netbox/core/tests/test_views.py | 36 +---- netbox/core/ui/panels.py | 6 +- .../extras/management/commands/runscript.py | 3 +- .../extras/tests/test_management_commands.py | 7 +- netbox/netbox/ui/attrs.py | 11 ++ .../core/job/attrs/elapsed_time.html | 8 - .../templates/extras/htmx/script_result.html | 10 +- netbox/utilities/templatetags/helpers.py | 19 --- netbox/utilities/tests/test_string.py | 4 +- 14 files changed, 44 insertions(+), 404 deletions(-) delete mode 100644 netbox/templates/core/job/attrs/elapsed_time.html diff --git a/docs/models/core/job.md b/docs/models/core/job.md index c3fe939a9..43ba0d660 100644 --- a/docs/models/core/job.md +++ b/docs/models/core/job.md @@ -30,13 +30,7 @@ The date and time at which the job completed (if complete). ### Execution Time -The amount of time the job spent executing, calculated as the difference between its start and completion times. This is populated only once a started job has completed; while a job is still running, NetBox displays the time elapsed since it started instead. - -!!! warning "The duration property is deprecated" - The job model's `duration` property, which returned a preformatted string such as `5 minutes, 3.00 seconds`, has been **deprecated** and is planned for removal in NetBox v5.0. Export templates and plugins should reference `elapsed_time` instead, which returns a duration rather than a string and which also reports progress for a job that is still running. - -!!! note "Filtering and sorting behave differently" - Filtering on execution time matches only the recorded value, so a job which is still running is never returned: it has no execution time yet. Sorting the jobs list by the **Execution Time** column instead orders by the value displayed, which for a running job is the time elapsed so far. A long-running job therefore appears near the top when sorting in descending order, but is excluded by a filter on the same attribute. Exports likewise carry only the recorded value, in seconds. +The amount of time the job spent executing, calculated as the difference between its start and completion times. This is populated only once a started job has completed. ### User diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 82830d51c..36e412c54 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -1,8 +1,6 @@ import logging import uuid -import warnings from dataclasses import asdict -from datetime import timedelta from functools import partial import django_rq @@ -13,8 +11,6 @@ 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 Case, ExpressionWrapper, F, When -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 _ @@ -188,17 +184,6 @@ class Job(models.Model): @property def duration(self): - """ - Deprecated: use `elapsed_time` instead, which reports a timedelta and also covers jobs which - are still running. Retained for the benefit of existing export templates and plugins. - """ - warnings.warn( - "Job.duration is deprecated and will be removed in NetBox v5.0; use Job.elapsed_time " - "instead.", - DeprecationWarning, - stacklevel=2, - ) - if not self.completed: return None @@ -210,43 +195,7 @@ class Job(models.Model): duration = self.completed - start_time minutes, seconds = divmod(duration.total_seconds(), 60) - return f'{int(minutes)} minutes, {seconds:.2f} seconds' - - @property - def elapsed_time(self): - """ - The job's recorded execution time, or the time elapsed so far if it is still running. - Returns None for a job which has not yet started. As this is the value NetBox displays, an - anomalous negative duration (which can result from clock skew) is clamped to zero; the - stored `execution_time` is left as recorded. - """ - if self.execution_time is not None: - elapsed = self.execution_time - elif self.started and not self.completed: - elapsed = timezone.now() - self.started - else: - return None - - return max(elapsed, timedelta()) - - @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, and for jobs which have - completed without recording an execution time. - """ - return Coalesce( - 'execution_time', - # Only a job which has yet to complete accrues elapsed time - Case( - When( - completed__isnull=True, - then=ExpressionWrapper(Now() - F('started'), output_field=models.DurationField()), - ), - output_field=models.DurationField(), - ), - ) + return f"{int(minutes)} minutes, {seconds:.2f} seconds" 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 e3a7c2b74..7d413d87e 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -1,5 +1,5 @@ import django_tables2 as tables -from django.utils.html import conditional_escape, format_html +from django.utils.html import conditional_escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -47,8 +47,6 @@ class JobTable(NetBoxTable): ) execution_time = tables.Column( verbose_name=_('Execution Time'), - # Render running jobs (which have no recorded execution time yet) rather than the placeholder - empty_values=(), ) queue_name = tables.Column( verbose_name=_('Queue'), @@ -73,34 +71,13 @@ class JobTable(NetBoxTable): def render_log_entries(self, value): return len(value) - def render_execution_time(self, record): - if (duration := record.elapsed_time) is None: - return self.default + def render_execution_time(self, value): + return humanize_duration(value) - value = humanize_duration(duration) - 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 - ) - - return value - - def value_execution_time(self, record): - # Export the recorded execution time verbatim, as a raw number of seconds. A running job's - # provisional elapsed time is deliberately omitted, as is the clamping of anomalous negative - # values applied when rendering: an export is intended for analysis. - if record.execution_time is None: - return None - return round(record.execution_time.total_seconds(), 3) - - def order_execution_time(self, queryset, is_descending): - # 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 + def value_execution_time(self, value): + # Export the recorded execution time as a raw number of seconds, rather than the humanized + # string, as an export is intended for analysis + return round(value.total_seconds(), 3) class JobLogEntryTable(BaseTable): diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index 2f8c21d6c..d4465c436 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -381,145 +381,3 @@ class JobTestCase(TestCase): job.terminate(status=JobStatusChoices.STATUS_COMPLETED) self.assertIsNone(job.execution_time) - - @patch('core.models.jobs.job_end') - def test_elapsed_time_returns_execution_time_once_completed(self, mock_job_end): - """ - For a completed job, elapsed_time should return the recorded execution_time. - """ - job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - job.started = timezone.now() - timedelta(seconds=90) - job.save() - - job.terminate(status=JobStatusChoices.STATUS_COMPLETED) - - self.assertEqual(job.elapsed_time, job.execution_time) - - def test_elapsed_time_of_running_job(self): - """ - A running job has no execution_time yet, so elapsed_time should report the time elapsed - since it started. - """ - job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - job.started = timezone.now() - timedelta(seconds=90) - job.save() - - self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING) - self.assertIsNone(job.execution_time) - self.assertGreaterEqual(job.elapsed_time, timedelta(seconds=90)) - self.assertLess(job.elapsed_time, timedelta(seconds=120)) - - def test_elapsed_time_none_when_never_started(self): - """ - A job which has not started has no elapsed time to report. - """ - job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - - self.assertIsNone(job.started) - self.assertIsNone(job.elapsed_time) - - def test_elapsed_time_clamps_negative_execution_time(self): - """ - elapsed_time is the value NetBox displays, so an anomalous negative duration (e.g. resulting - from clock skew) is floored at zero. The stored execution_time is left as recorded, so that - the anomaly remains visible to the API and to exports. - """ - job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - job.started = timezone.now() - job.completed = timezone.now() - job.execution_time = timedelta(seconds=-5) - job.status = JobStatusChoices.STATUS_COMPLETED - job.save() - - self.assertEqual(job.elapsed_time, timedelta()) - self.assertEqual(job.execution_time, timedelta(seconds=-5)) - - def test_duration_is_deprecated(self): - """ - Job.duration is retained for existing export templates and plugins, but must warn. - """ - job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - job.started = timezone.now() - timedelta(seconds=90) - job.completed = job.started + timedelta(seconds=90) - job.status = JobStatusChoices.STATUS_COMPLETED - job.save() - - with self.assertWarns(DeprecationWarning): - self.assertEqual(job.duration, '1 minutes, 30.00 seconds') - - def test_duration_falls_back_to_created(self): - """ - The deprecated property's original behavior must be preserved: a job which completed without - ever starting reports its duration relative to creation. - """ - job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - job.started = None - job.completed = job.created + timedelta(seconds=30) - job.status = JobStatusChoices.STATUS_ERRORED - job.save() - - with self.assertWarns(DeprecationWarning): - self.assertEqual(job.duration, '0 minutes, 30.00 seconds') - - def test_duration_none_when_not_completed(self): - job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - - with self.assertWarns(DeprecationWarning): - self.assertIsNone(job.duration) - - def test_elapsed_time_none_when_completed_without_execution_time(self): - """ - A job which completed without recording an execution time (e.g. one predating the field) - has no elapsed time to report; it must not accrue time indefinitely. - """ - job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - job.started = timezone.now() - timedelta(seconds=90) - job.completed = timezone.now() - job.status = JobStatusChoices.STATUS_COMPLETED - job.save() - - self.assertIsNone(job.execution_time) - self.assertIsNone(job.elapsed_time) - - def test_elapsed_time_expression_matches_property(self): - """ - The elapsed_time_expression() queryset expression should agree with the elapsed_time - property for completed, running, and never-started jobs. - """ - completed = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - completed.started = timezone.now() - timedelta(seconds=90) - completed.completed = timezone.now() - completed.execution_time = timedelta(seconds=90) - completed.status = JobStatusChoices.STATUS_COMPLETED - completed.save() - - # A job which completed without recording an execution time must resolve to null, rather - # than to an ever-growing interval since it started - unrecorded = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - unrecorded.started = timezone.now() - timedelta(seconds=90) - unrecorded.completed = timezone.now() - unrecorded.status = JobStatusChoices.STATUS_COMPLETED - unrecorded.save() - - running = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - running.started = timezone.now() - timedelta(minutes=5) - running.save() - - pending = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) - pending.status = JobStatusChoices.STATUS_PENDING - pending.save() - - annotated = { - job.pk: job - for job in Job.objects.annotate(elapsed=Job.elapsed_time_expression()) - } - - self.assertEqual(annotated[completed.pk].elapsed, timedelta(seconds=90)) - self.assertIsNone(annotated[unrecorded.pk].elapsed) - self.assertIsNone(annotated[pending.pk].elapsed) - # The running job's elapsed time is computed at query time, so compare approximately - self.assertAlmostEqual( - annotated[running.pk].elapsed.total_seconds(), - running.elapsed_time.total_seconds(), - delta=5, - ) diff --git a/netbox/core/tests/test_tables.py b/netbox/core/tests/test_tables.py index 91a1572d5..89ba6d193 100644 --- a/netbox/core/tests/test_tables.py +++ b/netbox/core/tests/test_tables.py @@ -24,7 +24,7 @@ class JobTableTestCase(TableTestCases.StandardTableTestCase): class JobExecutionTimeColumnTestCase(TestCase): """ - Test the rendering, export, and ordering behavior of JobTable's execution_time column. + Test the rendering and export behavior of JobTable's execution_time column. """ @classmethod def setUpTestData(cls): @@ -44,24 +44,16 @@ class JobExecutionTimeColumnTestCase(TestCase): started=now - timedelta(days=2, hours=3), completed=now, execution_time=timedelta(days=2, hours=3), ), - Job( - name='negative', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED, - started=now, completed=now, execution_time=timedelta(seconds=-5), - ), - Job( - name='unrecorded', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED, - started=now - timedelta(seconds=90), completed=now, - ), - Job( - name='running', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_RUNNING, - started=now - timedelta(minutes=5), - ), Job(name='pending', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_PENDING), )) - def _render(self, name): + def _table(self, name): table = JobTable(Job.objects.filter(name=name)) table.columns.show('execution_time') + return table + + def _render(self, name): + table = self._table(name) return str(next(iter(table.rows)).get_cell('execution_time')) def test_render_completed_job(self): @@ -72,92 +64,22 @@ class JobExecutionTimeColumnTestCase(TestCase): # Sub-second jobs report millisecond precision rather than reading as zero self.assertEqual(self._render('completed-subsecond'), '0.43s') - def test_render_negative_execution_time(self): - # A negative stored value (e.g. from clock skew) never renders as negative - self.assertEqual(self._render('negative'), '0s') - - def test_render_running_job(self): - """ - A running job has no recorded execution time, so the column shows the time elapsed so far, - visually distinguished from a completed job's final value. - """ - rendered = self._render('running') - self.assertIn('5m', rendered) - self.assertIn('text-primary', rendered) - - def test_render_job_never_started(self): - table = JobTable(Job.objects.filter(name='pending')) - table.columns.show('execution_time') - row = next(iter(table.rows)) - self.assertEqual(str(row.get_cell('execution_time')), table.default) - - def test_render_job_completed_without_execution_time(self): - # A completed job with no recorded execution time shows the placeholder, rather than - # accruing time since it started - table = JobTable(Job.objects.filter(name='unrecorded')) - table.columns.show('execution_time') - row = next(iter(table.rows)) - self.assertEqual(str(row.get_cell('execution_time')), table.default) + def test_render_job_without_execution_time(self): + table = self._table('pending') + self.assertEqual(str(next(iter(table.rows)).get_cell('execution_time')), table.default) def _export_value(self, name): - table = JobTable(Job.objects.filter(name=name)) - table.columns.show('execution_time') - rows = list(table.as_values()) + rows = list(self._table(name).as_values()) return rows[1][rows[0].index('Execution Time')] def test_export_value_is_raw_seconds(self): + # Exports carry the recorded duration in seconds, not the humanized string self.assertEqual(self._export_value('completed-90s'), 90.0) + self.assertEqual(self._export_value('completed-subsecond'), 0.43) - def test_export_value_of_job_never_started(self): + def test_export_value_of_job_without_execution_time(self): self.assertIsNone(self._export_value('pending')) - def test_export_value_of_running_job(self): - # Only the recorded execution time is exported; a running job has none yet - self.assertIsNone(self._export_value('running')) - - def test_export_value_is_not_clamped(self): - # An anomalous negative value is clamped when rendered, but exported verbatim so that it - # remains visible to analysis - self.assertEqual(self._export_value('negative'), -5.0) - - def test_ordering_matches_displayed_values(self): - """ - Sorting must order by the value the column displays — which for a running job is its elapsed - time, not a null — so that a long-running job is not buried. Jobs with no elapsed time to - display sort last in both directions, in pk order. - """ - # 'running' has been going 5 minutes, so it sorts between the 90s and 2d3h jobs - ascending = ['negative', 'completed-subsecond', 'completed-90s', 'running', 'completed-long'] - # Neither a job which never started nor one which completed without recording an execution - # time has a value to sort by - no_value = ['unrecorded', 'pending'] - - for descending, expected in ( - (False, ascending), - (True, list(reversed(ascending))), - ): - with self.subTest(descending=descending): - table = JobTable(Job.objects.all()) - queryset, modified = table.columns['execution_time'].order(Job.objects.all(), descending) - self.assertTrue(modified) - names = list(queryset.values_list('name', flat=True)) - self.assertEqual(names, expected + no_value) - - def test_ordering_breaks_ties_on_pk(self): - """ - Tied rows need a stable secondary sort, or paginating through them can skip or repeat rows. - """ - Job.objects.bulk_create( - Job(name=f'tied-{i}', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_PENDING) - for i in range(4) - ) - table = JobTable(Job.objects.all()) - queryset, _modified = table.columns['execution_time'].order(Job.objects.filter( - name__startswith='tied-' - ), True) - pks = list(queryset.values_list('pk', flat=True)) - self.assertEqual(pks, sorted(pks)) - class ObjectChangeTableTestCase(TableTestCases.StandardTableTestCase): table = ObjectChangeTable diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index a6479c090..ad89d002a 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -1,7 +1,7 @@ import json import urllib.parse import uuid -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -151,40 +151,6 @@ class JobTestCase( ] ) - def test_execution_time_on_detail_view(self): - """ - The job detail view must present execution time consistently with the jobs list: the recorded - value for a completed job, the elapsed time (visually distinguished) for a running one, and a - placeholder for a job which never started. - """ - self.add_permissions('core.view_job') - now = timezone.now() - - completed = Job.objects.get(name='Job 3') - completed.started = now - timedelta(seconds=90) - completed.completed = now - completed.execution_time = timedelta(seconds=90) - completed.save() - response = self.client.get(completed.get_absolute_url()) - self.assertHttpStatus(response, 200) - content = str(response.content) - self.assertIn('1m 30s', content) - # The panel must use the same label as the list column, filter form, and API field - self.assertIn('Execution Time', content) - - running = Job.objects.get(name='Job 2') - running.started = now - timedelta(hours=2) - running.save() - response = self.client.get(running.get_absolute_url()) - self.assertHttpStatus(response, 200) - content = str(response.content) - self.assertIn('2h', content) - self.assertIn('Still running', content) - - pending = Job.objects.get(name='Job 1') - response = self.client.get(pending.get_absolute_url()) - self.assertHttpStatus(response, 200) - class JobLogViewTestCase(TestCase): user_permissions = ( diff --git a/netbox/core/ui/panels.py b/netbox/core/ui/panels.py index 79d872b2a..13e21ef79 100644 --- a/netbox/core/ui/panels.py +++ b/netbox/core/ui/panels.py @@ -62,11 +62,7 @@ class JobSchedulingPanel(panels.ObjectAttributesPanel): scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html') started = attrs.DateTimeAttr('started') completed = attrs.DateTimeAttr('completed') - elapsed_time = attrs.TemplatedAttr( - 'elapsed_time', - label=_('Execution Time'), - template_name='core/job/attrs/elapsed_time.html', - ) + execution_time = attrs.DurationAttr('execution_time') queue = attrs.TextAttr('queue_name', label=_('Queue')) diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 60948058f..ef3a46fac 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -9,7 +9,6 @@ from extras.jobs import ScriptJob from extras.scripts import get_module_and_script from users.models import User from utilities.request import NetBoxFakeRequest -from utilities.string import humanize_duration class Command(BaseCommand): @@ -107,4 +106,4 @@ class Command(BaseCommand): commit=commit, ) - logger.info(f"Script completed in {humanize_duration(job.elapsed_time)}") + logger.info(f"Script completed in {job.duration}") diff --git a/netbox/extras/tests/test_management_commands.py b/netbox/extras/tests/test_management_commands.py index 968d48760..afc2f8d73 100644 --- a/netbox/extras/tests/test_management_commands.py +++ b/netbox/extras/tests/test_management_commands.py @@ -1,4 +1,3 @@ -from datetime import timedelta from io import BytesIO, StringIO from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -274,7 +273,7 @@ class RunScriptTestCase(TestCase): return form script_obj = SimpleNamespace(python_class=TestScript) - job = SimpleNamespace(elapsed_time=timedelta(0)) + job = SimpleNamespace(duration='0 seconds') with ( patch( @@ -359,7 +358,7 @@ class RunScriptTestCase(TestCase): return form script_obj = SimpleNamespace(python_class=TestScript) - job = SimpleNamespace(elapsed_time=timedelta(0)) + job = SimpleNamespace(duration='0 seconds') with ( patch( @@ -399,7 +398,7 @@ class RunScriptTestCase(TestCase): return form script_obj = SimpleNamespace(python_class=TestScript) - job = SimpleNamespace(elapsed_time=timedelta(0)) + job = SimpleNamespace(duration='0 seconds') with ( patch( diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 93811bce5..bb4959466 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.config import get_config from netbox.ui.utils import build_coords_url, is_coordinate_map_url from utilities.data import resolve_attr_path +from utilities.string import humanize_duration __all__ = ( 'AddressAttr', @@ -15,6 +16,7 @@ __all__ = ( 'DateTimeAttr', 'DiameterAttr', 'DistanceAttr', + 'DurationAttr', 'FlowRateAttr', 'GPSCoordinatesAttr', 'GenericForeignKeyAttr', @@ -564,6 +566,15 @@ class TimezoneAttr(ObjectAttribute): template_name = 'ui/attrs/timezone.html' +class DurationAttr(TextAttr): + """ + A duration (timedelta) value, rendered in a human-friendly format (e.g. 1h 5m 23s). + """ + def get_value(self, obj): + value = resolve_attr_path(obj, self.accessor) + return humanize_duration(value) or None + + class TemplatedAttr(ObjectAttribute): """ Renders an attribute using a custom template. diff --git a/netbox/templates/core/job/attrs/elapsed_time.html b/netbox/templates/core/job/attrs/elapsed_time.html deleted file mode 100644 index 742d903ba..000000000 --- a/netbox/templates/core/job/attrs/elapsed_time.html +++ /dev/null @@ -1,8 +0,0 @@ -{% load helpers %} -{% load i18n %} -{% if object.completed %} - {{ value|humanize_duration }} -{% else %} - {# The job is still running, so its elapsed time is provisional #} - {{ value|humanize_duration }} -{% endif %} diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index fcd2319c5..3844c7857 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -11,13 +11,9 @@ {% else %} {% trans "Created" %}: {{ job.created|isodatetime }} {% endif %} - {# For a running job this reflects the time elapsed so far; the container refreshes every 5s #} - {% with execution_time=job.elapsed_time|humanize_duration %} - {% if execution_time %} - {% trans "Execution time" %}: - {{ execution_time }} - {% endif %} - {% endwith %} + {% if job.completed %} + {% trans "Duration" %}: {{ job.duration }} + {% endif %} {% badge job.get_status_display job.get_status_color %}

{% if job.completed %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 6ad608094..9e7d22034 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -17,7 +17,6 @@ from netbox.ui.attrs import ( ) from utilities.forms import TableConfigForm, get_selected_values from utilities.forms.mixins import FORM_FIELD_LOOKUPS -from utilities.string import humanize_duration from utilities.views import get_action_url, get_viewname __all__ = ( @@ -32,7 +31,6 @@ __all__ = ( 'get_item', 'get_key', 'humanize_disk_capacity', - 'humanize_duration_filter', 'humanize_ram_capacity', 'humanize_speed', 'icon_from_status', @@ -213,23 +211,6 @@ def _format_speed(speed, divisor, unit): return f'{whole}.{fraction} {unit}' -@register.filter('humanize_duration') -def humanize_duration_filter(value): - """ - Express a timedelta in a human-friendly format, always using the largest appropriate units. - Sub-second durations are rendered with millisecond precision. A negative duration is rendered - with a leading minus sign rather than being suppressed. - - Examples: - - timedelta(seconds=90) => "1m 30s" - timedelta(hours=1, minutes=5, seconds=23) => "1h 5m 23s" - timedelta(milliseconds=430) => "0.43s" - timedelta(seconds=-5) => "-5s" - """ - return humanize_duration(value) - - @register.filter() def humanize_speed(speed): """ diff --git a/netbox/utilities/tests/test_string.py b/netbox/utilities/tests/test_string.py index 66ef5b082..ea45b01f6 100644 --- a/netbox/utilities/tests/test_string.py +++ b/netbox/utilities/tests/test_string.py @@ -44,8 +44,8 @@ class HumanizeDurationTest(TestCase): self.assertEqual(humanize_duration(timedelta(seconds=59, milliseconds=600)), '1m') def test_negative_duration_retains_sign(self): - # A negative duration is anomalous, so it is rendered as such rather than suppressed here. - # Callers which need a floor of zero (e.g. Job.elapsed_time) apply one themselves. + # A negative duration is anomalous (e.g. resulting from clock skew), so it is rendered as + # such rather than decomposed into a nonsensical value. self.assertEqual(humanize_duration(timedelta(seconds=-5)), '-5s') self.assertEqual(humanize_duration(timedelta(seconds=-1.5)), '-2s') self.assertEqual(humanize_duration(timedelta(days=-2)), '-2d') From d924937ef14d66c6312dbbb2e396ea85f811dd61 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Aug 2026 15:14:26 -0400 Subject: [PATCH 22/22] Keep completed as a default column --- netbox/core/tables/jobs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index 7d413d87e..191835908 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -65,7 +65,8 @@ class JobTable(NetBoxTable): 'completed', 'execution_time', 'user', 'queue_name', 'log_entries', 'error', 'job_id', ) default_columns = ( - 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'execution_time', 'user', + 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'execution_time', + 'user', ) def render_log_entries(self, value):