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):