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:
commit
4daa1a0165
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = ()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
Loading…
Reference in New Issue