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 jinja2_fragments import render_block
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
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 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 = Blueprint('timer', __name__)
|
||||||
|
|
||||||
@timer.route('/overview')
|
@timer.route('/overview')
|
||||||
@login_required
|
@login_required
|
||||||
def overview():
|
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:
|
if htmx:
|
||||||
return render_block(environment, 'dashboard/timer_functions/overview.html', 'page')
|
return render_block(environment, 'dashboard/timer_functions/overview.html', 'page', timer_functions=timer_functions)
|
||||||
return render_template('dashboard/timer_functions/overview.html')
|
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.deleteUrl = vnode.attrs.deleteUrl;
|
||||||
|
|
||||||
this.dashboardUrl = vnode.attrs.dashboardUrl;
|
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() {
|
oncreate() {
|
||||||
@@ -122,7 +136,7 @@ const Editor = {
|
|||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
let payload = {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
script_content: this.jsValue,
|
script_content: this.jsValue,
|
||||||
environment_info: this.jsonValue,
|
environment_info: this.jsonValue,
|
||||||
@@ -131,6 +145,21 @@ const Editor = {
|
|||||||
log_response: this.logResponse
|
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({
|
const response = await m.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: this.saveUrl,
|
url: this.saveUrl,
|
||||||
@@ -175,7 +204,7 @@ const Editor = {
|
|||||||
if (response.status === 'success') {
|
if (response.status === 'success') {
|
||||||
Alert.show(response.message || 'Function deleted successfully!', 'success');
|
Alert.show(response.message || 'Function deleted successfully!', 'success');
|
||||||
// Optionally redirect to a different page after deletion
|
// Optionally redirect to a different page after deletion
|
||||||
window.location.href = '/dashboard/http_functions';
|
window.location.href = this.cancelUrl;
|
||||||
} else {
|
} else {
|
||||||
Alert.show(response.message || 'Error deleting function', 'error');
|
Alert.show(response.message || 'Error deleting function', 'error');
|
||||||
this.error = new Error(response.message);
|
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
|
ResponseView (child) if needed
|
||||||
─────────────────────────────────────────────────────────────────*/
|
─────────────────────────────────────────────────────────────────*/
|
||||||
!this.executeLoading &&
|
!this.executeLoading &&
|
||||||
!this.error &&
|
!this.error &&
|
||||||
this.response &&
|
this.response &&
|
||||||
m(ResponseView, {
|
m(ResponseView, {
|
||||||
response: this.response,
|
response: this.response,
|
||||||
responseTime: this.responseTime,
|
responseTime: this.responseTime,
|
||||||
responseSize: this.responseSize,
|
responseSize: this.responseSize,
|
||||||
envEditorValue: this.jsonValue,
|
envEditorValue: this.jsonValue,
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.response = null;
|
this.response = null;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -315,8 +315,8 @@ const ResponseView = {
|
|||||||
: ""),
|
: ""),
|
||||||
},
|
},
|
||||||
response.status === "SUCCESS"
|
response.status === "SUCCESS"
|
||||||
? m.trust(response.result.body)
|
? m.trust(response?.result?.body)
|
||||||
: JSON.stringify(response.result)
|
: 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 %}
|
{% block page %}
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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>
|
<h1 class="text-2xl font-bold text-gray-900">Timer Functions</h1>
|
||||||
<button
|
<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"
|
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('get_http_function_add_form') }}" hx-target="#container" hx-swap="innerHTML"
|
hx-get="{{ url_for('timer.new') }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true">
|
||||||
hx-push-url="true">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<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">
|
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>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path>
|
||||||
@@ -17,96 +16,124 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-50 border-b border-gray-200">
|
<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">Name</th>
|
||||||
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">URL
|
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Schedule</th>
|
||||||
</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 hidden md:table-cell">Actions
|
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||||
</th>
|
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200">
|
||||||
{% for function in http_functions %}
|
{% for function in timer_functions %}
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center gap-2 cursor-pointer"
|
<div class="flex items-center gap-2 cursor-pointer"
|
||||||
hx-get="{{ url_for('http_function_editor', function_id=function.id) }}"
|
hx-get="{{ url_for('timer.edit', function_id=function.id) }}" hx-target="#container"
|
||||||
hx-target="#container" hx-swap="innerHTML" hx-push-url="true">
|
hx-swap="innerHTML" hx-push-url="true">
|
||||||
<span class="font-medium text-gray-900">{{ function.name }}</span>
|
<span class="font-medium text-gray-900">{{ function.name }}</span>
|
||||||
<span
|
{% if function.last_run %}
|
||||||
class="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
|
||||||
#{{ function.invoked_count }}
|
|
||||||
</span>
|
|
||||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||||
v{{ function.version_number }}
|
Last run: {{ function.last_run.strftime('%Y-%m-%d %H:%M') }}
|
||||||
</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>
|
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center">
|
{% if function.trigger_type == 'interval' %}
|
||||||
<a href="{{ url_for('execute_http_function', user_id=function.user_id, function=function.name) }}"
|
<span class="inline-flex items-center">
|
||||||
class="text-blue-600 hover:text-blue-800">
|
<svg class="w-4 h-4 mr-1.5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||||
{{ url_for('execute_http_function', user_id=function.user_id,
|
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
function=function.name) }}
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
</a>
|
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" />
|
||||||
<button class="ml-2 text-gray-400 hover:text-gray-600">
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
Every {{ function.frequency_minutes }} minutes
|
||||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
</span>
|
||||||
stroke-linejoin="round">
|
{% else %}
|
||||||
<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>
|
<span class="inline-flex items-center">
|
||||||
<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 class="w-4 h-4 mr-1.5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||||
</svg>
|
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
</button>
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
</div>
|
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>
|
||||||
<td class="px-6 py-4 hidden md:table-cell">
|
<td class="px-6 py-4">
|
||||||
<div class="flex gap-3">
|
{% 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
|
<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"
|
class="inline-flex items-center p-1.5 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100"
|
||||||
hx-get="{{ url_for('get_http_function_logs', function_id=function.id) }}"
|
title="Edit" hx-get="{{ url_for('timer.edit', function_id=function.id) }}"
|
||||||
hx-target="#container" hx-swap="innerHTML" hx-push-url="true">
|
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">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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>
|
</svg>
|
||||||
Logs
|
|
||||||
</button>
|
</button>
|
||||||
<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"
|
class="inline-flex items-center p-1.5 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100"
|
||||||
hx-get="{{ url_for('client', function_id=function.id) }}" hx-target="#container"
|
title="{{ 'Pause' if function.enabled else 'Resume' }}"
|
||||||
hx-swap="innerHTML" hx-push-url="true">
|
hx-post="{{ url_for('timer.toggle', function_id=function.id) }}"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-1.5" fill="none"
|
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">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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" />
|
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"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
Try
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if http_functions|length == 0 %}
|
{% if timer_functions|length == 0 %}
|
||||||
<tr>
|
<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-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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user