Pārlūkot izejas kodu

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

Closes #22441: Add execution_time to background jobs
bctiemann 1 nedēļu atpakaļ
vecāks
revīzija
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).
 
+### 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.

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

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

+ 14 - 1
netbox/core/filtersets.py

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

+ 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',
             '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):

+ 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,
         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

+ 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.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(

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

@@ -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 = ()

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

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

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

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

+ 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')
     started = attrs.DateTimeAttr('started')
     completed = attrs.DateTimeAttr('completed')
+    execution_time = attrs.DurationAttr('execution_time')
     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.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.

+ 27 - 0
netbox/utilities/string.py

@@ -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.

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