Add mutable datastores that can be linked to multiple functions

This commit is contained in:
Peter Stockings
2025-11-30 13:10:53 +11:00
parent bb20146143
commit b4cda2f4c4
5 changed files with 976 additions and 7 deletions

43
app.py
View File

@@ -18,6 +18,7 @@ from routes.llm import llm
from routes.auth import auth
from routes.settings import settings
from routes.community import community
from routes.shared_env import shared_env
from constants import DEFAULT_FUNCTION_NAME, DEFAULT_SCRIPT, DEFAULT_ENVIRONMENT
from flask_apscheduler import APScheduler
import asyncio
@@ -47,6 +48,7 @@ app.register_blueprint(llm, url_prefix='/llm')
app.register_blueprint(auth, url_prefix='/auth')
app.register_blueprint(settings, url_prefix='/settings')
app.register_blueprint(community, url_prefix='/community')
app.register_blueprint(shared_env, url_prefix='/shared_env')
# Swith to inter app routing, which results in speed up from ~400ms to ~270ms
# https://stackoverflow.com/questions/76886643/linking-two-not-exposed-dokku-apps
@@ -212,6 +214,24 @@ async def execute_http_function(user_id, function):
if request.data and not request.is_json:
request_data['text'] = request.data.decode('utf-8')
# Load and inject shared environments (namespaced)
shared_envs = db.execute('''
SELECT se.id, se.name, se.environment
FROM http_function_shared_envs hfse
JOIN shared_environments se ON hfse.shared_env_id = se.id
WHERE hfse.http_function_id = %s
ORDER BY se.name
''', [http_function['id']])
# Inject shared environments as nested objects
combined_environment = environment.copy()
shared_env_map = {} # Track shared env IDs for later extraction
if shared_envs:
for se in shared_envs:
env_data = json.loads(se['environment']) if isinstance(se['environment'], str) else se['environment']
combined_environment[se['name']] = env_data
shared_env_map[se['name']] = se['id']
# Call the Node.js API asynchronously
if runtime == 'deno':
api_url = DENO_API_URL
@@ -220,10 +240,29 @@ async def execute_http_function(user_id, function):
else:
api_url = NODE_API_URL
async with aiohttp.ClientSession() as session:
async with session.post(api_url, json={'code': code, 'request': request_data, 'environment': environment, 'name': function_name}) as response:
async with session.post(api_url, json={'code': code, 'request': request_data, 'environment': combined_environment, 'name': function_name}) as response:
response_data = await response.json()
db.update_http_function_environment_info_and_invoked_count(user_id, function_name, response_data['environment'])
# Extract and persist shared environment mutations
returned_env = response_data['environment']
function_specific_env = {}
# Separate function-specific properties from shared environments
for key, value in returned_env.items():
if key in shared_env_map:
# This is a shared environment - save it back
db.execute(
'UPDATE shared_environments SET environment=%s, updated_at=NOW() WHERE id=%s',
[json.dumps(value), shared_env_map[key]],
commit=True
)
else:
# This is function-specific - keep it
function_specific_env[key] = value
# Update function's own environment (without shared envs)
db.update_http_function_environment_info_and_invoked_count(user_id, function_name, function_specific_env)
db.add_http_function_invocation(
http_function['id'],
response_data['status'],

417
routes/shared_env.py Normal file
View File

@@ -0,0 +1,417 @@
from flask import Blueprint, request, jsonify, render_template
from flask_login import login_required, current_user
from extensions import db, htmx
from jinja2_fragments import render_block
import json
'''
-- 1. Create shared_environments table
CREATE TABLE IF NOT EXISTS shared_environments (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
environment JSONB NOT NULL DEFAULT '{}',
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_shared_env_user
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE,
CONSTRAINT unique_shared_env_name_per_user
UNIQUE (user_id, name)
);
CREATE INDEX idx_shared_env_user ON shared_environments(user_id);
-- 2. Create junction table for HTTP functions
CREATE TABLE IF NOT EXISTS http_function_shared_envs (
id SERIAL PRIMARY KEY,
http_function_id INT NOT NULL,
shared_env_id INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_http_function
FOREIGN KEY (http_function_id)
REFERENCES http_functions (id)
ON DELETE CASCADE,
CONSTRAINT fk_shared_env_http
FOREIGN KEY (shared_env_id)
REFERENCES shared_environments (id)
ON DELETE CASCADE,
CONSTRAINT unique_http_function_shared_env
UNIQUE (http_function_id, shared_env_id)
);
CREATE INDEX idx_http_func_shared_env ON http_function_shared_envs(http_function_id);
-- 3. Create junction table for Timer functions
CREATE TABLE IF NOT EXISTS timer_function_shared_envs (
id SERIAL PRIMARY KEY,
timer_function_id INT NOT NULL,
shared_env_id INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_timer_function
FOREIGN KEY (timer_function_id)
REFERENCES timer_functions (id)
ON DELETE CASCADE,
CONSTRAINT fk_shared_env_timer
FOREIGN KEY (shared_env_id)
REFERENCES shared_environments (id)
ON DELETE CASCADE,
CONSTRAINT unique_timer_function_shared_env
UNIQUE (timer_function_id, shared_env_id)
);
CREATE INDEX idx_timer_func_shared_env ON timer_function_shared_envs(timer_function_id);
'''
shared_env = Blueprint('shared_env', __name__)
@shared_env.route('/', methods=['GET'])
@login_required
def list_shared_environments():
"""List all shared environments for the current user"""
envs = db.execute(
'SELECT id, name, environment, description, created_at, updated_at FROM shared_environments WHERE user_id=%s ORDER BY name',
[current_user.id]
)
# Check if HTMX request
if request.headers.get('HX-Request'):
return render_template('dashboard/shared_environments/index.html', environments=envs)
# For API/fetch requests, return JSON
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
return jsonify(envs if envs else [])
# For regular page loads
return render_template('dashboard/shared_environments/index.html', environments=envs)
@shared_env.route('/new', methods=['GET', 'POST'])
@login_required
def create():
"""Create a new shared environment"""
if request.method == 'GET':
# Show creation form
if htmx:
return render_block('environment', 'dashboard/shared_environments/new.html', 'page')
return render_template('dashboard/shared_environments/new.html')
# Handle POST - create new shared environment
try:
data = request.json
name = data.get('name')
environment = data.get('environment')
description = data.get('description', '')
# Validate name
if not name:
return jsonify({'status': 'error', 'message': 'Name is required'}), 400
# Validate environment JSON
if isinstance(environment, str):
try:
environment_dict = json.loads(environment)
except json.JSONDecodeError:
return jsonify({'status': 'error', 'message': 'Invalid JSON in environment'}), 400
else:
environment_dict = environment
# Create shared environment
result = db.execute(
'INSERT INTO shared_environments (user_id, name, environment, description) VALUES (%s, %s, %s, %s) RETURNING id',
[current_user.id, name, json.dumps(environment_dict), description],
commit=True,
one=True
)
env_id = result['id'] if result else None
return jsonify({
'status': 'success',
'message': f'Shared environment "{name}" created successfully',
'id': env_id
}), 201
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@shared_env.route('/<int:env_id>', methods=['GET'])
@login_required
def get(env_id):
"""Get a specific shared environment"""
env = db.execute(
'SELECT id, name, environment, description FROM shared_environments WHERE id=%s AND user_id=%s',
[env_id, current_user.id],
one=True
)
if not env:
return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404
return jsonify(env)
@shared_env.route('/<int:env_id>', methods=['PUT'])
@login_required
def update(env_id):
"""Update a shared environment"""
try:
# Verify ownership
existing = db.execute(
'SELECT id, name FROM shared_environments WHERE id=%s AND user_id=%s',
[env_id, current_user.id],
one=True
)
if not existing:
return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404
data = request.json
name = data.get('name')
environment = data.get('environment')
description = data.get('description', '')
# Validate environment JSON
if isinstance(environment, str):
try:
environment_dict = json.loads(environment)
except json.JSONDecodeError:
return jsonify({'status': 'error', 'message': 'Invalid JSON in environment'}), 400
else:
environment_dict = environment
db.execute(
'UPDATE shared_environments SET name=%s, environment=%s, description=%s, updated_at=NOW() WHERE id=%s AND user_id=%s',
[name, json.dumps(environment_dict), description, env_id, current_user.id],
commit=True
)
return jsonify({
'status': 'success',
'message': f'Shared environment "{name}" updated successfully'
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@shared_env.route('/<int:env_id>', methods=['DELETE'])
@login_required
def delete(env_id):
"""Delete a shared environment"""
try:
# Verify ownership
existing = db.execute(
'SELECT name FROM shared_environments WHERE id=%s AND user_id=%s',
[env_id, current_user.id],
one=True
)
if not existing:
return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404
db.execute(
'DELETE FROM shared_environments WHERE id=%s AND user_id=%s',
[env_id, current_user.id],
commit=True
)
return jsonify({
'status': 'success',
'message': f'Shared environment "{existing["name"]}" deleted successfully'
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@shared_env.route('/<int:env_id>/linked-functions', methods=['GET'])
@login_required
def get_linked_functions(env_id):
"""Get all functions linked to this shared environment"""
try:
# Verify ownership
existing = db.execute(
'SELECT id FROM shared_environments WHERE id=%s AND user_id=%s',
[env_id, current_user.id],
one=True
)
if not existing:
return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404
# Get linked HTTP functions
http_functions = db.execute('''
SELECT hf.id, hf.name, 'http' as type
FROM http_function_shared_envs hfse
JOIN http_functions hf ON hfse.http_function_id = hf.id
WHERE hfse.shared_env_id = %s AND hf.user_id = %s
ORDER BY hf.name
''', [env_id, current_user.id])
# Get linked Timer functions
timer_functions = db.execute('''
SELECT tf.id, tf.name, 'timer' as type
FROM timer_function_shared_envs tfse
JOIN timer_functions tf ON tfse.timer_function_id = tf.id
WHERE tfse.shared_env_id = %s AND tf.user_id = %s
ORDER BY tf.name
''', [env_id, current_user.id])
return jsonify({
'http_functions': http_functions if http_functions else [],
'timer_functions': timer_functions if timer_functions else []
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@shared_env.route('/<int:env_id>/available-functions', methods=['GET'])
@login_required
def get_available_functions(env_id):
"""Get all functions that can be linked (not already linked)"""
try:
# Get all HTTP functions for this user
all_http = db.execute(
'SELECT id, name FROM http_functions WHERE user_id=%s ORDER BY name',
[current_user.id]
)
# Get already linked HTTP functions
linked_http = db.execute('''
SELECT http_function_id
FROM http_function_shared_envs
WHERE shared_env_id = %s
''', [env_id])
linked_http_ids = [row['http_function_id'] for row in (linked_http or [])]
# Filter out already linked
available_http = [func for func in (all_http or []) if func['id'] not in linked_http_ids]
# Get all Timer functions for this user
all_timer = db.execute(
'SELECT id, name FROM timer_functions WHERE user_id=%s ORDER BY name',
[current_user.id]
)
# Get already linked Timer functions
linked_timer = db.execute('''
SELECT timer_function_id
FROM timer_function_shared_envs
WHERE shared_env_id = %s
''', [env_id])
linked_timer_ids = [row['timer_function_id'] for row in (linked_timer or [])]
# Filter out already linked
available_timer = [func for func in (all_timer or []) if func['id'] not in linked_timer_ids]
return jsonify({
'http_functions': available_http,
'timer_functions': available_timer
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@shared_env.route('/<int:env_id>/link-function', methods=['POST'])
@login_required
def link_function(env_id):
"""Link a function to this shared environment"""
try:
# Verify ownership
existing = db.execute(
'SELECT id FROM shared_environments WHERE id=%s AND user_id=%s',
[env_id, current_user.id],
one=True
)
if not existing:
return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404
data = request.json
function_id = data.get('function_id')
function_type = data.get('function_type') # 'http' or 'timer'
if not function_id or not function_type:
return jsonify({'status': 'error', 'message': 'Missing function_id or function_type'}), 400
if function_type == 'http':
# Verify function ownership
func = db.execute(
'SELECT id FROM http_functions WHERE id=%s AND user_id=%s',
[function_id, current_user.id],
one=True
)
if not func:
return jsonify({'status': 'error', 'message': 'Function not found'}), 404
# Link it
db.execute(
'INSERT INTO http_function_shared_envs (http_function_id, shared_env_id) VALUES (%s, %s) ON CONFLICT DO NOTHING',
[function_id, env_id],
commit=True
)
elif function_type == 'timer':
# Verify function ownership
func = db.execute(
'SELECT id FROM timer_functions WHERE id=%s AND user_id=%s',
[function_id, current_user.id],
one=True
)
if not func:
return jsonify({'status': 'error', 'message': 'Function not found'}), 404
# Link it
db.execute(
'INSERT INTO timer_function_shared_envs (timer_function_id, shared_env_id) VALUES (%s, %s) ON CONFLICT DO NOTHING',
[function_id, env_id],
commit=True
)
else:
return jsonify({'status': 'error', 'message': 'Invalid function_type'}), 400
return jsonify({'status': 'success', 'message': 'Function linked successfully'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@shared_env.route('/<int:env_id>/unlink-function', methods=['POST'])
@login_required
def unlink_function(env_id):
"""Unlink a function from this shared environment"""
try:
data = request.json
function_id = data.get('function_id')
function_type = data.get('function_type') # 'http' or 'timer'
if not function_id or not function_type:
return jsonify({'status': 'error', 'message': 'Missing function_id or function_type'}), 400
if function_type == 'http':
db.execute(
'DELETE FROM http_function_shared_envs WHERE http_function_id=%s AND shared_env_id=%s',
[function_id, env_id],
commit=True
)
elif function_type == 'timer':
db.execute(
'DELETE FROM timer_function_shared_envs WHERE timer_function_id=%s AND shared_env_id=%s',
[function_id, env_id],
commit=True
)
else:
return jsonify({'status': 'error', 'message': 'Invalid function_type'}), 400
return jsonify({'status': 'success', 'message': 'Function unlinked successfully'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500

View File

@@ -53,6 +53,16 @@
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Scheduled Jobs
</a>
<a class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-gray-600 transition-all hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-50 dark:hover:bg-gray-800 cursor-pointer group"
href="{{ url_for('shared_env.list_shared_environments') }}">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor"
class="group-hover:text-blue-600 transition-colors">
<path stroke-linecap="round" stroke-linejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
Shared Environments
</a>
<div class="my-2 border-t border-gray-100 dark:border-gray-800"></div>
@@ -243,7 +253,17 @@
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Scheduled Jobs
</a>
</a>
<a class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-gray-600 transition-all hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-50 dark:hover:bg-gray-800 cursor-pointer group"
href="{{ url_for('shared_env.list_shared_environments') }}">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor"
class="group-hover:text-blue-600 transition-colors">
<path stroke-linecap="round" stroke-linejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
Shared Environments
</a>
<div class="my-2 border-t border-gray-100 dark:border-gray-800"></div>
<a class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-gray-600 transition-all hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-50 dark:hover:bg-gray-800"
href="{{ url_for('community.index') }}">
@@ -305,4 +325,4 @@
if (mobileSidebarOverlay) mobileSidebarOverlay.addEventListener('click', toggleSidebar);
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,458 @@
{% extends 'dashboard.html' %}
{% block page %}
<div class="container mx-auto p-6 max-w-6xl">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Shared Environments</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage reusable environment configurations that can be
injected into your functions</p>
</div>
<button onclick="openCreateModal()"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
<span>New Shared Environment</span>
</button>
</div>
{% if environments and environments|length > 0 %}
<div class="grid gap-4">
{% for env in environments %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-1">{{ env.name }}</h3>
{% if env.description %}
<p class="text-gray-600 dark:text-gray-400 text-sm mb-3">{{ env.description }}</p>
{% endif %}
<div class="bg-gray-50 dark:bg-gray-900 rounded p-3 font-mono text-sm overflow-x-auto">
<pre class="text-gray-800 dark:text-gray-200">{{ env.environment | tojson(indent=2) }}</pre>
</div>
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
Created: {{ env.created_at.strftime('%Y-%m-%d %H:%M') if env.created_at else 'Unknown' }}
{% if env.updated_at and env.updated_at != env.created_at %}
| Updated: {{ env.updated_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
</div>
<!-- Linked Functions Section -->
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<button onclick="toggleLinkedFunctions({{ env.id }})"
class="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
<svg id="expand-icon-{{ env.id }}" class="w-4 h-4 transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7"></path>
</svg>
<span id="linked-count-{{ env.id }}">🔗 Linked Functions (loading...)</span>
</button>
<button onclick="openLinkModal({{ env.id }}, '{{ env.name }}')"
class="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center space-x-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4"></path>
</svg>
<span>Link Function</span>
</button>
</div>
<div id="linked-functions-{{ env.id }}" class="hidden mt-3 space-y-2">
<div class="text-sm text-gray-500 dark:text-gray-400">Loading...</div>
</div>
</div>
</div>
<div class="flex space-x-2 ml-4">
<button
onclick="editEnvironment({{ env.id }}, '{{ env.name }}', {{ env.environment | tojson }}, '{{ env.description or '' }}')"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-2"
title="Edit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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">
</path>
</svg>
</button>
<button onclick="deleteEnvironment({{ env.id }}, '{{ env.name }}')"
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-2"
title="Delete">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
</path>
</svg>
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div
class="text-center py-12 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-700">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4">
</path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No shared environments</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by creating a new shared environment</p>
<div class="mt-6">
<button onclick="openCreateModal()"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
New Shared Environment
</button>
</div>
</div>
{% endif %}
</div>
<!-- Create/Edit Modal -->
<div id="envModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 id="modalTitle" class="text-lg font-medium text-gray-900 dark:text-white">New Shared Environment</h3>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12">
</path>
</svg>
</button>
</div>
<form id="envForm" class="space-y-4">
<input type="hidden" id="envId" value="">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name <span
class="text-red-500">*</span></label>
<input type="text" id="envName" required placeholder="e.g., apiConfig, database"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
<p class="mt-1 text-xs text-gray-500">This becomes the namespace key (e.g., environment.apiConfig)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<input type="text" id="envDescription" placeholder="Brief description of this shared environment"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Environment JSON <span
class="text-red-500">*</span></label>
<div id="envEditor" class="border border-gray-300 dark:border-gray-600 rounded-md"
style="height: 300px;"></div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" onclick="closeModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Cancel</button>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"><span
id="submitText">Create</span></button>
</div>
</form>
</div>
</div>
<!-- Link Function Modal -->
<div id="linkModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-full max-w-lg shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
Link Function to <span id="linkEnvName" class="text-blue-600 dark:text-blue-400"></span>
</h3>
<button onclick="closeLinkModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12">
</path>
</svg>
</button>
</div>
<div class="space-y-4">
<input type="hidden" id="linkEnvId" value="">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">HTTP Functions</label>
<select id="httpFunctionSelect"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
<option value="">Select an HTTP function...</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timer Functions</label>
<select id="timerFunctionSelect"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
<option value="">Select a timer function...</option>
</select>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button onclick="closeLinkModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">Cancel</button>
<button onclick="linkSelectedFunction()"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Link</button>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js"></script>
<script>
let envEditor;
let isEditMode = false;
let linkedFunctionsCache = {};
document.addEventListener('DOMContentLoaded', function () {
// Initialize Ace Editor
envEditor = ace.edit("envEditor");
envEditor.setTheme("ace/theme/github_dark");
envEditor.session.setMode("ace/mode/json");
envEditor.setValue('{\n \n}', -1);
envEditor.setOptions({
fontSize: "14px",
showPrintMargin: false
});
// Load linked functions for all environments
{% for env in environments %}
loadLinkedFunctions({{ env.id }});
{% endfor %}
});
// Environment CRUD Functions
function openCreateModal() {
isEditMode = false;
document.getElementById('modalTitle').textContent = 'New Shared Environment';
document.getElementById('submitText').textContent = 'Create';
document.getElementById('envId').value = '';
document.getElementById('envName').value = '';
document.getElementById('envDescription').value = '';
envEditor.setValue('{\n \n}', -1);
document.getElementById('envModal').classList.remove('hidden');
}
function editEnvironment(id, name, environment, description) {
isEditMode = true;
document.getElementById('modalTitle').textContent = 'Edit Shared Environment';
document.getElementById('submitText').textContent = 'Update';
document.getElementById('envId').value = id;
document.getElementById('envName').value = name;
document.getElementById('envDescription').value = description;
envEditor.setValue(JSON.stringify(environment, null, 2), -1);
document.getElementById('envModal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('envModal').classList.add('hidden');
}
document.getElementById('envForm').addEventListener('submit', async function (e) {
e.preventDefault();
const id = document.getElementById('envId').value;
const name = document.getElementById('envName').value.trim();
const description = document.getElementById('envDescription').value.trim();
const environment = envEditor.getValue().trim();
try {
JSON.parse(environment);
} catch (err) {
alert('Invalid JSON: ' + err.message);
return;
}
const url = isEditMode ? `/shared_env/${id}` : '/shared_env/new';
const method = isEditMode ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, environment })
});
const data = await response.json();
if (data.status === 'success') {
window.location.reload();
} else {
alert('Error: ' + data.message);
}
} catch (err) {
alert('Error: ' + err.message);
}
});
async function deleteEnvironment(id, name) {
if (!confirm(`Are you sure you want to delete "${name}"?\n\nThis will remove it from all linked functions.`)) {
return;
}
try {
const response = await fetch(`/shared_env/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.status === 'success') {
window.location.reload();
} else {
alert('Error: ' + data.message);
}
} catch (err) {
alert('Error: ' + err.message);
}
}
// Linked Functions UI
async function toggleLinkedFunctions(envId) {
const container = document.getElementById(`linked-functions-${envId}`);
const icon = document.getElementById(`expand-icon-${envId}`);
if (container.classList.contains('hidden')) {
container.classList.remove('hidden');
icon.style.transform = 'rotate(90deg)';
await loadLinkedFunctions(envId);
} else {
container.classList.add('hidden');
icon.style.transform = 'rotate(0deg)';
}
}
async function loadLinkedFunctions(envId) {
try {
const response = await fetch(`/shared_env/${envId}/linked-functions`);
const data = await response.json();
linkedFunctionsCache[envId] = data;
const httpFuncs = data.http_functions || [];
const timerFuncs = data.timer_functions || [];
const total = httpFuncs.length + timerFuncs.length;
// Update count
document.getElementById(`linked-count-${envId}`).textContent = `🔗 Linked Functions (${total})`;
// Update list
const container = document.getElementById(`linked-functions-${envId}`);
if (total === 0) {
container.innerHTML = '<div class="text-sm text-gray-500 dark:text-gray-400 italic">No functions linked yet</div>';
} else {
let html = '<div class="space-y-1">';
httpFuncs.forEach(func => {
html += `<div class="flex items-center justify-between bg-gray-100 dark:bg-gray-700 rounded px-3 py-2">
<div class="flex items-center space-x-2">
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">${func.name}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">(HTTP)</span>
</div>
<button onclick="unlinkFunction(${envId}, ${func.id}, 'http')" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>`;
});
timerFuncs.forEach(func => {
html += `<div class="flex items-center justify-between bg-gray-100 dark:bg-gray-700 rounded px-3 py-2">
<div class="flex items-center space-x-2">
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">${func.name}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">(Timer)</span>
</div>
<button onclick="unlinkFunction(${envId}, ${func.id}, 'timer')" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>`;
});
html += '</div>';
container.innerHTML = html;
}
} catch (err) {
console.error('Failed to load linked functions:', err);
}
}
// Link Modal Functions
async function openLinkModal(envId, envName) {
document.getElementById('linkEnvId').value = envId;
document.getElementById('linkEnvName').textContent = envName;
try {
const response = await fetch(`/shared_env/${envId}/available-functions`);
const data = await response.json();
const httpSelect = document.getElementById('httpFunctionSelect');
const timerSelect = document.getElementById('timerFunctionSelect');
httpSelect.innerHTML = '<option value="">Select an HTTP function...</option>';
(data.http_functions || []).forEach(func => {
httpSelect.innerHTML += `<option value="${func.id}">${func.name}</option>`;
});
timerSelect.innerHTML = '<option value="">Select a timer function...</option>';
(data.timer_functions || []).forEach(func => {
timerSelect.innerHTML += `<option value="${func.id}">${func.name}</option>`;
});
document.getElementById('linkModal').classList.remove('hidden');
} catch (err) {
alert('Error loading available functions: ' + err.message);
}
}
function closeLinkModal() {
document.getElementById('linkModal').classList.add('hidden');
document.getElementById('httpFunctionSelect').value = '';
document.getElementById('timerFunctionSelect').value = '';
}
async function linkSelectedFunction() {
const envId = document.getElementById('linkEnvId').value;
const httpFuncId = document.getElementById('httpFunctionSelect').value;
const timerFuncId = document.getElementById('timerFunctionSelect').value;
if (!httpFuncId && !timerFuncId) {
alert('Please select a function to link');
return;
}
const functionId = httpFuncId || timerFuncId;
const functionType = httpFuncId ? 'http' : 'timer';
try {
const response = await fetch(`/shared_env/${envId}/link-function`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ function_id: functionId, function_type: functionType })
});
const data = await response.json();
if (data.status === 'success') {
closeLinkModal();
await loadLinkedFunctions(envId);
} else {
alert('Error: ' + data.message);
}
} catch (err) {
alert('Error: ' + err.message);
}
}
async function unlinkFunction(envId, functionId, functionType) {
try {
const response = await fetch(`/shared_env/${envId}/unlink-function`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ function_id: functionId, function_type: functionType })
});
const data = await response.json();
if (data.status === 'success') {
await loadLinkedFunctions(envId);
} else {
alert('Error: ' + data.message);
}
} catch (err) {
alert('Error: ' + err.message);
}
}
</script>
{% endblock %}

View File

@@ -44,16 +44,51 @@ async def execute_timer_function_async(timer_function):
api_url = PYTHON_API_URL
else:
api_url = NODE_API_URL
# Load and inject shared environments (namespaced)
shared_envs = db.execute('''
SELECT se.id, se.name, se.environment
FROM timer_function_shared_envs tfse
JOIN shared_environments se ON tfse.shared_env_id = se.id
WHERE tfse.timer_function_id = %s
ORDER BY se.name
''', [timer_function['id']])
# Inject shared environments as nested objects
combined_environment = environment.copy() if environment else {}
shared_env_map = {} # Track shared env IDs for later extraction
if shared_envs:
for se in shared_envs:
env_data = json.loads(se['environment']) if isinstance(se['environment'], str) else se['environment']
combined_environment[se['name']] = env_data
shared_env_map[se['name']] = se['id']
async with aiohttp.ClientSession() as session:
async with session.post(api_url, json={
'code': code,
'request': {'method': 'TIMER'},
'environment': environment,
'environment': combined_environment,
'name': name
}) as response:
response_data = await response.json()
# Extract and persist shared environment mutations
returned_env = response_data['environment']
function_specific_env = {}
# Separate function-specific properties from shared environments
for key, value in returned_env.items():
if key in shared_env_map:
# This is a shared environment - save it back
db.execute(
'UPDATE shared_environments SET environment=%s, updated_at=NOW() WHERE id=%s',
[json.dumps(value), shared_env_map[key]],
commit=True
)
else:
# This is function-specific - keep it
function_specific_env[key] = value
# Update environment and record invocation
# Calculate next run time based on trigger type
next_run = None
@@ -74,7 +109,7 @@ async def execute_timer_function_async(timer_function):
last_run = NOW(),
next_run = %s
WHERE id = %s
""", [json.dumps(response_data['environment']), next_run, timer_function['id']], commit=True)
""", [json.dumps(function_specific_env), next_run, timer_function['id']], commit=True)
# Record the invocation
db.execute("""
@@ -162,4 +197,4 @@ if __name__ == '__main__':
try:
asyncio.get_event_loop().run_forever()
except (KeyboardInterrupt, SystemExit):
pass
pass