Add support for cron expressions for scheduling timer functions
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,6 +36,7 @@ title='New Timer Function')
|
||||
showTimerSettings: true,
|
||||
triggerType: 'interval',
|
||||
frequencyMinutes: 60,
|
||||
cronExpression: '',
|
||||
runtime: 'node',
|
||||
showPublicToggle: false,
|
||||
showLogRequestToggle: false,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
24
worker.py
24
worker.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user