Add support for cron expressions for scheduling timer functions

This commit is contained in:
Peter Stockings
2025-11-27 15:51:15 +11:00
parent f3c2664b31
commit 3f9fa79515
7 changed files with 152 additions and 44 deletions

View File

@@ -11,4 +11,5 @@ requests==2.26.0
Flask-Login==0.6.3
python-dotenv==1.0.1
aiohttp==3.11.12
flask-apscheduler==1.13.1
flask-apscheduler==1.13.1
croniter>=2.0.0

View File

@@ -17,10 +17,11 @@ CREATE TABLE timer_functions (
user_id INT NOT NULL, -- the referencing column
trigger_type VARCHAR(20) NOT NULL CHECK (
trigger_type IN ('interval', 'date')
trigger_type IN ('interval', 'date', 'cron')
),
frequency_minutes INT, -- used if trigger_type = 'interval'
run_date TIMESTAMPTZ, -- used if trigger_type = 'date' (one-off)
cron_expression TEXT, -- used if trigger_type = 'cron'
next_run TIMESTAMPTZ,
last_run TIMESTAMPTZ,
@@ -154,7 +155,7 @@ def overview():
timer_functions = db.execute("""
SELECT id, name, code, environment, trigger_type,
frequency_minutes, run_date, next_run,
last_run, enabled, invocation_count, runtime
last_run, enabled, invocation_count, runtime, cron_expression
FROM timer_functions
WHERE user_id = %s
ORDER BY id DESC
@@ -185,7 +186,7 @@ def new():
runtime = data.get('runtime', 'node')
# Validate trigger type
if trigger_type not in ('interval', 'date'):
if trigger_type not in ('interval', 'date', 'cron'):
return jsonify({
"status": "error",
"message": "Invalid trigger type"
@@ -193,19 +194,41 @@ def new():
# Calculate next_run based on trigger type
next_run = None
frequency_minutes = None
run_date = None
cron_expression = None
if trigger_type == 'interval':
frequency_minutes = int(data.get('frequency_minutes'))
next_run = datetime.now(timezone.utc) + timedelta(minutes=frequency_minutes)
elif trigger_type == 'date':
run_date = datetime.fromisoformat(data.get('run_date'))
next_run = run_date
elif trigger_type == 'cron':
from croniter import croniter
cron_expression = data.get('cron_expression')
# Validate cron expression
try:
if not croniter.is_valid(cron_expression):
return jsonify({
"status": "error",
"message": "Invalid cron expression format"
}), 400
# Calculate first run time
next_run = croniter(cron_expression, datetime.now(timezone.utc)).get_next(datetime)
except Exception as e:
return jsonify({
"status": "error",
"message": f"Invalid cron expression: {str(e)}"
}), 400
# Insert new timer function
db.execute("""
INSERT INTO timer_functions
(name, code, environment, user_id, trigger_type,
frequency_minutes, run_date, next_run, enabled, runtime)
VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s)
frequency_minutes, run_date, cron_expression, next_run, enabled, runtime)
VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", [
data.get('name'),
@@ -213,8 +236,9 @@ def new():
data.get('environment_info'),
current_user.id,
trigger_type,
frequency_minutes if trigger_type == 'interval' else None,
run_date if trigger_type == 'date' else None,
frequency_minutes,
run_date,
cron_expression,
next_run,
True,
runtime
@@ -239,8 +263,8 @@ def edit(function_id):
# Fetch the timer function
timer_function = db.execute("""
SELECT id, name, code, environment, version_number, trigger_type,
frequency_minutes, run_date, next_run,
last_run, enabled, invocation_count, runtime
frequency_minutes, run_date, cron_expression, next_run,
last_run, enabled, invocation_count, runtime
FROM timer_functions
WHERE id = %s AND user_id = %s
""", [function_id, current_user.id], one=True)
@@ -271,7 +295,7 @@ def edit(function_id):
runtime = data.get('runtime', 'node')
# Validate trigger type
if trigger_type not in ('interval', 'date'):
if trigger_type not in ('interval', 'date', 'cron'):
return jsonify({
"status": "error",
"message": "Invalid trigger type"
@@ -279,12 +303,34 @@ def edit(function_id):
# Calculate next_run based on trigger type
next_run = None
frequency_minutes = None
run_date = None
cron_expression = None
if trigger_type == 'interval':
frequency_minutes = int(data.get('frequency_minutes'))
next_run = datetime.now(timezone.utc) + timedelta(minutes=frequency_minutes)
elif trigger_type == 'date':
run_date = datetime.fromisoformat(data.get('run_date'))
next_run = run_date
elif trigger_type == 'cron':
from croniter import croniter
cron_expression = data.get('cron_expression')
# Validate cron expression
try:
if not croniter.is_valid(cron_expression):
return jsonify({
"status": "error",
"message": "Invalid cron expression format"
}), 400
# Calculate first run time
next_run = croniter(cron_expression, datetime.now(timezone.utc)).get_next(datetime)
except Exception as e:
return jsonify({
"status": "error",
"message": f"Invalid cron expression: {str(e)}"
}), 400
# Update timer function
db.execute("""
@@ -295,6 +341,7 @@ def edit(function_id):
trigger_type = %s,
frequency_minutes = %s,
run_date = %s,
cron_expression = %s,
next_run = %s,
enabled = %s,
runtime = %s
@@ -305,8 +352,9 @@ def edit(function_id):
data.get('script_content'),
data.get('environment_info'),
trigger_type,
frequency_minutes if trigger_type == 'interval' else None,
run_date if trigger_type == 'date' else None,
frequency_minutes,
run_date,
cron_expression,
next_run,
data.get('is_enabled', True), # Default to True if not provided
runtime,
@@ -371,12 +419,12 @@ def toggle(function_id):
"status": "error",
"message": "Timer function not found or unauthorized"
}), 404
# Fetch updated timer functions for the overview template
timer_functions = db.execute("""
SELECT id, name, code, environment, trigger_type,
frequency_minutes, run_date, next_run,
last_run, enabled, invocation_count, runtime
last_run, enabled, invocation_count, runtime, cron_expression
FROM timer_functions
WHERE user_id = %s
ORDER BY id DESC

View File

@@ -39,6 +39,7 @@ const Editor = {
this.triggerType = vnode.attrs.triggerType || "interval";
this.frequencyMinutes = vnode.attrs.frequencyMinutes || 60;
this.runDate = vnode.attrs.runDate || "";
this.cronExpression = vnode.attrs.cronExpression || "";
this.showTimerSettings = vnode.attrs.showTimerSettings === true;
this.cancelUrl = vnode.attrs.cancelUrl || "/dashboard/http_functions";
this.isEnabled = vnode.attrs.isEnabled !== false;
@@ -243,6 +244,7 @@ const Editor = {
? parseInt(this.frequencyMinutes)
: null,
run_date: this.triggerType === "date" ? this.runDate : null,
cron_expression: this.triggerType === "cron" ? this.cronExpression : null,
is_enabled: this.isEnabled,
description: this.description,
runtime: this.runtime,
@@ -869,6 +871,7 @@ const Editor = {
},
[
m("option", { value: "interval" }, "Interval"),
m("option", { value: "cron" }, "Cron Expression"),
m("option", { value: "date" }, "Specific Date"),
]
),
@@ -893,6 +896,37 @@ const Editor = {
(this.frequencyMinutes = e.target.value),
}),
])
: this.triggerType === "cron"
? m("div", { class: "flex flex-col space-y-2" }, [
m(
"label",
{
class:
"text-sm font-medium text-gray-700 dark:text-gray-300",
},
"Cron Expression"
),
m("input[type=text]", {
class:
"bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 font-mono text-sm",
placeholder: "0 9 * * * (Daily at 9 AM)",
value: this.cronExpression,
oninput: (e) => (this.cronExpression = e.target.value),
}),
m(
"p",
{ class: "text-xs text-gray-500 dark:text-gray-400" },
[
"Examples: ",
m("code", { class: "bg-gray-100 dark:bg-gray-800 px-1 rounded" }, "0 9 * * *"),
" (9 AM daily), ",
m("code", { class: "bg-gray-100 dark:bg-gray-800 px-1 rounded" }, "*/15 * * * *"),
" (every 15 min), ",
m("code", { class: "bg-gray-100 dark:bg-gray-800 px-1 rounded" }, "0 0 * * MON"),
" (Mon midnight)",
]
),
])
: m("div", { class: "flex flex-col space-y-2" }, [
m(
"label",

View File

@@ -38,7 +38,9 @@ history_url=url_for('timer.history', function_id=function_id)) }}
showDeleteButton: true,
isTimer: true,
showTimerSettings: true,
frequencyMinutes: {{ timer_function.frequency_minutes }},
triggerType: '{{ timer_function.trigger_type }}',
frequencyMinutes: {{ timer_function.frequency_minutes or "null" }},
cronExpression: '{{ timer_function.cron_expression or "" }}',
cancelUrl: "{{ url_for('timer.overview') }}",
generateUrl: "{{ url_for('llm.generate_script') }}",
showPublicToggle: false,

View File

@@ -36,6 +36,7 @@ title='New Timer Function')
showTimerSettings: true,
triggerType: 'interval',
frequencyMinutes: 60,
cronExpression: '',
runtime: 'node',
showPublicToggle: false,
showLogRequestToggle: false,

View File

@@ -58,27 +58,39 @@
</div>
</td>
<td class="px-6 py-4">
{% if function.trigger_type == 'interval' %}
<span class="inline-flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Every {{ function.frequency_minutes }} minutes
</span>
{% else %}
<span class="inline-flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
{{ function.run_date.strftime('%Y-%m-%d %H:%M') }}
</span>
{% endif %}
<div class="flex flex-col gap-1">
{% if function.trigger_type == 'cron' %}
<span
class="inline-flex items-center text-xs font-medium text-orange-800 dark:text-orange-300">
<span
class="bg-orange-100 dark:bg-orange-900/30 px-2 py-0.5 rounded-full uppercase mr-2">
Cron
</span>
</span>
<code
class="text-xs font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">{{ function.cron_expression }}</code>
{% elif function.trigger_type == 'interval' %}
<span
class="inline-flex items-center text-xs font-medium text-blue-800 dark:text-blue-300">
<span
class="bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 rounded-full uppercase mr-2">
Interval
</span>
</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Every {{
function.frequency_minutes }} minutes</span>
{% else %}
<span
class="inline-flex items-center text-xs font-medium text-purple-800 dark:text-purple-300">
<span
class="bg-purple-100 dark:bg-purple-900/30 px-2 py-0.5 rounded-full uppercase mr-2">
One-Time
</span>
</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
function.run_date.strftime('%Y-%m-%d %H:%M') }}</span>
{% endif %}
</div>
</td>
<td class="px-6 py-4">
{% if function.next_run %}

View File

@@ -8,6 +8,7 @@ if os.environ.get('FLASK_ENV') != 'production':
import asyncio
import aiohttp
import json
from datetime import datetime, timezone, timedelta
from flask import Flask
from extensions import db, init_app
from flask_apscheduler import APScheduler
@@ -54,17 +55,26 @@ async def execute_timer_function_async(timer_function):
response_data = await response.json()
# Update environment and record invocation
# Calculate next run time based on trigger type
next_run = None
if timer_function.get('trigger_type') == 'interval' and timer_function.get('frequency_minutes'):
next_run = datetime.now(timezone.utc) + timedelta(minutes=timer_function['frequency_minutes'])
elif timer_function.get('trigger_type') == 'cron' and timer_function.get('cron_expression'):
from croniter import croniter
try:
next_run = croniter(timer_function['cron_expression'], datetime.now(timezone.utc)).get_next(datetime)
except Exception as e:
print(f"Error calculating next cron run for timer {timer_function['id']}: {str(e)}")
next_run = None
# For 'date' trigger type, next_run should be NULL (one-time execution)
db.execute("""
UPDATE timer_functions
SET environment = %s::jsonb,
last_run = NOW(),
next_run = CASE
WHEN trigger_type = 'interval'
THEN NOW() + (frequency_minutes || ' minutes')::interval
ELSE NULL
END
next_run = %s
WHERE id = %s
""", [json.dumps(response_data['environment']), timer_function['id']], commit=True)
""", [json.dumps(response_data['environment']), next_run, timer_function['id']], commit=True)
# Record the invocation
db.execute("""
@@ -105,7 +115,7 @@ def check_and_execute_timer_functions():
timer_functions = db.execute("""
SELECT
id, name, code, environment, version_number,
trigger_type, frequency_minutes, run_date,
trigger_type, frequency_minutes, run_date, cron_expression,
next_run, enabled, runtime,
EXTRACT(EPOCH FROM (NOW() - next_run)) as seconds_since_next_run
FROM timer_functions