mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-04 12:55:25 +00:00
Add support for systemd's OnCalendar schedules
(work-in-progress) cc: #919
This commit is contained in:
parent
5daa13a57f
commit
d65f41d192
16 changed files with 325 additions and 13 deletions
|
@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Update Twilio integrations to disable channel on "Invalid 'To' Phone Number"
|
||||
- Update the Signal integration to disable channel on UNREGISTERED_FAILURE
|
||||
- Upgrade to Django 5.0
|
||||
- Add support for systemd's OnCalendar schedules (#919)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix "Ping Details" dialog to handle email bodies not yet uploaded to object storage
|
||||
|
|
|
@ -23,6 +23,7 @@ from django.http import HttpRequest
|
|||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from oncalendar import OnCalendar
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from hc.accounts.models import Project
|
||||
|
@ -35,7 +36,7 @@ STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New"), ("paused", "Paused")
|
|||
DEFAULT_TIMEOUT = td(days=1)
|
||||
DEFAULT_GRACE = td(hours=1)
|
||||
NEVER = datetime(3000, 1, 1, tzinfo=timezone.utc)
|
||||
CHECK_KINDS = (("simple", "Simple"), ("cron", "Cron"))
|
||||
CHECK_KINDS = (("simple", "Simple"), ("cron", "Cron"), ("oncalendar", "OnCalendar"))
|
||||
# max time between start and ping where we will consider both events related:
|
||||
MAX_DURATION = td(hours=72)
|
||||
|
||||
|
@ -282,6 +283,11 @@ class Check(models.Model):
|
|||
# a timedelta to it later (in `going_down_after` and in `get_status`)
|
||||
# may yield incorrect results during DST transitions.
|
||||
result = result.astimezone(timezone.utc)
|
||||
elif self.kind == "oncalendar" and self.status == "up":
|
||||
assert self.last_ping is not None
|
||||
last_local = self.last_ping.astimezone(ZoneInfo(self.tz))
|
||||
result = next(OnCalendar(self.schedule, last_local))
|
||||
result = result.astimezone(timezone.utc)
|
||||
|
||||
if with_started and self.last_start and self.status != "down":
|
||||
result = min(result, self.last_start)
|
||||
|
|
|
@ -12,6 +12,7 @@ from django.core.exceptions import ValidationError
|
|||
|
||||
from hc.front.validators import (
|
||||
CronExpressionValidator,
|
||||
OnCalendarValidator,
|
||||
TimezoneValidator,
|
||||
WebhookValidator,
|
||||
)
|
||||
|
@ -124,6 +125,14 @@ class CronForm(forms.Form):
|
|||
grace = forms.IntegerField(min_value=1, max_value=43200)
|
||||
|
||||
|
||||
class OnCalendarForm(forms.Form):
|
||||
schedule = forms.CharField(
|
||||
max_length=100, validators=[OnCalendarValidator()]
|
||||
)
|
||||
tz = forms.CharField(max_length=36, validators=[TimezoneValidator()])
|
||||
grace = forms.IntegerField(min_value=1, max_value=43200)
|
||||
|
||||
|
||||
class AddOpsgenieForm(forms.Form):
|
||||
error_css_class = "has-error"
|
||||
region = forms.ChoiceField(initial="us", choices=(("us", "US"), ("eu", "EU")))
|
||||
|
|
26
hc/front/tests/test_oncalendar_preview.py
Normal file
26
hc/front/tests/test_oncalendar_preview.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
CURRENT_TIME = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
MOCK_NOW = Mock(return_value=CURRENT_TIME)
|
||||
|
||||
|
||||
@patch("hc.front.views.now", MOCK_NOW)
|
||||
class OnCalendarPreviewTestCase(BaseTestCase):
|
||||
url = "/checks/oncalendar_preview/"
|
||||
|
||||
def test_it_works(self) -> None:
|
||||
payload = {"schedule": "*:*", "tz": "UTC"}
|
||||
r = self.client.post(self.url, payload)
|
||||
self.assertContains(r, "oncalendar-preview-title", status_code=200)
|
||||
self.assertContains(r, "2020-01-01 00:01:00 UTC")
|
||||
|
||||
def test_it_handles_single_result(self) -> None:
|
||||
payload = {"schedule": "2020-02-01", "tz": "UTC"}
|
||||
r = self.client.post(self.url, payload)
|
||||
self.assertContains(r, "oncalendar-preview-title", status_code=200)
|
||||
self.assertContains(r, "2020-02-01 00:00:00 UTC")
|
|
@ -152,6 +152,31 @@ class UpdateTimeoutTestCase(BaseTestCase):
|
|||
r = self.client.post(self.url, data=payload)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_saves_oncalendar_expression(self) -> None:
|
||||
payload = {"kind": "oncalendar", "schedule": "12:34", "tz": "UTC", "grace": 60}
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, data=payload)
|
||||
self.assertRedirects(r, self.redirect_url)
|
||||
|
||||
self.check.refresh_from_db()
|
||||
self.assertEqual(self.check.kind, "oncalendar")
|
||||
self.assertEqual(self.check.schedule, "12:34")
|
||||
|
||||
def test_it_validates_oncalendar_expression(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
samples = ["*-*-* invalid", "12:99", "0-0"]
|
||||
|
||||
for sample in samples:
|
||||
payload = {"kind": "oncalendar", "schedule": sample, "tz": "UTC", "grace": 60}
|
||||
|
||||
r = self.client.post(self.url, data=payload)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
# Check should still have its original data:
|
||||
self.check.refresh_from_db()
|
||||
self.assertEqual(self.check.kind, "simple")
|
||||
|
||||
def test_team_access_works(self) -> None:
|
||||
payload = {"kind": "simple", "timeout": 7200, "grace": 60}
|
||||
|
||||
|
|
|
@ -107,6 +107,7 @@ urlpatterns = [
|
|||
path("", views.index, name="hc-index"),
|
||||
path("tv/", views.dashboard, name="hc-dashboard"),
|
||||
path("checks/cron_preview/", views.cron_preview),
|
||||
path("checks/oncalendar_preview/", views.oncalendar_preview),
|
||||
path("checks/validate_schedule/", views.validate_schedule),
|
||||
path("checks/<uuid:code>/", include(check_urls)),
|
||||
path("cloaked/<sha1:unique_key>/", views.uncloak, name="hc-uncloak"),
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from cronsim import CronSim, CronSimError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from oncalendar import OnCalendar, OnCalendarError
|
||||
|
||||
from hc.lib.tz import all_timezones
|
||||
|
||||
|
@ -45,6 +46,23 @@ class CronExpressionValidator(object):
|
|||
raise ValidationError(message=self.message)
|
||||
|
||||
|
||||
class OnCalendarValidator(object):
|
||||
message = "Not a valid OnCalendar expression."
|
||||
|
||||
def __call__(self, value: str) -> None:
|
||||
# Expect 1 - 4 components
|
||||
if len(value.split()) > 4:
|
||||
raise ValidationError(message=self.message)
|
||||
|
||||
try:
|
||||
# Does oncalendar accept the schedule?
|
||||
it = OnCalendar(value, datetime(2000, 1, 1, tzinfo=timezone.utc))
|
||||
# Can it calculate the next datetime?
|
||||
next(it)
|
||||
except (OnCalendarError, StopIteration):
|
||||
raise ValidationError(message=self.message)
|
||||
|
||||
|
||||
class TimezoneValidator(object):
|
||||
message = "Not a valid time zone."
|
||||
|
||||
|
|
|
@ -40,6 +40,7 @@ from django.urls import reverse
|
|||
from django.utils.timezone import now
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
from oncalendar import OnCalendar, OnCalendarError
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
from hc.accounts.http import AuthenticatedHttpRequest
|
||||
|
@ -578,6 +579,15 @@ def update_timeout(request: AuthenticatedHttpRequest, code: UUID) -> HttpRespons
|
|||
check.schedule = cron_form.cleaned_data["schedule"]
|
||||
check.tz = cron_form.cleaned_data["tz"]
|
||||
check.grace = td(minutes=cron_form.cleaned_data["grace"])
|
||||
elif kind == "oncalendar":
|
||||
oncalendar_form = forms.OnCalendarForm(request.POST)
|
||||
if not oncalendar_form.is_valid():
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
check.kind = "oncalendar"
|
||||
check.schedule = oncalendar_form.cleaned_data["schedule"]
|
||||
check.tz = oncalendar_form.cleaned_data["tz"]
|
||||
check.grace = td(minutes=oncalendar_form.cleaned_data["grace"])
|
||||
|
||||
check.alert_after = check.going_down_after()
|
||||
if check.status == "up":
|
||||
|
@ -630,6 +640,30 @@ def cron_preview(request: HttpRequest) -> HttpResponse:
|
|||
return render(request, "front/cron_preview.html", ctx)
|
||||
|
||||
|
||||
@require_POST
|
||||
def oncalendar_preview(request: HttpRequest) -> HttpResponse:
|
||||
schedule = request.POST.get("schedule", "")
|
||||
tz = request.POST.get("tz")
|
||||
ctx: dict[str, object] = {"tz": tz, "dates": []}
|
||||
|
||||
if tz not in all_timezones:
|
||||
ctx["bad_tz"] = True
|
||||
return render(request, "front/oncalendar_preview.html", ctx)
|
||||
|
||||
now_local = now().astimezone(ZoneInfo(tz))
|
||||
try:
|
||||
it = OnCalendar(schedule, now_local)
|
||||
iterations = 5 if tz == "UTC" else 4
|
||||
for i in range(0, iterations):
|
||||
assert isinstance(ctx["dates"], list)
|
||||
ctx["dates"].append(next(it))
|
||||
except (OnCalendarError, StopIteration):
|
||||
if not ctx["dates"]:
|
||||
ctx["bad_schedule"] = True
|
||||
|
||||
return render(request, "front/oncalendar_preview.html", ctx)
|
||||
|
||||
|
||||
def validate_schedule(request: HttpRequest) -> HttpResponse:
|
||||
schedule = request.GET.get("schedule", "")
|
||||
result = True
|
||||
|
|
3
mypy.ini
3
mypy.ini
|
@ -10,5 +10,8 @@ ignore_missing_imports = True
|
|||
[mypy-minio.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-oncalendar.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pygments.*]
|
||||
ignore_missing_imports = True
|
||||
|
|
|
@ -4,6 +4,7 @@ Django==5.0
|
|||
django-compressor==4.4
|
||||
django-stubs-ext==4.2.5
|
||||
fido2==1.1.2
|
||||
oncalendar==1.0a1
|
||||
psycopg2==2.9.9
|
||||
pycurl==7.45.2
|
||||
pydantic==2.5.2
|
||||
|
|
|
@ -69,7 +69,8 @@
|
|||
padding: 40px;
|
||||
}
|
||||
|
||||
#update-cron-form label {
|
||||
#update-cron-form label,
|
||||
#update-oncalendar-form label {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
|
@ -91,11 +92,44 @@
|
|||
padding: 16px 8px 0px 8px;
|
||||
}
|
||||
|
||||
#cron-preview {
|
||||
#cron-preview, #oncalendar-preview {
|
||||
background: var(--pre-bg);
|
||||
min-height: 298px;
|
||||
}
|
||||
|
||||
#update-oncalendar-form .modal-body {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
#oncalendar-controls {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
#oncalendar-preview table.table {
|
||||
margin: 15px 0 0 0;
|
||||
}
|
||||
|
||||
#oncalendar-preview table td:nth-child(1) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#oncalendar-preview th {
|
||||
padding-top: 0;
|
||||
font-weight: normal;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
#oncalendar-preview tr.in-utc td,
|
||||
#oncalendar-preview tr.from-now td {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#oncalendar-preview tr.from-now td {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
|
||||
#cron-preview p {
|
||||
padding: 8px;
|
||||
font-size: small;
|
||||
|
|
|
@ -15,6 +15,7 @@ $(function () {
|
|||
|
||||
$("#update-timeout-form").attr("action", url);
|
||||
$("#update-cron-form").attr("action", url);
|
||||
$("#update-oncalendar-form").attr("action", url);
|
||||
|
||||
// Simple, period
|
||||
var parsed = secsToUnits(this.dataset.timeout);
|
||||
|
@ -33,13 +34,24 @@ $(function () {
|
|||
// Cron
|
||||
currentPreviewHash = "";
|
||||
$("#cron-preview").html("<p>Updating...</p>");
|
||||
$("#schedule").val(this.dataset.schedule);
|
||||
$("#schedule").val(this.dataset.kind == "cron" ? this.dataset.schedule: "* * * * *");
|
||||
$("#tz")[0].selectize.setValue(this.dataset.tz, true);
|
||||
|
||||
var minutes = parseInt(this.dataset.grace / 60);
|
||||
$("#update-timeout-grace-cron").val(minutes);
|
||||
updateCronPreview();
|
||||
|
||||
this.dataset.kind == "simple" ? showSimple() : showCron();
|
||||
// OnCalendar
|
||||
$("#cron-preview").html("<p>Updating...</p>");
|
||||
$("#schedule-oncalendar").val(this.dataset.kind == "oncalendar" ? this.dataset.schedule: "*-*-* *:*:*");
|
||||
$("#tz-oncalendar")[0].selectize.setValue(this.dataset.tz, true);
|
||||
var minutes = parseInt(this.dataset.grace / 60);
|
||||
$("#update-timeout-grace-oncalendar").val(minutes);
|
||||
updateOnCalendarPreview();
|
||||
|
||||
if (this.dataset.kind == "simple") showSimple();
|
||||
if (this.dataset.kind == "cron") showCron();
|
||||
if (this.dataset.kind == "oncalendar") showOnCalendar();
|
||||
$('#update-timeout-modal').modal({"show":true, "backdrop":"static"});
|
||||
return false;
|
||||
});
|
||||
|
@ -157,25 +169,34 @@ $(function () {
|
|||
function showSimple() {
|
||||
$("#update-timeout-form").show();
|
||||
$("#update-cron-form").hide();
|
||||
$("#update-oncalendar-form").hide();
|
||||
}
|
||||
|
||||
function showCron() {
|
||||
$("#update-timeout-form").hide();
|
||||
$("#update-cron-form").show();
|
||||
$("#update-oncalendar-form").hide();
|
||||
}
|
||||
|
||||
var currentPreviewHash = "";
|
||||
function showOnCalendar() {
|
||||
$("#update-timeout-form").hide();
|
||||
$("#update-cron-form").hide();
|
||||
$("#update-oncalendar-form").show();
|
||||
}
|
||||
|
||||
var cronPreviewHash = "";
|
||||
function updateCronPreview() {
|
||||
console.log("updating cron preview");
|
||||
var schedule = $("#schedule").val();
|
||||
var tz = $("#tz").val();
|
||||
var hash = schedule + tz;
|
||||
|
||||
// Don't try preview with empty values, or if values have not changed
|
||||
if (!schedule || !tz || hash == currentPreviewHash)
|
||||
if (!schedule || !tz || hash == cronPreviewHash)
|
||||
return;
|
||||
|
||||
// OK, we're good
|
||||
currentPreviewHash = hash;
|
||||
cronPreviewHash = hash;
|
||||
$("#cron-preview-title").text("Updating...");
|
||||
|
||||
var token = $('input[name=csrfmiddlewaretoken]').val();
|
||||
|
@ -185,7 +206,7 @@ $(function () {
|
|||
headers: {"X-CSRFToken": token},
|
||||
data: {schedule: schedule, tz: tz},
|
||||
success: function(data) {
|
||||
if (hash != currentPreviewHash) {
|
||||
if (hash != cronPreviewHash) {
|
||||
return; // ignore stale results
|
||||
}
|
||||
|
||||
|
@ -196,11 +217,46 @@ $(function () {
|
|||
});
|
||||
}
|
||||
|
||||
var onCalendarPreviewHash = "";
|
||||
function updateOnCalendarPreview() {
|
||||
var schedule = $("#schedule-oncalendar").val();
|
||||
var tz = $("#tz-oncalendar").val();
|
||||
var hash = schedule + tz;
|
||||
|
||||
// Don't try preview with empty values, or if values have not changed
|
||||
if (!schedule || !tz || hash == onCalendarPreviewHash)
|
||||
return;
|
||||
|
||||
// OK, we're good
|
||||
onCalendarPreviewHash = hash;
|
||||
$("#oncalendar-preview-title").text("Updating...");
|
||||
|
||||
var token = $('input[name=csrfmiddlewaretoken]').val();
|
||||
$.ajax({
|
||||
url: base + "/checks/oncalendar_preview/",
|
||||
type: "post",
|
||||
headers: {"X-CSRFToken": token},
|
||||
data: {schedule: schedule, tz: tz},
|
||||
success: function(data) {
|
||||
if (hash != onCalendarPreviewHash) {
|
||||
return; // ignore stale results
|
||||
}
|
||||
|
||||
$("#oncalendar-preview" ).html(data);
|
||||
var haveError = $("#invalid-arguments").length > 0;
|
||||
$("#update-oncalendar-submit").prop("disabled", haveError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire up events for Timeout/Cron forms
|
||||
$(".kind-simple").click(showSimple);
|
||||
$(".kind-cron").click(showCron);
|
||||
$(".kind-oncalendar").click(showOnCalendar);
|
||||
|
||||
$("#schedule").on("keyup", updateCronPreview);
|
||||
$("#schedule-oncalendar").on("keyup", updateOnCalendarPreview);
|
||||
$("#tz").on("change", updateCronPreview);
|
||||
$("#tz-oncalendar").on("change", updateOnCalendarPreview);
|
||||
|
||||
});
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
</p>
|
||||
{% elif bad_tz %}
|
||||
<p id="invalid-arguments">Invalid timezone</p>
|
||||
<p id="invalid-arguments">Invalid timezone.</p>
|
||||
{% else %}
|
||||
{% if desc %}<div id="cron-description">“{{ desc }}”</div>{% endif %}
|
||||
<table id="cron-preview-table" class="table">
|
||||
|
|
|
@ -169,6 +169,11 @@
|
|||
<td>
|
||||
<span class="value">{{ check.schedule }}</span>
|
||||
</td>
|
||||
{% elif check.kind == "oncalendar" %}
|
||||
<th>OnCalendar Expr.</th>
|
||||
<td>
|
||||
<span class="value">{{ check.schedule|linebreaksbr }}</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% if check.kind == "cron" %}
|
||||
|
@ -382,11 +387,11 @@
|
|||
<script src="{% static 'js/moment.min.js' %}"></script>
|
||||
<script src="{% static 'js/moment-timezone-with-data-10-year-range.min.js' %}"></script>
|
||||
<script src="{% static 'js/purify.min.js' %}"></script>
|
||||
<script src="{% static 'js/initialize-timezone-selects.js' %}"></script>
|
||||
<script src="{% static 'js/update-timeout-modal.js' %}"></script>
|
||||
<script src="{% static 'js/adaptive-setinterval.js' %}"></script>
|
||||
<script src="{% static 'js/ping_details.js' %}"></script>
|
||||
<script src="{% static 'js/details.js' %}"></script>
|
||||
<script src="{% static 'js/initialize-timezone-selects.js' %}"></script>
|
||||
<script src="{% static 'js/slug-suggestions.js' %}"></script>
|
||||
{% endcompress %}
|
||||
{% endblock %}
|
||||
|
|
38
templates/front/oncalendar_preview.html
Normal file
38
templates/front/oncalendar_preview.html
Normal file
|
@ -0,0 +1,38 @@
|
|||
{% load humanize tz %}
|
||||
|
||||
{% if bad_schedule %}
|
||||
<p id="invalid-arguments">Invalid OnCalendar expression.</p>
|
||||
{% elif bad_tz %}
|
||||
<p id="invalid-arguments">Invalid timezone.</p>
|
||||
{% else %}
|
||||
<table class="table">
|
||||
<tr><th id="oncalendar-preview-title" colspan="2">Expected Ping Dates</th></tr>
|
||||
{% for d in dates %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if forloop.first %}
|
||||
Next elapse:
|
||||
{% else %}
|
||||
#{{ forloop.counter }}:
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% timezone tz %}
|
||||
<code>{{ d|date:"D Y-m-d H:i:s T" }}</code>
|
||||
{% endtimezone %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if tz != 'UTC' %}
|
||||
<tr class="in-utc">
|
||||
<td>(in UTC):</td>
|
||||
<td><code>{{ d|date:"D Y-m-d H:i:s T" }}</code></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="from-now">
|
||||
<td></td>
|
||||
<td>{{ d|naturaltime }}</td>
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
|
@ -55,6 +55,7 @@
|
|||
<div class="btn-group pull-left">
|
||||
<label class="btn btn-default kind-simple active">Simple</label>
|
||||
<label class="btn btn-default kind-cron">Cron</label>
|
||||
<label class="btn btn-default kind-oncalendar">OnCalendar</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
|
@ -116,6 +117,7 @@
|
|||
<div class="btn-group pull-left">
|
||||
<label class="btn btn-default kind-simple">Simple</label>
|
||||
<label class="btn btn-default active kind-cron">Cron</label>
|
||||
<label class="btn btn-default kind-oncalendar">OnCalendar</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
|
@ -124,6 +126,59 @@
|
|||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="update-oncalendar-form" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="kind" value="oncalendar" />
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div id="oncalendar-controls" class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="schedule">OnCalendar Expression(s)</label>
|
||||
|
||||
<textarea
|
||||
id="schedule-oncalendar"
|
||||
name="schedule"
|
||||
class="form-control"
|
||||
rows="5"
|
||||
placeholder="*-*-* *:*:*"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tz">Server's Time Zone</label>
|
||||
<br />
|
||||
<select id="tz-oncalendar" name="tz" class="form-control"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="update-timeout-grace-oncalendar">Grace Time</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="43200"
|
||||
class="form-control"
|
||||
id="update-timeout-grace-oncalendar"
|
||||
name="grace">
|
||||
<div class="input-group-addon">minutes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="oncalendar-preview" class="col-md-6"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="btn-group pull-left">
|
||||
<label class="btn btn-default kind-simple">Simple</label>
|
||||
<label class="btn btn-default kind-cron">Cron</label>
|
||||
<label class="btn btn-default active kind-oncalendar">OnCalendar</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
<button id="update-oncalendar-submit" type="submit" class="btn btn-primary" {% if not rw %}disabled{% endif %}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Reference in a new issue