Display the duration for running scripts

This commit is contained in:
Jeremy Stretch 2026-08-05 14:57:14 -04:00
parent 736fd38e08
commit cd7fdf2267
4 changed files with 55 additions and 10 deletions

View File

@ -11,9 +11,13 @@
{% else %}
{% trans "Created" %}: <strong>{{ job.created|isodatetime }}</strong>
{% endif %}
{% if job.completed %}
{% trans "Duration" %}: <strong>{{ job.duration }}</strong>
{% 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" %}:
<strong{% if not job.completed %} class="text-primary" title="{% trans "Still running" %}"{% endif %}>{{ execution_time }}</strong>
{% endif %}
{% endwith %}
<span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
</p>
{% if job.completed %}

View File

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

View File

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

View File

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