浏览代码

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

Closes #22441: Add execution_time to background jobs
bctiemann 1 周之前
父节点
当前提交
4daa1a0165

+ 4 - 0
docs/models/core/job.md

@@ -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).
 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
 ### User
 
 
 The user who created the job.
 The user who created the job.

+ 2 - 2
netbox/core/api/serializers_/jobs.py

@@ -32,8 +32,8 @@ class JobSerializer(BaseModelSerializer):
         model = Job
         model = Job
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
             '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')
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
 
 

+ 14 - 1
netbox/core/filtersets.py

@@ -132,6 +132,19 @@ class JobFilterSet(BaseFilterSet):
         field_name='completed',
         field_name='completed',
         lookup_expr='gte'
         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(
     status = django_filters.MultipleChoiceFilter(
         choices=JobStatusChoices,
         choices=JobStatusChoices,
         distinct=False,
         distinct=False,
@@ -160,7 +173,7 @@ class JobFilterSet(BaseFilterSet):
         model = Job
         model = Job
         fields = (
         fields = (
             'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
             '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):
     def search(self, queryset, name, value):

+ 11 - 0
netbox/core/forms/filtersets.py

@@ -80,6 +80,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
             'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
             'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
             'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
             'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
         ),
         ),
+        FieldSet('execution_time__gte', 'execution_time__lte', name=_('Execution')),
     )
     )
     object_type_id = ContentTypeChoiceField(
     object_type_id = ContentTypeChoiceField(
         label=_('Object Type'),
         label=_('Object Type'),
@@ -140,6 +141,16 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         required=False,
         label=_('User')
         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):
 class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):

+ 16 - 0
netbox/core/migrations/0025_add_job_execution_time.py

@@ -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),
+        ),
+    ]

+ 8 - 0
netbox/core/models/jobs.py

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

+ 8 - 1
netbox/core/tables/jobs.py

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

+ 16 - 0
netbox/core/tests/test_api.py

@@ -206,10 +206,26 @@ class JobTestCase(
                     status='completed',
                     status='completed',
                     queue_name='default',
                     queue_name='default',
                     job_id=uuid.uuid4(),
                     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):
 class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
     user_permissions = ()
     user_permissions = ()

+ 13 - 1
netbox/core/tests/test_filtersets.py

@@ -1,5 +1,5 @@
 import uuid
 import uuid
-from datetime import UTC, datetime
+from datetime import UTC, datetime, timedelta
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
@@ -262,14 +262,17 @@ class JobTestCase(TestCase, BaseFilterSetTests):
             Job(
             Job(
                 name='Job 1', job_id=uuid.uuid4(), user=users[0],
                 name='Job 1', job_id=uuid.uuid4(), user=users[0],
                 notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
                 notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
+                execution_time=timedelta(seconds=30),
             ),
             ),
             Job(
             Job(
                 name='Job 2', job_id=uuid.uuid4(), user=users[0],
                 name='Job 2', job_id=uuid.uuid4(), user=users[0],
                 notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
                 notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
+                execution_time=timedelta(seconds=60),
             ),
             ),
             Job(
             Job(
                 name='Job 3', job_id=uuid.uuid4(), user=users[1],
                 name='Job 3', job_id=uuid.uuid4(), user=users[1],
                 notifications=JobNotificationChoices.NOTIFICATION_ON_FAILURE,
                 notifications=JobNotificationChoices.NOTIFICATION_ON_FAILURE,
+                execution_time=timedelta(seconds=120),
             ),
             ),
             Job(
             Job(
                 name='Job 4', job_id=uuid.uuid4(), user=users[2],
                 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)
         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):
 class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectType.objects.all()
     queryset = ObjectType.objects.all()

+ 37 - 0
netbox/core/tests/test_models.py

@@ -1,9 +1,11 @@
 import uuid
 import uuid
+from datetime import timedelta
 from unittest.mock import MagicMock, patch
 from unittest.mock import MagicMock, patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.test import TestCase
 from django.test import TestCase
+from django.utils import timezone
 
 
 from core.choices import JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices
 from core.choices import JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices
 from core.models import DataSource, Job, ObjectType
 from core.models import DataSource, Job, ObjectType
@@ -344,3 +346,38 @@ class JobTestCase(TestCase):
                     0,
                     0,
                     msg=f"Expected no notification for status={status} with notifications=never",
                     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)

+ 1 - 0
netbox/core/ui/panels.py

@@ -62,6 +62,7 @@ class JobSchedulingPanel(panels.ObjectAttributesPanel):
     scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html')
     scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html')
     started = attrs.DateTimeAttr('started')
     started = attrs.DateTimeAttr('started')
     completed = attrs.DateTimeAttr('completed')
     completed = attrs.DateTimeAttr('completed')
+    execution_time = attrs.DurationAttr('execution_time')
     queue = attrs.TextAttr('queue_name', label=_('Queue'))
     queue = attrs.TextAttr('queue_name', label=_('Queue'))
 
 
 
 

+ 11 - 0
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.config import get_config
 from netbox.ui.utils import build_coords_url, is_coordinate_map_url
 from netbox.ui.utils import build_coords_url, is_coordinate_map_url
 from utilities.data import resolve_attr_path
 from utilities.data import resolve_attr_path
+from utilities.string import humanize_duration
 
 
 __all__ = (
 __all__ = (
     'AddressAttr',
     'AddressAttr',
@@ -14,6 +15,7 @@ __all__ = (
     'ColorAttr',
     'ColorAttr',
     'DateTimeAttr',
     'DateTimeAttr',
     'DistanceAttr',
     'DistanceAttr',
+    'DurationAttr',
     'GPSCoordinatesAttr',
     'GPSCoordinatesAttr',
     'GenericForeignKeyAttr',
     'GenericForeignKeyAttr',
     'ImageAttr',
     'ImageAttr',
@@ -562,6 +564,15 @@ class TimezoneAttr(ObjectAttribute):
     template_name = 'ui/attrs/timezone.html'
     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):
 class TemplatedAttr(ObjectAttribute):
     """
     """
     Renders an attribute using a custom template.
     Renders an attribute using a custom template.

+ 27 - 0
netbox/utilities/string.py

@@ -2,12 +2,39 @@ import re
 
 
 __all__ = (
 __all__ = (
     'enum_key',
     'enum_key',
+    'humanize_duration',
     'remove_linebreaks',
     'remove_linebreaks',
     'title',
     'title',
     'trailing_slash',
     '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):
 def enum_key(value):
     """
     """
     Convert the given value to a string suitable for use as an Enum key.
     Convert the given value to a string suitable for use as an Enum key.

+ 33 - 0
netbox/utilities/tests/test_string.py

@@ -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')