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

@@ -12,3 +12,4 @@ Flask-Login==0.6.3
python-dotenv==1.0.1 python-dotenv==1.0.1
aiohttp==3.11.12 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 user_id INT NOT NULL, -- the referencing column
trigger_type VARCHAR(20) NOT NULL CHECK ( 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' frequency_minutes INT, -- used if trigger_type = 'interval'
run_date TIMESTAMPTZ, -- used if trigger_type = 'date' (one-off) run_date TIMESTAMPTZ, -- used if trigger_type = 'date' (one-off)
cron_expression TEXT, -- used if trigger_type = 'cron'
next_run TIMESTAMPTZ, next_run TIMESTAMPTZ,
last_run TIMESTAMPTZ, last_run TIMESTAMPTZ,
@@ -154,7 +155,7 @@ def overview():
timer_functions = db.execute(""" timer_functions = db.execute("""
SELECT id, name, code, environment, trigger_type, SELECT id, name, code, environment, trigger_type,
frequency_minutes, run_date, next_run, frequency_minutes, run_date, next_run,
last_run, enabled, invocation_count, runtime last_run, enabled, invocation_count, runtime, cron_expression
FROM timer_functions FROM timer_functions
WHERE user_id = %s WHERE user_id = %s
ORDER BY id DESC ORDER BY id DESC
@@ -185,7 +186,7 @@ def new():
runtime = data.get('runtime', 'node') runtime = data.get('runtime', 'node')
# Validate trigger type # Validate trigger type
if trigger_type not in ('interval', 'date'): if trigger_type not in ('interval', 'date', 'cron'):
return jsonify({ return jsonify({
"status": "error", "status": "error",
"message": "Invalid trigger type" "message": "Invalid trigger type"
@@ -193,19 +194,41 @@ def new():
# Calculate next_run based on trigger type # Calculate next_run based on trigger type
next_run = None next_run = None
frequency_minutes = None
run_date = None
cron_expression = None
if trigger_type == 'interval': if trigger_type == 'interval':
frequency_minutes = int(data.get('frequency_minutes')) frequency_minutes = int(data.get('frequency_minutes'))
next_run = datetime.now(timezone.utc) + timedelta(minutes=frequency_minutes) next_run = datetime.now(timezone.utc) + timedelta(minutes=frequency_minutes)
elif trigger_type == 'date': elif trigger_type == 'date':
run_date = datetime.fromisoformat(data.get('run_date')) run_date = datetime.fromisoformat(data.get('run_date'))
next_run = 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 # Insert new timer function
db.execute(""" db.execute("""
INSERT INTO timer_functions INSERT INTO timer_functions
(name, code, environment, user_id, trigger_type, (name, code, environment, user_id, trigger_type,
frequency_minutes, run_date, next_run, enabled, runtime) frequency_minutes, run_date, cron_expression, next_run, enabled, runtime)
VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", [ """, [
data.get('name'), data.get('name'),
@@ -213,8 +236,9 @@ def new():
data.get('environment_info'), data.get('environment_info'),
current_user.id, current_user.id,
trigger_type, trigger_type,
frequency_minutes if trigger_type == 'interval' else None, frequency_minutes,
run_date if trigger_type == 'date' else None, run_date,
cron_expression,
next_run, next_run,
True, True,
runtime runtime
@@ -239,7 +263,7 @@ def edit(function_id):
# Fetch the timer function # Fetch the timer function
timer_function = db.execute(""" timer_function = db.execute("""
SELECT id, name, code, environment, version_number, trigger_type, SELECT id, name, code, environment, version_number, trigger_type,
frequency_minutes, run_date, next_run, frequency_minutes, run_date, cron_expression, next_run,
last_run, enabled, invocation_count, runtime last_run, enabled, invocation_count, runtime
FROM timer_functions FROM timer_functions
WHERE id = %s AND user_id = %s WHERE id = %s AND user_id = %s
@@ -271,7 +295,7 @@ def edit(function_id):
runtime = data.get('runtime', 'node') runtime = data.get('runtime', 'node')
# Validate trigger type # Validate trigger type
if trigger_type not in ('interval', 'date'): if trigger_type not in ('interval', 'date', 'cron'):
return jsonify({ return jsonify({
"status": "error", "status": "error",
"message": "Invalid trigger type" "message": "Invalid trigger type"
@@ -279,12 +303,34 @@ def edit(function_id):
# Calculate next_run based on trigger type # Calculate next_run based on trigger type
next_run = None next_run = None
frequency_minutes = None
run_date = None
cron_expression = None
if trigger_type == 'interval': if trigger_type == 'interval':
frequency_minutes = int(data.get('frequency_minutes')) frequency_minutes = int(data.get('frequency_minutes'))
next_run = datetime.now(timezone.utc) + timedelta(minutes=frequency_minutes) next_run = datetime.now(timezone.utc) + timedelta(minutes=frequency_minutes)
elif trigger_type == 'date': elif trigger_type == 'date':
run_date = datetime.fromisoformat(data.get('run_date')) run_date = datetime.fromisoformat(data.get('run_date'))
next_run = 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 # Update timer function
db.execute(""" db.execute("""
@@ -295,6 +341,7 @@ def edit(function_id):
trigger_type = %s, trigger_type = %s,
frequency_minutes = %s, frequency_minutes = %s,
run_date = %s, run_date = %s,
cron_expression = %s,
next_run = %s, next_run = %s,
enabled = %s, enabled = %s,
runtime = %s runtime = %s
@@ -305,8 +352,9 @@ def edit(function_id):
data.get('script_content'), data.get('script_content'),
data.get('environment_info'), data.get('environment_info'),
trigger_type, trigger_type,
frequency_minutes if trigger_type == 'interval' else None, frequency_minutes,
run_date if trigger_type == 'date' else None, run_date,
cron_expression,
next_run, next_run,
data.get('is_enabled', True), # Default to True if not provided data.get('is_enabled', True), # Default to True if not provided
runtime, runtime,
@@ -376,7 +424,7 @@ def toggle(function_id):
timer_functions = db.execute(""" timer_functions = db.execute("""
SELECT id, name, code, environment, trigger_type, SELECT id, name, code, environment, trigger_type,
frequency_minutes, run_date, next_run, frequency_minutes, run_date, next_run,
last_run, enabled, invocation_count, runtime last_run, enabled, invocation_count, runtime, cron_expression
FROM timer_functions FROM timer_functions
WHERE user_id = %s WHERE user_id = %s
ORDER BY id DESC ORDER BY id DESC

View File

@@ -39,6 +39,7 @@ const Editor = {
this.triggerType = vnode.attrs.triggerType || "interval"; this.triggerType = vnode.attrs.triggerType || "interval";
this.frequencyMinutes = vnode.attrs.frequencyMinutes || 60; this.frequencyMinutes = vnode.attrs.frequencyMinutes || 60;
this.runDate = vnode.attrs.runDate || ""; this.runDate = vnode.attrs.runDate || "";
this.cronExpression = vnode.attrs.cronExpression || "";
this.showTimerSettings = vnode.attrs.showTimerSettings === true; this.showTimerSettings = vnode.attrs.showTimerSettings === true;
this.cancelUrl = vnode.attrs.cancelUrl || "/dashboard/http_functions"; this.cancelUrl = vnode.attrs.cancelUrl || "/dashboard/http_functions";
this.isEnabled = vnode.attrs.isEnabled !== false; this.isEnabled = vnode.attrs.isEnabled !== false;
@@ -243,6 +244,7 @@ const Editor = {
? parseInt(this.frequencyMinutes) ? parseInt(this.frequencyMinutes)
: null, : null,
run_date: this.triggerType === "date" ? this.runDate : null, run_date: this.triggerType === "date" ? this.runDate : null,
cron_expression: this.triggerType === "cron" ? this.cronExpression : null,
is_enabled: this.isEnabled, is_enabled: this.isEnabled,
description: this.description, description: this.description,
runtime: this.runtime, runtime: this.runtime,
@@ -869,6 +871,7 @@ const Editor = {
}, },
[ [
m("option", { value: "interval" }, "Interval"), m("option", { value: "interval" }, "Interval"),
m("option", { value: "cron" }, "Cron Expression"),
m("option", { value: "date" }, "Specific Date"), m("option", { value: "date" }, "Specific Date"),
] ]
), ),
@@ -893,6 +896,37 @@ const Editor = {
(this.frequencyMinutes = e.target.value), (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("div", { class: "flex flex-col space-y-2" }, [
m( m(
"label", "label",

View File

@@ -38,7 +38,9 @@ history_url=url_for('timer.history', function_id=function_id)) }}
showDeleteButton: true, showDeleteButton: true,
isTimer: true, isTimer: true,
showTimerSettings: 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') }}", cancelUrl: "{{ url_for('timer.overview') }}",
generateUrl: "{{ url_for('llm.generate_script') }}", generateUrl: "{{ url_for('llm.generate_script') }}",
showPublicToggle: false, showPublicToggle: false,

View File

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

View File

@@ -58,27 +58,39 @@
</div> </div>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
{% if function.trigger_type == 'interval' %} <div class="flex flex-col gap-1">
<span class="inline-flex items-center text-gray-700 dark:text-gray-300"> {% if function.trigger_type == 'cron' %}
<svg class="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400" <span
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-flex items-center text-xs font-medium text-orange-800 dark:text-orange-300">
stroke-width="1.5" stroke="currentColor"> <span
<path stroke-linecap="round" stroke-linejoin="round" class="bg-orange-100 dark:bg-orange-900/30 px-2 py-0.5 rounded-full uppercase mr-2">
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" /> Cron
</svg>
Every {{ function.frequency_minutes }} minutes
</span> </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 %} {% else %}
<span class="inline-flex items-center text-gray-700 dark:text-gray-300"> <span
<svg class="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400" class="inline-flex items-center text-xs font-medium text-purple-800 dark:text-purple-300">
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <span
stroke-width="1.5" stroke="currentColor"> class="bg-purple-100 dark:bg-purple-900/30 px-2 py-0.5 rounded-full uppercase mr-2">
<path stroke-linecap="round" stroke-linejoin="round" One-Time
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> </span>
</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
function.run_date.strftime('%Y-%m-%d %H:%M') }}</span>
{% endif %} {% endif %}
</div>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
{% if function.next_run %} {% if function.next_run %}

View File

@@ -8,6 +8,7 @@ if os.environ.get('FLASK_ENV') != 'production':
import asyncio import asyncio
import aiohttp import aiohttp
import json import json
from datetime import datetime, timezone, timedelta
from flask import Flask from flask import Flask
from extensions import db, init_app from extensions import db, init_app
from flask_apscheduler import APScheduler from flask_apscheduler import APScheduler
@@ -54,17 +55,26 @@ async def execute_timer_function_async(timer_function):
response_data = await response.json() response_data = await response.json()
# Update environment and record invocation # 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(""" db.execute("""
UPDATE timer_functions UPDATE timer_functions
SET environment = %s::jsonb, SET environment = %s::jsonb,
last_run = NOW(), last_run = NOW(),
next_run = CASE next_run = %s
WHEN trigger_type = 'interval'
THEN NOW() + (frequency_minutes || ' minutes')::interval
ELSE NULL
END
WHERE id = %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 # Record the invocation
db.execute(""" db.execute("""
@@ -105,7 +115,7 @@ def check_and_execute_timer_functions():
timer_functions = db.execute(""" timer_functions = db.execute("""
SELECT SELECT
id, name, code, environment, version_number, id, name, code, environment, version_number,
trigger_type, frequency_minutes, run_date, trigger_type, frequency_minutes, run_date, cron_expression,
next_run, enabled, runtime, next_run, enabled, runtime,
EXTRACT(EPOCH FROM (NOW() - next_run)) as seconds_since_next_run EXTRACT(EPOCH FROM (NOW() - next_run)) as seconds_since_next_run
FROM timer_functions FROM timer_functions