Merge pull request #22524 from netbox-community/22441-exec-time-jobs-table

Closes #22441: Add execution_time to background jobs
This commit is contained in:
bctiemann 2026-06-24 18:54:33 -04:00 committed by GitHub
commit 4daa1a0165
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 201 additions and 5 deletions

View File

@ -28,6 +28,10 @@ The interval (in minutes) at which a scheduled job should re-execute.
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.
### User
The user who created the job.

View File

@ -32,8 +32,8 @@ class JobSerializer(BaseModelSerializer):
model = Job
fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
'notifications', 'log_entries',
'scheduled', 'interval', 'started', 'completed', 'execution_time', 'user', 'data', 'error', 'job_id',
'queue_name', 'notifications', 'log_entries',
]
brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

@ -132,6 +132,19 @@ class JobFilterSet(BaseFilterSet):
field_name='completed',
lookup_expr='gte'
)
execution_time = django_filters.DurationFilter(
label=_('Execution time')
)
execution_time__gte = django_filters.DurationFilter(
field_name='execution_time',
lookup_expr='gte',
label=_('Execution time (minimum)')
)
execution_time__lte = django_filters.DurationFilter(
field_name='execution_time',
lookup_expr='lte',
label=_('Execution time (maximum)')
)
status = django_filters.MultipleChoiceFilter(
choices=JobStatusChoices,
distinct=False,
@ -160,7 +173,7 @@ class JobFilterSet(BaseFilterSet):
model = Job
fields = (
'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
'queue_name',
'queue_name', 'execution_time',
)
def search(self, queryset, name, value):

View File

@ -80,6 +80,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
),
FieldSet('execution_time__gte', 'execution_time__lte', name=_('Execution')),
)
object_type_id = ContentTypeChoiceField(
label=_('Object Type'),
@ -140,6 +141,16 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('User')
)
execution_time__gte = forms.DurationField(
label=_('Execution time (minimum)'),
required=False,
help_text=_('Seconds, or HH:MM:SS')
)
execution_time__lte = forms.DurationField(
label=_('Execution time (maximum)'),
required=False,
help_text=_('Seconds, or HH:MM:SS')
)
class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_job_notifications"),
]
operations = [
migrations.AddField(
model_name="job",
name="execution_time",
field=models.DurationField(blank=True, editable=False, null=True),
),
]

View File

@ -84,6 +84,12 @@ class Job(models.Model):
null=True,
blank=True
)
execution_time = models.DurationField(
verbose_name=_('execution time'),
null=True,
blank=True,
editable=False
)
user = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
@ -241,6 +247,8 @@ class Job(models.Model):
if error:
self.error = error
self.completed = timezone.now()
if self.started:
self.execution_time = self.completed - self.started
self.save()
# Notify the user (if any) of completion

View File

@ -7,6 +7,7 @@ from core.constants import JOB_LOG_ENTRY_LEVELS
from core.models import Job
from core.tables.columns import BadgeColumn
from netbox.tables import BaseTable, NetBoxTable, columns
from utilities.string import humanize_duration
class JobTable(NetBoxTable):
@ -44,6 +45,9 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn(
verbose_name=_('Completed'),
)
execution_time = tables.Column(
verbose_name=_('Execution Time'),
)
queue_name = tables.Column(
verbose_name=_('Queue'),
)
@ -58,7 +62,7 @@ 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',
'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id', 'execution_time'
)
default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
@ -67,6 +71,9 @@ class JobTable(NetBoxTable):
def render_log_entries(self, value):
return len(value)
def render_execution_time(self, value):
return humanize_duration(value)
class JobLogEntryTable(BaseTable):
timestamp = columns.DateTimeColumn(

View File

@ -206,10 +206,26 @@ class JobTestCase(
status='completed',
queue_name='default',
job_id=uuid.uuid4(),
execution_time=timezone.timedelta(seconds=90),
),
]
)
def test_list_objects_by_execution_time(self):
"""The Job list endpoint supports filtering and ordering by execution_time."""
self.add_permissions('core.view_job')
url = reverse('core-api:job-list')
# Filter: only the completed job has a (90s) execution_time
response = self.client.get(f'{url}?execution_time__gte=60', **self.header)
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)
response = self.client.get(f'{url}?ordering=execution_time', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 3)
class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
user_permissions = ()

View File

@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
@ -262,14 +262,17 @@ class JobTestCase(TestCase, BaseFilterSetTests):
Job(
name='Job 1', job_id=uuid.uuid4(), user=users[0],
notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
execution_time=timedelta(seconds=30),
),
Job(
name='Job 2', job_id=uuid.uuid4(), user=users[0],
notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
execution_time=timedelta(seconds=60),
),
Job(
name='Job 3', job_id=uuid.uuid4(), user=users[1],
notifications=JobNotificationChoices.NOTIFICATION_ON_FAILURE,
execution_time=timedelta(seconds=120),
),
Job(
name='Job 4', job_id=uuid.uuid4(), user=users[2],
@ -293,6 +296,15 @@ class JobTestCase(TestCase, BaseFilterSetTests):
]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_execution_time(self):
"""Filter Jobs by execution time (exact value and gte/lte range)."""
params = {'execution_time': timedelta(seconds=60)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'execution_time__gte': timedelta(seconds=60)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'execution_time__lte': timedelta(seconds=60)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectType.objects.all()

View File

@ -1,9 +1,11 @@
import uuid
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase
from django.utils import timezone
from core.choices import JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices
from core.models import DataSource, Job, ObjectType
@ -344,3 +346,38 @@ class JobTestCase(TestCase):
0,
msg=f"Expected no notification for status={status} with notifications=never",
)
@patch('core.models.jobs.job_end')
def test_execution_time_set_on_terminate(self, mock_job_end):
"""
terminate() should set execution_time to (completed - started) for a started job.
"""
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
job.started = timezone.now() - timedelta(seconds=90)
job.save()
job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
self.assertIsNotNone(job.execution_time)
self.assertEqual(job.execution_time, job.completed - job.started)
@patch('core.models.jobs.job_end')
def test_execution_time_none_before_completion(self, mock_job_end):
"""
A job which has not completed should have a null execution_time.
"""
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
self.assertIsNone(job.execution_time)
@patch('core.models.jobs.job_end')
def test_execution_time_none_when_never_started(self, mock_job_end):
"""
Terminating a job which was never started (no started timestamp) should leave
execution_time null rather than computing a value from a missing start time.
"""
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
self.assertIsNone(job.started)
job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
self.assertIsNone(job.execution_time)

View File

@ -62,6 +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')
queue = attrs.TextAttr('queue_name', label=_('Queue'))

View File

@ -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',
@ -14,6 +15,7 @@ __all__ = (
'ColorAttr',
'DateTimeAttr',
'DistanceAttr',
'DurationAttr',
'GPSCoordinatesAttr',
'GenericForeignKeyAttr',
'ImageAttr',
@ -562,6 +564,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.

View File

@ -2,12 +2,39 @@ import re
__all__ = (
'enum_key',
'humanize_duration',
'remove_linebreaks',
'title',
'trailing_slash',
)
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".
"""
if value is None:
return ''
# Round to whole seconds and decompose
total_seconds = int(value.total_seconds())
days, remainder = divmod(total_seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
ret = ''
if days:
ret += f'{days}d '
if hours:
ret += f'{hours}h '
if minutes:
ret += f'{minutes}m '
if seconds or not ret:
ret += f'{seconds}s'
return ret.strip()
def enum_key(value):
"""
Convert the given value to a string suitable for use as an Enum key.

View File

@ -0,0 +1,33 @@
from datetime import timedelta
from django.test import TestCase
from utilities.string import humanize_duration
class HumanizeDurationTest(TestCase):
def test_none(self):
self.assertEqual(humanize_duration(None), '')
def test_zero_duration(self):
self.assertEqual(humanize_duration(timedelta(0)), '0s')
def test_seconds_only(self):
self.assertEqual(humanize_duration(timedelta(seconds=45)), '45s')
def test_minutes_and_seconds(self):
self.assertEqual(humanize_duration(timedelta(minutes=5, seconds=23)), '5m 23s')
def test_hours_minutes_seconds(self):
self.assertEqual(humanize_duration(timedelta(hours=1, minutes=5, seconds=23)), '1h 5m 23s')
def test_days(self):
self.assertEqual(humanize_duration(timedelta(days=2, hours=3, minutes=17)), '2d 3h 17m')
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')