diff --git a/requirements.txt b/requirements.txt index b393197..bfe19cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +flask-apscheduler==1.13.1 +croniter>=2.0.0 \ No newline at end of file diff --git a/routes/timer.py b/routes/timer.py index a510623..207d591 100644 --- a/routes/timer.py +++ b/routes/timer.py @@ -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 diff --git a/static/js/mithril/editor.js b/static/js/mithril/editor.js index bd40256..cfd6cef 100644 --- a/static/js/mithril/editor.js +++ b/static/js/mithril/editor.js @@ -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", diff --git a/templates/dashboard/timer_functions/edit.html b/templates/dashboard/timer_functions/edit.html index 4606ae1..f85584e 100644 --- a/templates/dashboard/timer_functions/edit.html +++ b/templates/dashboard/timer_functions/edit.html @@ -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, diff --git a/templates/dashboard/timer_functions/new.html b/templates/dashboard/timer_functions/new.html index c0cf968..e9e6501 100644 --- a/templates/dashboard/timer_functions/new.html +++ b/templates/dashboard/timer_functions/new.html @@ -36,6 +36,7 @@ title='New Timer Function') showTimerSettings: true, triggerType: 'interval', frequencyMinutes: 60, + cronExpression: '', runtime: 'node', showPublicToggle: false, showLogRequestToggle: false, diff --git a/templates/dashboard/timer_functions/overview.html b/templates/dashboard/timer_functions/overview.html index 905b863..3d3b2d4 100644 --- a/templates/dashboard/timer_functions/overview.html +++ b/templates/dashboard/timer_functions/overview.html @@ -58,27 +58,39 @@ - {% if function.trigger_type == 'interval' %} - - - - - Every {{ function.frequency_minutes }} minutes - - {% else %} - - - - - {{ function.run_date.strftime('%Y-%m-%d %H:%M') }} - - {% endif %} +
+ {% if function.trigger_type == 'cron' %} + + + Cron + + + {{ function.cron_expression }} + {% elif function.trigger_type == 'interval' %} + + + Interval + + + Every {{ + function.frequency_minutes }} minutes + {% else %} + + + One-Time + + + {{ + function.run_date.strftime('%Y-%m-%d %H:%M') }} + {% endif %} +
{% if function.next_run %} diff --git a/worker.py b/worker.py index 1bb011f..3cb8d84 100644 --- a/worker.py +++ b/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