Implement timer functions with full CRUD functionality and enhanced UI
- Add comprehensive routes for creating, editing, deleting, and toggling timer functions - Create new HTML templates for timer function overview, new, and edit pages - Extend Mithril editor component to support timer-specific settings like trigger type, frequency, and enabled status - Implement database schema and versioning for timer functions - Add UI improvements for timer function listing with detailed schedule and status information
This commit is contained in:
347
routes/timer.py
347
routes/timer.py
@@ -1,17 +1,356 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from jinja2_fragments import render_block
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import login_user, login_required, logout_user
|
||||
from flask_login import current_user, login_required
|
||||
from extensions import db, htmx, environment
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import json
|
||||
|
||||
'''
|
||||
CREATE TABLE timer_functions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
environment JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
version_number INT NOT NULL DEFAULT 1,
|
||||
|
||||
user_id INT NOT NULL, -- the referencing column
|
||||
|
||||
trigger_type VARCHAR(20) NOT NULL CHECK (
|
||||
trigger_type IN ('interval', 'date')
|
||||
),
|
||||
frequency_minutes INT, -- used if trigger_type = 'interval'
|
||||
run_date TIMESTAMPTZ, -- used if trigger_type = 'date' (one-off)
|
||||
|
||||
next_run TIMESTAMPTZ,
|
||||
last_run TIMESTAMPTZ,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Define the foreign key constraint
|
||||
CONSTRAINT fk_timer_functions_user
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES users (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE timer_function_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
timer_function_id INT NOT NULL,
|
||||
script TEXT NOT NULL,
|
||||
version_number INT NOT NULL,
|
||||
versioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT fk_timer_function_versions
|
||||
FOREIGN KEY (timer_function_id)
|
||||
REFERENCES timer_functions (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION fn_timer_functions_versioning()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
next_version INT;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- In an AFTER INSERT, the row already has been inserted with version_number default (1).
|
||||
-- We can optionally override that or ensure an initial version is recorded:
|
||||
INSERT INTO timer_function_versions (timer_function_id, script, version_number)
|
||||
VALUES (NEW.id, NEW.code, 1);
|
||||
|
||||
-- If desired, ensure timer_functions.version_number is set explicitly:
|
||||
UPDATE timer_functions
|
||||
SET version_number = 1
|
||||
WHERE id = NEW.id;
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Only version if the 'code' changed
|
||||
IF NEW.code IS DISTINCT FROM OLD.code THEN
|
||||
-- Determine the next version number based on existing versions
|
||||
SELECT COALESCE(MAX(version_number), 0) + 1
|
||||
INTO next_version
|
||||
FROM timer_function_versions
|
||||
WHERE timer_function_id = NEW.id;
|
||||
|
||||
-- Insert new version record
|
||||
INSERT INTO timer_function_versions (timer_function_id, script, version_number)
|
||||
VALUES (NEW.id, NEW.code, next_version);
|
||||
|
||||
-- Manually update timer_functions to set version_number
|
||||
-- This second UPDATE will cause the trigger to fire again,
|
||||
-- but because code won't change, the trigger won't do another version bump.
|
||||
UPDATE timer_functions
|
||||
SET version_number = next_version
|
||||
WHERE id = NEW.id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER tr_timer_functions_versioning
|
||||
AFTER INSERT OR UPDATE
|
||||
ON timer_functions
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE fn_timer_functions_versioning();
|
||||
'''
|
||||
|
||||
DEFAULT_SCRIPT = """async (req) => {
|
||||
environment.count += 1
|
||||
console.log(`Executing ${environment.count}`)
|
||||
}"""
|
||||
|
||||
DEFAULT_ENVIRONMENT = """{
|
||||
"count": 0
|
||||
}"""
|
||||
|
||||
timer = Blueprint('timer', __name__)
|
||||
|
||||
@timer.route('/overview')
|
||||
@login_required
|
||||
def overview():
|
||||
timer_functions = db.execute("""
|
||||
SELECT id, name, code, environment, trigger_type,
|
||||
frequency_minutes, run_date, next_run,
|
||||
last_run, enabled
|
||||
FROM timer_functions
|
||||
WHERE user_id = %s
|
||||
ORDER BY id DESC
|
||||
""", [current_user.id])
|
||||
|
||||
if htmx:
|
||||
return render_block(environment, 'dashboard/timer_functions/overview.html', 'page')
|
||||
return render_template('dashboard/timer_functions/overview.html')
|
||||
return render_block(environment, 'dashboard/timer_functions/overview.html', 'page', timer_functions=timer_functions)
|
||||
return render_template('dashboard/timer_functions/overview.html', timer_functions=timer_functions)
|
||||
|
||||
@timer.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def new():
|
||||
if request.method == 'GET':
|
||||
args = {
|
||||
'name': 'foo',
|
||||
'script': DEFAULT_SCRIPT,
|
||||
'environment_info': DEFAULT_ENVIRONMENT,
|
||||
'user_id': current_user.id
|
||||
}
|
||||
if htmx:
|
||||
return render_block(environment, 'dashboard/timer_functions/new.html', 'page', **args)
|
||||
return render_template('dashboard/timer_functions/new.html', **args)
|
||||
|
||||
# Handle POST request
|
||||
try:
|
||||
data = request.json
|
||||
trigger_type = data.get('trigger_type')
|
||||
|
||||
# Validate trigger type
|
||||
if trigger_type not in ('interval', 'date'):
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Invalid trigger type"
|
||||
}), 400
|
||||
|
||||
# Calculate next_run based on trigger type
|
||||
next_run = 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
|
||||
|
||||
# Insert new timer function
|
||||
db.execute("""
|
||||
INSERT INTO timer_functions
|
||||
(name, code, environment, user_id, trigger_type,
|
||||
frequency_minutes, run_date, next_run, enabled)
|
||||
VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", [
|
||||
data.get('name'),
|
||||
data.get('script_content'),
|
||||
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,
|
||||
next_run,
|
||||
True
|
||||
],
|
||||
commit=True)
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": "Timer function created successfully"
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": f"Error creating timer function: {str(e)}"
|
||||
}), 400
|
||||
|
||||
@timer.route('/edit/<int:function_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit(function_id):
|
||||
if request.method == 'GET':
|
||||
# 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
|
||||
FROM timer_functions
|
||||
WHERE id = %s AND user_id = %s
|
||||
""", [function_id, current_user.id], one=True)
|
||||
|
||||
if not timer_function:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Timer function not found"
|
||||
}), 404
|
||||
|
||||
# Format the environment JSON with indentation
|
||||
timer_function['environment'] = json.dumps(timer_function['environment'], indent=2)
|
||||
|
||||
args = {
|
||||
'function_id': function_id,
|
||||
'timer_function': timer_function
|
||||
}
|
||||
|
||||
if htmx:
|
||||
return render_block(environment, 'dashboard/timer_functions/edit.html', 'page', **args)
|
||||
return render_template('dashboard/timer_functions/edit.html', **args)
|
||||
|
||||
# Handle POST request
|
||||
try:
|
||||
data = request.json
|
||||
trigger_type = data.get('trigger_type')
|
||||
|
||||
# Validate trigger type
|
||||
if trigger_type not in ('interval', 'date'):
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Invalid trigger type"
|
||||
}), 400
|
||||
|
||||
# Calculate next_run based on trigger type
|
||||
next_run = 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
|
||||
|
||||
# Update timer function
|
||||
db.execute("""
|
||||
UPDATE timer_functions
|
||||
SET name = %s,
|
||||
code = %s,
|
||||
environment = %s::jsonb,
|
||||
trigger_type = %s,
|
||||
frequency_minutes = %s,
|
||||
run_date = %s,
|
||||
next_run = %s,
|
||||
enabled = %s
|
||||
WHERE id = %s AND user_id = %s
|
||||
RETURNING id
|
||||
""", [
|
||||
data.get('name'),
|
||||
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,
|
||||
next_run,
|
||||
data.get('is_enabled', True), # Default to True if not provided
|
||||
function_id,
|
||||
current_user.id
|
||||
],
|
||||
commit=True)
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": "Timer function updated successfully"
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": f"Error updating timer function: {str(e)}"
|
||||
}), 400
|
||||
|
||||
@timer.route('/delete/<int:function_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete(function_id):
|
||||
try:
|
||||
# Delete the timer function, but only if it belongs to the current user
|
||||
result = db.execute("""
|
||||
DELETE FROM timer_functions
|
||||
WHERE id = %s AND user_id = %s
|
||||
RETURNING id
|
||||
""", [function_id, current_user.id], commit=True)
|
||||
|
||||
if not result:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Timer function not found or unauthorized"
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": "Timer function deleted successfully"
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": f"Error deleting timer function: {str(e)}"
|
||||
}), 400
|
||||
|
||||
@timer.route('/toggle/<int:function_id>', methods=['POST'])
|
||||
@login_required
|
||||
def toggle(function_id):
|
||||
try:
|
||||
# Toggle the enabled status
|
||||
result = db.execute("""
|
||||
UPDATE timer_functions
|
||||
SET enabled = NOT enabled
|
||||
WHERE id = %s AND user_id = %s
|
||||
RETURNING enabled
|
||||
""", [function_id, current_user.id], commit=True, one=True)
|
||||
|
||||
if not result:
|
||||
return jsonify({
|
||||
"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
|
||||
FROM timer_functions
|
||||
WHERE user_id = %s
|
||||
ORDER BY id DESC
|
||||
""", [current_user.id])
|
||||
|
||||
if htmx:
|
||||
return render_block(environment, 'dashboard/timer_functions/overview.html', 'page',
|
||||
timer_functions=timer_functions)
|
||||
return render_template('dashboard/timer_functions/overview.html', timer_functions=timer_functions)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": f"Error toggling timer function: {str(e)}"
|
||||
}), 400
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,20 @@ const Editor = {
|
||||
this.deleteUrl = vnode.attrs.deleteUrl;
|
||||
|
||||
this.dashboardUrl = vnode.attrs.dashboardUrl;
|
||||
|
||||
// New timer-specific props
|
||||
this.isTimer = vnode.attrs.isTimer || false;
|
||||
this.triggerType = vnode.attrs.triggerType || 'interval'; // 'interval' or 'date'
|
||||
this.frequencyMinutes = vnode.attrs.frequencyMinutes || 60;
|
||||
this.runDate = vnode.attrs.runDate || '';
|
||||
|
||||
// Show timer settings panel
|
||||
this.showTimerSettings = vnode.attrs.showTimerSettings === true; // default false
|
||||
|
||||
this.cancelUrl = vnode.attrs.cancelUrl || '/dashboard/http_functions';
|
||||
|
||||
// Add enabled property for timer functions
|
||||
this.isEnabled = vnode.attrs.isEnabled !== false; // default true
|
||||
},
|
||||
|
||||
oncreate() {
|
||||
@@ -122,7 +136,7 @@ const Editor = {
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
let payload = {
|
||||
name: this.name,
|
||||
script_content: this.jsValue,
|
||||
environment_info: this.jsonValue,
|
||||
@@ -131,6 +145,21 @@ const Editor = {
|
||||
log_response: this.logResponse
|
||||
};
|
||||
|
||||
// Create payload based on whether this is a timer function
|
||||
payload = this.isTimer ? {
|
||||
name: this.name,
|
||||
script_content: this.jsValue,
|
||||
environment_info: this.jsonValue,
|
||||
trigger_type: this.triggerType,
|
||||
frequency_minutes: this.triggerType === 'interval' ? parseInt(this.frequencyMinutes) : null,
|
||||
run_date: this.triggerType === 'date' ? this.runDate : null,
|
||||
is_enabled: this.isEnabled // Add enabled status to payload
|
||||
} : {
|
||||
name: this.name,
|
||||
script_content: this.jsValue,
|
||||
environment_info: this.jsonValue
|
||||
};
|
||||
|
||||
const response = await m.request({
|
||||
method: 'POST',
|
||||
url: this.saveUrl,
|
||||
@@ -175,7 +204,7 @@ const Editor = {
|
||||
if (response.status === 'success') {
|
||||
Alert.show(response.message || 'Function deleted successfully!', 'success');
|
||||
// Optionally redirect to a different page after deletion
|
||||
window.location.href = '/dashboard/http_functions';
|
||||
window.location.href = this.cancelUrl;
|
||||
} else {
|
||||
Alert.show(response.message || 'Error deleting function', 'error');
|
||||
this.error = new Error(response.message);
|
||||
@@ -476,21 +505,85 @@ const Editor = {
|
||||
])
|
||||
]),
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
// Timer settings panel (shown only if isTimer is true)
|
||||
this.isTimer && this.showTimerSettings && m("div", {
|
||||
class: "bg-gray-100 dark:bg-gray-800 p-4 border-b"
|
||||
}, [
|
||||
m("div", { class: "flex flex-col space-y-4" }, [
|
||||
// Add Enabled toggle at the top
|
||||
m("label", {
|
||||
class: "flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-300 cursor-pointer mb-4"
|
||||
}, [
|
||||
m("div", { class: "relative" }, [
|
||||
m("input[type=checkbox]", {
|
||||
class: "sr-only peer",
|
||||
checked: this.isEnabled,
|
||||
onchange: (e) => this.isEnabled = e.target.checked
|
||||
}),
|
||||
m("div", {
|
||||
class: "w-11 h-6 bg-gray-200 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"
|
||||
})
|
||||
]),
|
||||
m("span", "Enabled")
|
||||
]),
|
||||
|
||||
// Trigger Type Selection
|
||||
m("div", { class: "flex flex-col space-y-2" }, [
|
||||
m("label", { class: "text-sm font-medium text-gray-700 dark:text-gray-300" },
|
||||
"Trigger Type"
|
||||
),
|
||||
m("select", {
|
||||
class: "bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2",
|
||||
value: this.triggerType,
|
||||
onchange: (e) => this.triggerType = e.target.value
|
||||
}, [
|
||||
m("option", { value: "interval" }, "Interval"),
|
||||
m("option", { value: "date" }, "Specific Date")
|
||||
])
|
||||
]),
|
||||
|
||||
// Interval Settings (shown only if triggerType is 'interval')
|
||||
this.triggerType === 'interval' && m("div", { class: "flex flex-col space-y-2" }, [
|
||||
m("label", { class: "text-sm font-medium text-gray-700 dark:text-gray-300" },
|
||||
"Frequency (minutes)"
|
||||
),
|
||||
m("input[type=number]", {
|
||||
class: "bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2",
|
||||
value: this.frequencyMinutes,
|
||||
min: 1,
|
||||
onchange: (e) => this.frequencyMinutes = e.target.value
|
||||
})
|
||||
]),
|
||||
|
||||
// Date Settings (shown only if triggerType is 'date')
|
||||
this.triggerType === 'date' && m("div", { class: "flex flex-col space-y-2" }, [
|
||||
m("label", { class: "text-sm font-medium text-gray-700 dark:text-gray-300" },
|
||||
"Run Date"
|
||||
),
|
||||
m("input[type=datetime-local]", {
|
||||
class: "bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2",
|
||||
value: this.runDate,
|
||||
onchange: (e) => this.runDate = e.target.value
|
||||
})
|
||||
])
|
||||
])
|
||||
]),
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
ResponseView (child) if needed
|
||||
─────────────────────────────────────────────────────────────────*/
|
||||
!this.executeLoading &&
|
||||
!this.error &&
|
||||
this.response &&
|
||||
m(ResponseView, {
|
||||
response: this.response,
|
||||
responseTime: this.responseTime,
|
||||
responseSize: this.responseSize,
|
||||
envEditorValue: this.jsonValue,
|
||||
onClose: () => {
|
||||
this.response = null;
|
||||
},
|
||||
}),
|
||||
!this.executeLoading &&
|
||||
!this.error &&
|
||||
this.response &&
|
||||
m(ResponseView, {
|
||||
response: this.response,
|
||||
responseTime: this.responseTime,
|
||||
responseSize: this.responseSize,
|
||||
envEditorValue: this.jsonValue,
|
||||
onClose: () => {
|
||||
this.response = null;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -315,8 +315,8 @@ const ResponseView = {
|
||||
: ""),
|
||||
},
|
||||
response.status === "SUCCESS"
|
||||
? m.trust(response.result.body)
|
||||
: JSON.stringify(response.result)
|
||||
? m.trust(response?.result?.body)
|
||||
: JSON.stringify(response?.result)
|
||||
),
|
||||
]),
|
||||
|
||||
|
||||
42
templates/dashboard/timer_functions/edit.html
Normal file
42
templates/dashboard/timer_functions/edit.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends 'dashboard.html' %}
|
||||
|
||||
{% block page %}
|
||||
|
||||
{{ render_partial('dashboard/http_functions/header.html', function_id=function_id,
|
||||
active_tab='edit',
|
||||
show_edit_form=True,
|
||||
show_logs=True,
|
||||
show_client=True,
|
||||
show_history=True,
|
||||
edit_url=url_for('timer.edit', function_id=function_id),
|
||||
cancel_url=url_for('timer.overview')) }}
|
||||
|
||||
|
||||
<div id="app" class="p-1">
|
||||
<!-- The Editor component will be mounted here -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Mount the component
|
||||
m.mount(document.getElementById("app"), {
|
||||
view: () => m(Editor, {
|
||||
name: '{{ timer_function.name }}',
|
||||
functionId: {{ timer_function.id }},
|
||||
jsValue: {{ timer_function.code | tojson | safe }},
|
||||
jsonValue: {{ timer_function.environment | tojson | safe }},
|
||||
isEdit: true,
|
||||
showHeader: true,
|
||||
versionNumber: {{ timer_function.version_number }},
|
||||
isEnabled: {{ timer_function.enabled | tojson }},
|
||||
executeUrl: "{{ url_for('execute_code', playground='true') }}",
|
||||
saveUrl: "{{ url_for('timer.edit', function_id=function_id) }}",
|
||||
deleteUrl: "{{ url_for('timer.delete', function_id=function_id) }}",
|
||||
showDeleteButton: true,
|
||||
isTimer: true,
|
||||
showTimerSettings: true,
|
||||
cancelUrl: "{{ url_for('timer.overview') }}"
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
42
templates/dashboard/timer_functions/new.html
Normal file
42
templates/dashboard/timer_functions/new.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends 'dashboard.html' %}
|
||||
|
||||
{% block page %}
|
||||
|
||||
{{ render_partial('dashboard/http_functions/header.html',
|
||||
user_id=user_id,
|
||||
show_name=False,
|
||||
show_refresh=False,
|
||||
show_logs=False,
|
||||
show_client=False,
|
||||
show_link=False,
|
||||
dashboardUrl=url_for('timer.overview'),
|
||||
title='New Timer Function')
|
||||
}}
|
||||
|
||||
<div id="app" class="p-1">
|
||||
<!-- The Editor component will be mounted here -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Mount the component
|
||||
m.mount(document.getElementById("app"), {
|
||||
view: () => m(Editor, {
|
||||
name: '{{ name }}',
|
||||
jsValue: {{ script | tojson | safe }},
|
||||
jsonValue: {{ environment_info | tojson | safe }},
|
||||
isEdit: false,
|
||||
isAdd: true,
|
||||
showHeader: true,
|
||||
executeUrl: "{{ url_for('execute_code', playground='true') }}",
|
||||
saveUrl: "{{ url_for('timer.new') }}",
|
||||
showDeleteButton: false,
|
||||
dashboardUrl: "{{ url_for('timer.overview') }}",
|
||||
isTimer: true,
|
||||
showTimerSettings: true,
|
||||
triggerType: 'interval',
|
||||
frequencyMinutes: 60
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -3,12 +3,11 @@
|
||||
{% block page %}
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center mb-6" data-id="51">
|
||||
<div class="flex items-center justify-between mb-8" data-id="51">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Timer Functions</h1>
|
||||
<button
|
||||
class="inline-flex items-center px-4 py-2 ml-auto bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors duration-200"
|
||||
hx-get="{{ url_for('get_http_function_add_form') }}" hx-target="#container" hx-swap="innerHTML"
|
||||
hx-push-url="true">
|
||||
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors duration-200"
|
||||
hx-get="{{ url_for('timer.new') }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-5 h-5 mr-2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path>
|
||||
@@ -17,96 +16,124 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden min-h-[200px]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 border-b border-gray-200">
|
||||
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Name</th>
|
||||
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">URL
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900 hidden md:table-cell">Actions
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Schedule</th>
|
||||
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Next Run</th>
|
||||
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{% for function in http_functions %}
|
||||
{% for function in timer_functions %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2 cursor-pointer"
|
||||
hx-get="{{ url_for('http_function_editor', function_id=function.id) }}"
|
||||
hx-target="#container" hx-swap="innerHTML" hx-push-url="true">
|
||||
hx-get="{{ url_for('timer.edit', function_id=function.id) }}" hx-target="#container"
|
||||
hx-swap="innerHTML" hx-push-url="true">
|
||||
<span class="font-medium text-gray-900">{{ function.name }}</span>
|
||||
<span
|
||||
class="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
#{{ function.invoked_count }}
|
||||
</span>
|
||||
{% if function.last_run %}
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
v{{ function.version_number }}
|
||||
</span>
|
||||
{% if function.is_public %}
|
||||
<span class="text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M13.5 10.5V6.75a4.5 4.5 0 1 1 9 0v3.75M3.75 21.75h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H3.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
Last run: {{ function.last_run.strftime('%Y-%m-%d %H:%M') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<a href="{{ url_for('execute_http_function', user_id=function.user_id, function=function.name) }}"
|
||||
class="text-blue-600 hover:text-blue-800">
|
||||
{{ url_for('execute_http_function', user_id=function.user_id,
|
||||
function=function.name) }}
|
||||
</a>
|
||||
<button class="ml-2 text-gray-400 hover:text-gray-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% if function.trigger_type == 'interval' %}
|
||||
<span class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 mr-1.5 text-gray-500" 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">
|
||||
<svg class="w-4 h-4 mr-1.5 text-gray-500" 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 %}
|
||||
</td>
|
||||
<td class="px-6 py-4 hidden md:table-cell">
|
||||
<div class="flex gap-3">
|
||||
<td class="px-6 py-4">
|
||||
{% if function.next_run %}
|
||||
<span class="text-gray-900">{{ function.next_run.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-500">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if function.enabled %}
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<svg class="w-2 h-2 mr-1.5 fill-current" viewBox="0 0 8 8">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
Active
|
||||
</span>
|
||||
{% else %}
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<svg class="w-2 h-2 mr-1.5 fill-current" viewBox="0 0 8 8">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
Paused
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-indigo-700 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-200"
|
||||
hx-get="{{ url_for('get_http_function_logs', function_id=function.id) }}"
|
||||
class="inline-flex items-center p-1.5 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100"
|
||||
title="Edit" hx-get="{{ url_for('timer.edit', function_id=function.id) }}"
|
||||
hx-target="#container" hx-swap="innerHTML" hx-push-url="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-1.5" fill="none"
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-indigo-700 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-200"
|
||||
hx-get="{{ url_for('client', function_id=function.id) }}" hx-target="#container"
|
||||
hx-swap="innerHTML" hx-push-url="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-1.5" fill="none"
|
||||
class="inline-flex items-center p-1.5 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100"
|
||||
title="{{ 'Pause' if function.enabled else 'Resume' }}"
|
||||
hx-post="{{ url_for('timer.toggle', function_id=function.id) }}"
|
||||
hx-target="#container" hx-swap="innerHTML">
|
||||
{% if function.enabled %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Try
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if http_functions|length == 0 %}
|
||||
{% if timer_functions|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="3" class="px-6 py-8 text-center">
|
||||
<td colspan="3" class="px-6 py-16 text-center">
|
||||
<p class="text-gray-500 text-lg">No functions found</p>
|
||||
<p class="text-gray-400 text-sm mt-2">Click the "Add Function" button to create your first
|
||||
function</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user