From cd7fdf22679077d18673543dcd881fad9cda7e6e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 14:57:14 -0400 Subject: [PATCH] Display the duration for running scripts --- .../templates/extras/htmx/script_result.html | 10 +++++++--- netbox/utilities/string.py | 18 +++++++++++++---- netbox/utilities/templatetags/helpers.py | 17 ++++++++++++++++ netbox/utilities/tests/test_string.py | 20 ++++++++++++++++--- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index 3844c7857..fcd2319c5 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -11,9 +11,13 @@ {% else %} {% trans "Created" %}: {{ job.created|isodatetime }} {% endif %} - {% if job.completed %} - {% trans "Duration" %}: {{ job.duration }} - {% 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" %}: + {{ execution_time }} + {% endif %} + {% endwith %} {% badge job.get_status_display job.get_status_color %}

{% if job.completed %} diff --git a/netbox/utilities/string.py b/netbox/utilities/string.py index f19e1d313..75769993b 100644 --- a/netbox/utilities/string.py +++ b/netbox/utilities/string.py @@ -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) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 9e7d22034..d9fcad060 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -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): """ diff --git a/netbox/utilities/tests/test_string.py b/netbox/utilities/tests/test_string.py index 620ee730d..2a377ec98 100644 --- a/netbox/utilities/tests/test_string.py +++ b/netbox/utilities/tests/test_string.py @@ -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')