Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions changelog.d/1941.added.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
Added a way to to store expected heartbeat frequency (in seconds, minimum 60)
to a source system, and made the new field visible in the admin.
Added a way to to store expected heartbeat frequency (as a Postgres interval,
minimum 60 seconds) to a source system, and made the new field visible in the
admin.
1 change: 1 addition & 0 deletions changelog.d/1962.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Show `last_seen` and `heartbeat_frequency` in the source list and allow altering `heartbeat_frequency`.
4 changes: 3 additions & 1 deletion src/argus/htmx/sourcesystem/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ class Meta(AddSourceSystemForm.Meta):
widgets = {
"name": forms.TextInput,
"base_url": forms.TextInput,
"heartbeat_frequency": forms.TextInput(attrs={"placeholder": "00:01:00"}),
}


class UpdateSourceSystemForm(forms.ModelForm):
class Meta:
model = SourceSystem
fields = ["name", "type", "base_url"]
fields = ["name", "type", "base_url", "heartbeat_frequency"]
widgets = {
"name": forms.TextInput,
"base_url": forms.TextInput,
"heartbeat_frequency": forms.TextInput(attrs={"placeholder": "00:01:00"}),
}


Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% load argus_htmx %}
<tbody id="source-{{ source.pk }}"
class="[&:last-child_tr:last-child_td]:border-b-0">
<tr>
Expand All @@ -8,6 +9,12 @@
<td class="{% if not generated_token %}border-b border-base-300{% endif %} max-w-xs truncate">
{{ source.base_url|default:"-" }}
</td>
<td class="{% if not generated_token %}border-b border-base-300{% endif %} max-w-xs truncate">
{{ source.last_seen|default:"Never" }}
</td>
<td class="{% if not generated_token %}border-b border-base-300{% endif %} max-w-xs truncate">
{{ source.heartbeat_frequency|pretty_timedelta:"Off" }}
</td>
<td class="{% if not generated_token %}border-b border-base-300{% endif %}">
{% if source.token_status == "valid" %}
<span class="badge badge-outline badge-success text-xs gap-1">
Expand Down
60 changes: 60 additions & 0 deletions src/argus/htmx/templates/htmx/sourcesystem/sourcesystem_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,66 @@ <h2 class="card-title">
</div>
{% endif %}
</div>
<div class="form-control w-full">
<label class="label" for="{{ form.heartbeat_frequency.auto_id }}">
<span class="label-text"><em>Heartbeat frequency</em></span>
</label>
{% render_field form.heartbeat_frequency class+="input input-bordered w-full" %}
{% if form.heartbeat_frequency.help_text %}
<div class="label">
<div class="label-text-alt">
<p>Expected to send heartbeat at least every N seconds. Lower bound: 60 seconds. Upper bound: 1 day.</p>
<table class="table table-sm">
<thead>
<tr>
<th scope="col">Valid inputs</th>
<th scope="col">Short</th>
<th scope="col">Long</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">seconds as an integer</th>
<td>1000</td>
<td>86400</td>
<td></td>
</tr>
<tr>
<th scope="row">DD HH:MM:SS</th>
<td>00:16:40</td>
<td>1 00:00:00</td>
<td>This is the format that is displayed in the form</td>
</tr>
<tr>
<th scope="row">
<a href="https://en.wikipedia.org/wiki/ISO_8601#Durations">ISO 8601 periods</a>
</th>
<td>PT16M40S</td>
<td>P1D</td>
<td>The T is needed if less than a day.</td>
</tr>
<tr>
<th scope="row">
<a href="https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-INTERVAL-INPUT">PostgreSQL’s day-time interval format</a>
</th>
<td>00:16:40</td>
<td>1 day 00:00:00</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
{% endif %}
{% if form.heartbeat_frequency.errors %}
<div class="label">
{% for error in form.heartbeat_frequency.errors %}
<span class="label-text-alt text-error">{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="card-actions justify-end mt-6">
<button type="submit" class="btn btn-primary">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<th class="border-b border-primary">Name</th>
<th class="border-b border-primary">Type</th>
<th class="border-b border-primary">Base URL</th>
<th class="border-b border-primary">Last seen</th>
<th class="border-b border-primary">Heartbeat</th>
<th class="border-b border-primary">Token</th>
<th class="border-b border-primary text-right">Incidents</th>
<th class="border-b border-primary text-right">Actions</th>
Expand Down
19 changes: 19 additions & 0 deletions src/argus/htmx/templatetags/argus_htmx.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from datetime import timedelta
from typing import Literal

from django import template
from django.contrib.messages.storage.base import Message
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.utils.timesince import timesince, timeuntil
from django.utils.timezone import now as tznow

from .. import defaults

Expand Down Expand Up @@ -99,3 +103,18 @@ def is_valid_url(value: str) -> bool:
return True
except ValidationError:
return False


@register.filter
def pretty_timedelta(value: timedelta, fallback: str = "") -> str:
Comment thread
hmpf marked this conversation as resolved.
'''Humanize a timedelta with the same alogrithm as "timesince"'''

if value is None:
return fallback
now = tznow()
then = now + value
if now > then:
return timesince(now, then)
if now < then:
return timeuntil(then, now)
return "0\xa0minutes"
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Generated by Django 5.2.13 on 2026-06-15 11:46
# Generated by Django 5.2.13 on 2026-06-22 12:05

import datetime
import django.core.validators
from django.db import migrations, models

Expand All @@ -14,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='sourcesystem',
name='heartbeat_frequency',
field=models.IntegerField(blank=True, help_text='Expected to send heartbeat at least every N seconds, lower bound: 60 seconds', null=True, validators=[django.core.validators.MinValueValidator(60)]),
field=models.DurationField(blank=True, help_text="Expected to send heartbeat at least every N seconds. Lower bound: 60 seconds. Upper bound: 1 day. Valid inputs: seconds (as an integer, e.g. 86400), DD HH:MM:SS (1 00:00:00), ISO 8601 periods (P1D, note the T is needed if less than a day), PostgreSQL's day-time interval format (1 day)", null=True, validators=[django.core.validators.MinValueValidator(datetime.timedelta(seconds=60)), django.core.validators.MaxValueValidator(datetime.timedelta(days=1))]),
),
]
15 changes: 10 additions & 5 deletions src/argus/incident/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator, MinValueValidator
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
from django.db import models
from django.db.models import F, Q
from django.utils import timezone
Expand All @@ -18,9 +18,11 @@
from argus.util.datetime_utils import INFINITY_REPR, get_infinity_repr
from .constants import Level
from .fields import DateTimeInfinityField
from .validators import validate_lowercase, validate_key
from .validators import validate_key, validate_lowercase


MINIMUM_DURATION = timedelta(seconds=60)
MAXIMUM_DURATION = timedelta(days=1)
LOG = logging.getLogger(__name__)
User = get_user_model()

Expand Down Expand Up @@ -165,11 +167,14 @@ class SourceSystem(models.Model):
blank=True,
)
last_seen = models.DateTimeField(null=True, blank=True)
heartbeat_frequency = models.IntegerField(
heartbeat_frequency = models.DurationField(
blank=True,
null=True,
help_text="Expected to send heartbeat at least every N seconds, lower bound: 60 seconds",
validators=[MinValueValidator(60)],
Comment thread
hmpf marked this conversation as resolved.
help_text="Expected to send heartbeat at least every N seconds. Lower bound: 60 seconds. Upper bound: 1 day. Valid inputs: seconds (as an integer, e.g. 86400), DD HH:MM:SS (1 00:00:00), ISO 8601 periods (P1D, note the T is needed if less than a day), PostgreSQL's day-time interval format (1 day)",
validators=[
MinValueValidator(MINIMUM_DURATION),
MaxValueValidator(MAXIMUM_DURATION),
],
)

class Meta:
Expand Down
30 changes: 29 additions & 1 deletion tests/htmx/test_templatetags.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from unittest import TestCase
from datetime import timedelta

from django.test import tag

from argus.htmx.templatetags.argus_htmx import dictvalue
from argus.htmx.templatetags.argus_htmx import dictvalue, pretty_timedelta


@tag("unit")
Expand All @@ -18,3 +19,30 @@ def test_get_value_from_dict_when_key_is_missing_returns_None(self):
def test_get_value_from_dict_when_key_is_missing_and_default_is_set_returns_default(self):
testdict = {}
self.assertEqual(dictvalue(testdict, 1, "boo"), "boo")


@tag("unit")
class PrettyTimedeltaTest(TestCase):
def test_pretty_timedelta_when_value_is_None_return_fallback(self):
result = pretty_timedelta(None, "foo")
self.assertEqual(result, "foo")

def test_when_value_is_zero_return_constant_string(self):
result = pretty_timedelta(timedelta(seconds=0))
self.assertEqual(result, "0\xa0minutes")

def test_when_value_is_positive_return_calculated_string(self):
result = pretty_timedelta(timedelta(seconds=1))
self.assertEqual(result, "0\xa0minutes")
result = pretty_timedelta(timedelta(seconds=10))
self.assertEqual(result, "0\xa0minutes")
result = pretty_timedelta(timedelta(seconds=100000))
self.assertEqual(result, "1\xa0day, 3\xa0hours")

def test_when_value_is_negative_return_0_minutes(self):
result = pretty_timedelta(timedelta(seconds=-1))
self.assertEqual(result, "0\xa0minutes")
result = pretty_timedelta(timedelta(seconds=-10))
self.assertEqual(result, "0\xa0minutes")
result = pretty_timedelta(timedelta(seconds=-100000))
self.assertEqual(result, "0\xa0minutes")
Loading