Add support to use LLM's (gemni) to create functions, also create documentation page
This commit is contained in:
113
app.py
113
app.py
@@ -114,6 +114,10 @@ def map_isolator_response_to_flask_response(response):
|
|||||||
def home():
|
def home():
|
||||||
return render_template("home.html", name='Try me', script=DEFAULT_SCRIPT, environment_info=DEFAULT_ENVIRONMENT)
|
return render_template("home.html", name='Try me', script=DEFAULT_SCRIPT, environment_info=DEFAULT_ENVIRONMENT)
|
||||||
|
|
||||||
|
@app.route("/documentation", methods=["GET"])
|
||||||
|
def documentation():
|
||||||
|
return render_template("documentation.html")
|
||||||
|
|
||||||
@ app.route("/dashboard", methods=["GET"])
|
@ app.route("/dashboard", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
@@ -257,6 +261,115 @@ def get_http_function_history(function_id):
|
|||||||
return render_block(app.jinja_env, 'dashboard/http_functions/history.html', 'page', user_id=user_id, function_id=function_id, name=name, http_function=http_function, http_function_history=http_function_history, original_script=original_script)
|
return render_block(app.jinja_env, 'dashboard/http_functions/history.html', 'page', user_id=user_id, function_id=function_id, name=name, http_function=http_function, http_function_history=http_function_history, original_script=original_script)
|
||||||
return render_template("dashboard/http_functions/history.html", user_id=user_id, name=name, function_id=function_id, http_function=http_function, http_function_history=http_function_history, original_script=original_script)
|
return render_template("dashboard/http_functions/history.html", user_id=user_id, name=name, function_id=function_id, http_function=http_function, http_function_history=http_function_history, original_script=original_script)
|
||||||
|
|
||||||
|
def _generate_script_from_natural_language(natural_query):
|
||||||
|
"""Generates a Javascript function from natural language using Gemini REST API."""
|
||||||
|
gemni_model = os.environ.get("GEMINI_MODEL", "gemini-1.5-flash")
|
||||||
|
api_key = os.environ.get("GEMINI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
return None, "GEMINI_API_KEY environment variable not set."
|
||||||
|
|
||||||
|
api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{gemni_model}:generateContent?key={api_key}"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
prompt = f"""
|
||||||
|
You are an expert Javascript developer. Your task is to write a complete, production-ready Javascript async function based on the user's request.
|
||||||
|
|
||||||
|
**Environment & Constraints:**
|
||||||
|
- The function will be executed in a simple, sandboxed Javascript environment.
|
||||||
|
- **DO NOT** use `require()`, `import`, or any other module loading system. The environment does not support it.
|
||||||
|
- **DO NOT** access the file system (`fs` module) or make network requests.
|
||||||
|
- The function must be a single `async` arrow function.
|
||||||
|
- You have access to a persistent JSON object called `environment`. You can read from and write to it. For example: `environment.my_variable = 'hello'`.
|
||||||
|
- You also have access to a `console.log()` function for logging.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
The function receives one argument: `req`, an object containing details about the incoming HTTP request. It has properties like `req.method`, `req.headers`, `req.body`, `req.query`, etc.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
You must use one of the following helper functions to return a response:
|
||||||
|
- `HtmlResponse(body)`: Returns an HTML response.
|
||||||
|
- `JsonResponse(body)`: Returns a JSON response. The `body` will be automatically stringified.
|
||||||
|
- `TextResponse(body)`: Returns a plain text response.
|
||||||
|
- `RedirectResponse(url)`: Redirects the user to a different URL.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
async (req) => {{
|
||||||
|
if (!environment.counter) {{
|
||||||
|
environment.counter = 0;
|
||||||
|
}}
|
||||||
|
environment.counter++;
|
||||||
|
console.log("The counter is now " + environment.counter);
|
||||||
|
return HtmlResponse(`<h1>Counter: ${{environment.counter}}</h1>`);
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**User's request:** "{natural_query}"
|
||||||
|
|
||||||
|
Return ONLY the complete Javascript function code, without any explanation, comments, or surrounding text/markdown.
|
||||||
|
"""
|
||||||
|
payload = json.dumps({
|
||||||
|
"contents": [{"parts": [{"text": prompt}]}]
|
||||||
|
})
|
||||||
|
|
||||||
|
response = requests.post(api_url, headers=headers, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
response_data = response.json()
|
||||||
|
|
||||||
|
candidates = response_data.get('candidates', [])
|
||||||
|
if not candidates:
|
||||||
|
return None, "No candidates found in API response."
|
||||||
|
|
||||||
|
content = candidates[0].get('content', {})
|
||||||
|
parts = content.get('parts', [])
|
||||||
|
if not parts:
|
||||||
|
return None, "No parts found in API response content."
|
||||||
|
|
||||||
|
generated_script = parts[0].get('text', '').strip()
|
||||||
|
|
||||||
|
# More robustly extract from markdown
|
||||||
|
if generated_script.startswith("```javascript"):
|
||||||
|
generated_script = generated_script[12:]
|
||||||
|
if generated_script.endswith("```"):
|
||||||
|
generated_script = generated_script[:-3]
|
||||||
|
|
||||||
|
generated_script = generated_script.strip()
|
||||||
|
|
||||||
|
# Remove any leading non-code characters
|
||||||
|
if not generated_script.startswith('async ('):
|
||||||
|
async_start = generated_script.find('async (')
|
||||||
|
if async_start != -1:
|
||||||
|
generated_script = generated_script[async_start:]
|
||||||
|
|
||||||
|
return generated_script.strip(), None
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
app.logger.error(f"Gemini API request error: {e}")
|
||||||
|
return None, f"Error communicating with API: {e}"
|
||||||
|
except (KeyError, IndexError, Exception) as e:
|
||||||
|
app.logger.error(f"Error processing Gemini API response: {e} - Response: {response_data if 'response_data' in locals() else 'N/A'}")
|
||||||
|
return None, f"Error processing API response: {e}"
|
||||||
|
|
||||||
|
@app.route("/api/generate_script", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def generate_script():
|
||||||
|
try:
|
||||||
|
natural_query = request.json.get('natural_query')
|
||||||
|
if not natural_query:
|
||||||
|
return jsonify({"error": "natural_query is required"}), 400
|
||||||
|
|
||||||
|
script_content, error = _generate_script_from_natural_language(natural_query)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return jsonify({"error": error}), 500
|
||||||
|
|
||||||
|
return jsonify({"script_content": script_content})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
@app.route('/execute', methods=['POST'])
|
@app.route('/execute', methods=['POST'])
|
||||||
def execute_code():
|
def execute_code():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -58,17 +58,22 @@ const Editor = {
|
|||||||
|
|
||||||
// New timer-specific props
|
// New timer-specific props
|
||||||
this.isTimer = vnode.attrs.isTimer || false;
|
this.isTimer = vnode.attrs.isTimer || false;
|
||||||
this.triggerType = vnode.attrs.triggerType || 'interval'; // 'interval' or 'date'
|
this.triggerType = vnode.attrs.triggerType || "interval"; // 'interval' or 'date'
|
||||||
this.frequencyMinutes = vnode.attrs.frequencyMinutes || 60;
|
this.frequencyMinutes = vnode.attrs.frequencyMinutes || 60;
|
||||||
this.runDate = vnode.attrs.runDate || '';
|
this.runDate = vnode.attrs.runDate || "";
|
||||||
|
|
||||||
// Show timer settings panel
|
// Show timer settings panel
|
||||||
this.showTimerSettings = vnode.attrs.showTimerSettings === true; // default false
|
this.showTimerSettings = vnode.attrs.showTimerSettings === true; // default false
|
||||||
|
|
||||||
this.cancelUrl = vnode.attrs.cancelUrl || '/dashboard/http_functions';
|
this.cancelUrl = vnode.attrs.cancelUrl || "/dashboard/http_functions";
|
||||||
|
|
||||||
// Add enabled property for timer functions
|
// Add enabled property for timer functions
|
||||||
this.isEnabled = vnode.attrs.isEnabled !== false; // default true
|
this.isEnabled = vnode.attrs.isEnabled !== false; // default true
|
||||||
|
|
||||||
|
// New state for AI generation
|
||||||
|
this.naturalLanguageQuery = "";
|
||||||
|
this.generateLoading = false;
|
||||||
|
this.showNaturalLanguageQuery = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
oncreate() {
|
oncreate() {
|
||||||
@@ -142,44 +147,52 @@ const Editor = {
|
|||||||
environment_info: this.jsonValue,
|
environment_info: this.jsonValue,
|
||||||
is_public: this.isPublic,
|
is_public: this.isPublic,
|
||||||
log_request: this.logRequest,
|
log_request: this.logRequest,
|
||||||
log_response: this.logResponse
|
log_response: this.logResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create payload based on whether this is a timer function
|
// Create payload based on whether this is a timer function
|
||||||
payload = this.isTimer ? {
|
payload = this.isTimer
|
||||||
name: this.name,
|
? {
|
||||||
script_content: this.jsValue,
|
name: this.name,
|
||||||
environment_info: this.jsonValue,
|
script_content: this.jsValue,
|
||||||
trigger_type: this.triggerType,
|
environment_info: this.jsonValue,
|
||||||
frequency_minutes: this.triggerType === 'interval' ? parseInt(this.frequencyMinutes) : null,
|
trigger_type: this.triggerType,
|
||||||
run_date: this.triggerType === 'date' ? this.runDate : null,
|
frequency_minutes:
|
||||||
is_enabled: this.isEnabled // Add enabled status to payload
|
this.triggerType === "interval"
|
||||||
} : {
|
? parseInt(this.frequencyMinutes)
|
||||||
name: this.name,
|
: null,
|
||||||
script_content: this.jsValue,
|
run_date: this.triggerType === "date" ? this.runDate : null,
|
||||||
environment_info: this.jsonValue
|
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,
|
||||||
body: payload
|
body: payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 'success') {
|
if (response.status === "success") {
|
||||||
if (this.isAdd) {
|
if (this.isAdd) {
|
||||||
window.location.href = this.dashboardUrl;
|
window.location.href = this.dashboardUrl;
|
||||||
} else {
|
} else {
|
||||||
// Increment version number after successful save
|
// Increment version number after successful save
|
||||||
this.versionNumber = (parseInt(this.versionNumber) + 1).toString();
|
this.versionNumber = (parseInt(this.versionNumber) + 1).toString();
|
||||||
}
|
}
|
||||||
Alert.show(response.message || 'Function saved successfully!', 'success');
|
Alert.show(
|
||||||
|
response.message || "Function saved successfully!",
|
||||||
|
"success"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
Alert.show(response.message || 'Error saving function', 'error');
|
Alert.show(response.message || "Error saving function", "error");
|
||||||
this.error = new Error(response.message);
|
this.error = new Error(response.message);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Alert.show(err?.response.message || 'Error saving function', 'error');
|
Alert.show(err?.response.message || "Error saving function", "error");
|
||||||
this.error = err?.response;
|
this.error = err?.response;
|
||||||
} finally {
|
} finally {
|
||||||
this.saveLoading = false;
|
this.saveLoading = false;
|
||||||
@@ -188,7 +201,7 @@ const Editor = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async delete() {
|
async delete() {
|
||||||
if (!confirm('Are you sure you want to delete this function?')) {
|
if (!confirm("Are you sure you want to delete this function?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,20 +210,23 @@ const Editor = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await m.request({
|
const response = await m.request({
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
url: this.deleteUrl
|
url: this.deleteUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = this.cancelUrl;
|
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);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Alert.show(err.message || 'Error deleting function', 'error');
|
Alert.show(err.message || "Error deleting function", "error");
|
||||||
this.error = err;
|
this.error = err;
|
||||||
} finally {
|
} finally {
|
||||||
this.deleteLoading = false;
|
this.deleteLoading = false;
|
||||||
@@ -218,6 +234,31 @@ const Editor = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async generateWithAI() {
|
||||||
|
this.generateLoading = true;
|
||||||
|
this.error = null;
|
||||||
|
m.redraw();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await m.request({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/generate_script", // Assuming this is the new endpoint
|
||||||
|
body: { natural_query: this.naturalLanguageQuery },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.script_content) {
|
||||||
|
this.editorJS.setValue(resp.script_content, -1);
|
||||||
|
} else if (resp.error) {
|
||||||
|
throw new Error(resp.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err;
|
||||||
|
} finally {
|
||||||
|
this.generateLoading = false;
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
return m("div", { class: "" }, [
|
return m("div", { class: "" }, [
|
||||||
/* ─────────────────────────────────────────────────────────────────
|
/* ─────────────────────────────────────────────────────────────────
|
||||||
@@ -350,6 +391,49 @@ const Editor = {
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────
|
||||||
|
AI Generation
|
||||||
|
─────────────────────────────────────────────────────────────────*/
|
||||||
|
m("div", { class: "p-2 border-b border-gray-200 dark:border-gray-800" }, [
|
||||||
|
m(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
class: "text-sm text-blue-500 hover:underline",
|
||||||
|
onclick: () =>
|
||||||
|
(this.showNaturalLanguageQuery = !this.showNaturalLanguageQuery),
|
||||||
|
},
|
||||||
|
"Generate with AI"
|
||||||
|
),
|
||||||
|
|
||||||
|
this.showNaturalLanguageQuery &&
|
||||||
|
m("div", { class: "mt-2" }, [
|
||||||
|
m("textarea", {
|
||||||
|
class:
|
||||||
|
"w-full p-2 border rounded bg-gray-50 dark:bg-gray-700 dark:border-gray-600",
|
||||||
|
rows: 3,
|
||||||
|
placeholder:
|
||||||
|
"Enter a description of what you want this function to do...",
|
||||||
|
oninput: (e) => (this.naturalLanguageQuery = e.target.value),
|
||||||
|
value: this.naturalLanguageQuery,
|
||||||
|
}),
|
||||||
|
m(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
class:
|
||||||
|
"mt-2 px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50",
|
||||||
|
onclick: () => this.generateWithAI(),
|
||||||
|
disabled: this.generateLoading,
|
||||||
|
},
|
||||||
|
this.generateLoading
|
||||||
|
? m("div", {
|
||||||
|
class:
|
||||||
|
"animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full",
|
||||||
|
})
|
||||||
|
: "Generate"
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────────────────────────
|
/* ─────────────────────────────────────────────────────────────────
|
||||||
JS Editor
|
JS Editor
|
||||||
─────────────────────────────────────────────────────────────────*/
|
─────────────────────────────────────────────────────────────────*/
|
||||||
@@ -419,170 +503,248 @@ const Editor = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Function settings panel
|
// Function settings panel
|
||||||
this.showFunctionSettings && m("div", { class: "bg-gray-100 dark:bg-gray-800 p-4 border-b" }, [
|
this.showFunctionSettings &&
|
||||||
// Settings group
|
m("div", { class: "bg-gray-100 dark:bg-gray-800 p-4 border-b" }, [
|
||||||
m("div", { class: "flex flex-col space-y-4" }, [
|
// Settings group
|
||||||
// Toggles group
|
m("div", { class: "flex flex-col space-y-4" }, [
|
||||||
m("div", { class: "flex flex-wrap gap-6" }, [
|
// Toggles group
|
||||||
// Public/Private toggle
|
m("div", { class: "flex flex-wrap gap-6" }, [
|
||||||
this.showPublicToggle && m("label", {
|
// Public/Private toggle
|
||||||
class: "flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-300 cursor-pointer"
|
this.showPublicToggle &&
|
||||||
}, [
|
m(
|
||||||
m("div", { class: "relative" }, [
|
"label",
|
||||||
m("input[type=checkbox]", {
|
{
|
||||||
class: "sr-only peer",
|
class:
|
||||||
checked: this.isPublic,
|
"flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-300 cursor-pointer",
|
||||||
onchange: (e) => this.isPublic = e.target.checked
|
},
|
||||||
}),
|
[
|
||||||
m("div", {
|
m("div", { class: "relative" }, [
|
||||||
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("input[type=checkbox]", {
|
||||||
})
|
class: "sr-only peer",
|
||||||
]),
|
checked: this.isPublic,
|
||||||
m("span", "Public Function")
|
onchange: (e) => (this.isPublic = e.target.checked),
|
||||||
]),
|
}),
|
||||||
|
m("div", {
|
||||||
// Log Request toggle
|
class:
|
||||||
this.showLogRequestToggle && m("label", {
|
"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",
|
||||||
class: "flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-300 cursor-pointer"
|
}),
|
||||||
}, [
|
]),
|
||||||
m("div", { class: "relative" }, [
|
m("span", "Public Function"),
|
||||||
m("input[type=checkbox]", {
|
]
|
||||||
class: "sr-only peer",
|
|
||||||
checked: this.logRequest,
|
|
||||||
onchange: (e) => this.logRequest = 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", "Log Requests")
|
|
||||||
]),
|
|
||||||
|
|
||||||
// Log Response toggle
|
|
||||||
this.showLogResponseToggle && m("label", {
|
|
||||||
class: "flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-300 cursor-pointer"
|
|
||||||
}, [
|
|
||||||
m("div", { class: "relative" }, [
|
|
||||||
m("input[type=checkbox]", {
|
|
||||||
class: "sr-only peer",
|
|
||||||
checked: this.logResponse,
|
|
||||||
onchange: (e) => this.logResponse = 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", "Log Responses")
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
|
|
||||||
// Timer settings (shown only if isTimer is true)
|
|
||||||
this.isTimer && this.showTimerSettings && [
|
|
||||||
// Enabled toggle
|
|
||||||
m("label", {
|
|
||||||
class: "flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-300 cursor-pointer mt-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")
|
|
||||||
]),
|
|
||||||
|
|
||||||
// Timer settings group
|
|
||||||
m("div", { class: "grid grid-cols-2 gap-4 mt-4" }, [
|
|
||||||
// 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 or Date Settings based on triggerType
|
// Log Request toggle
|
||||||
this.triggerType === 'interval' ?
|
this.showLogRequestToggle &&
|
||||||
m("div", { class: "flex flex-col space-y-2" }, [
|
m(
|
||||||
m("label", { class: "text-sm font-medium text-gray-700 dark:text-gray-300" },
|
"label",
|
||||||
"Frequency (minutes)"
|
{
|
||||||
),
|
class:
|
||||||
m("input[type=number]", {
|
"flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-300 cursor-pointer",
|
||||||
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,
|
m("div", { class: "relative" }, [
|
||||||
onchange: (e) => this.frequencyMinutes = e.target.value
|
m("input[type=checkbox]", {
|
||||||
})
|
class: "sr-only peer",
|
||||||
]) :
|
checked: this.logRequest,
|
||||||
m("div", { class: "flex flex-col space-y-2" }, [
|
onchange: (e) => (this.logRequest = e.target.checked),
|
||||||
m("label", { class: "text-sm font-medium text-gray-700 dark:text-gray-300" },
|
}),
|
||||||
"Run Date"
|
m("div", {
|
||||||
),
|
class:
|
||||||
m("input[type=datetime-local]", {
|
"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",
|
||||||
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
|
m("span", "Log Requests"),
|
||||||
})
|
]
|
||||||
])
|
),
|
||||||
])
|
|
||||||
],
|
|
||||||
|
|
||||||
// Actions group
|
// Log Response toggle
|
||||||
m("div", { class: "flex items-center justify-end space-x-3 pt-2" }, [
|
this.showLogResponseToggle &&
|
||||||
// Save button
|
m(
|
||||||
this.showSaveButton && m("button", {
|
"label",
|
||||||
class: "px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-lg hover:bg-blue-600 transition-colors flex items-center space-x-2 disabled:opacity-50",
|
{
|
||||||
onclick: () => this.save(),
|
class:
|
||||||
disabled: this.saveLoading
|
"flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-300 cursor-pointer",
|
||||||
}, [
|
},
|
||||||
this.saveLoading && m("div", {
|
[
|
||||||
class: "animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"
|
m("div", { class: "relative" }, [
|
||||||
}),
|
m("input[type=checkbox]", {
|
||||||
m("span", this.saveLoading ? "Saving..." : "Save Function")
|
class: "sr-only peer",
|
||||||
|
checked: this.logResponse,
|
||||||
|
onchange: (e) => (this.logResponse = 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", "Log Responses"),
|
||||||
|
]
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Delete button
|
// Timer settings (shown only if isTimer is true)
|
||||||
this.showDeleteButton && m("button", {
|
this.isTimer &&
|
||||||
class: "px-4 py-2 bg-white text-red-600 text-sm font-medium border border-red-200 rounded-lg hover:bg-red-50 transition-colors flex items-center space-x-2 disabled:opacity-50",
|
this.showTimerSettings && [
|
||||||
onclick: () => this.delete(),
|
// Enabled toggle
|
||||||
disabled: this.deleteLoading
|
m(
|
||||||
}, [
|
"label",
|
||||||
this.deleteLoading && m("div", {
|
{
|
||||||
class: "animate-spin h-4 w-4 border-2 border-red-600 border-t-transparent rounded-full"
|
class:
|
||||||
}),
|
"flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-300 cursor-pointer mt-4",
|
||||||
m("span", this.deleteLoading ? "Deleting..." : "Delete Function")
|
},
|
||||||
])
|
[
|
||||||
])
|
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"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
|
||||||
|
// Timer settings group
|
||||||
|
m("div", { class: "grid grid-cols-2 gap-4 mt-4" }, [
|
||||||
|
// 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 or Date Settings based on triggerType
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: 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),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Actions group
|
||||||
|
m(
|
||||||
|
"div",
|
||||||
|
{ class: "flex items-center justify-end space-x-3 pt-2" },
|
||||||
|
[
|
||||||
|
// Save button
|
||||||
|
this.showSaveButton &&
|
||||||
|
m(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
class:
|
||||||
|
"px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-lg hover:bg-blue-600 transition-colors flex items-center space-x-2 disabled:opacity-50",
|
||||||
|
onclick: () => this.save(),
|
||||||
|
disabled: this.saveLoading,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
this.saveLoading &&
|
||||||
|
m("div", {
|
||||||
|
class:
|
||||||
|
"animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full",
|
||||||
|
}),
|
||||||
|
m(
|
||||||
|
"span",
|
||||||
|
this.saveLoading ? "Saving..." : "Save Function"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
this.showDeleteButton &&
|
||||||
|
m(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
class:
|
||||||
|
"px-4 py-2 bg-white text-red-600 text-sm font-medium border border-red-200 rounded-lg hover:bg-red-50 transition-colors flex items-center space-x-2 disabled:opacity-50",
|
||||||
|
onclick: () => this.delete(),
|
||||||
|
disabled: this.deleteLoading,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
this.deleteLoading &&
|
||||||
|
m("div", {
|
||||||
|
class:
|
||||||
|
"animate-spin h-4 w-4 border-2 border-red-600 border-t-transparent rounded-full",
|
||||||
|
}),
|
||||||
|
m(
|
||||||
|
"span",
|
||||||
|
this.deleteLoading ? "Deleting..." : "Delete Function"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────────────────────────
|
/* ─────────────────────────────────────────────────────────────────
|
||||||
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,
|
||||||
isTimer: this.isTimer,
|
isTimer: this.isTimer,
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.response = null;
|
this.response = null;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
149
templates/documentation.html
Normal file
149
templates/documentation.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<title>Function - Documentation</title>
|
||||||
|
<link rel="icon" type="image/svg+xml"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='currentColor' class='w-6 h-6'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z' /%3E%3C/svg%3E%0A" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,900&display=swap" rel="stylesheet" />
|
||||||
|
<script src="/static/js/tailwindcss@3.2.4.js"></script>
|
||||||
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url("https://rsms.me/inter/inter.css");
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
|
||||||
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
|
"Noto Color Emoji";
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient {
|
||||||
|
background-image: linear-gradient(-225deg, #cbbacc 0%, #2580b3 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.gradient2 {
|
||||||
|
background-color: #f39f86;
|
||||||
|
background-image: linear-gradient(315deg, #f39f86 0%, #f9d976 74%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="gradient leading-relaxed tracking-wide flex flex-col">
|
||||||
|
<div class="container mx-auto p-4 lg:p-1 min-h-screen h-full">
|
||||||
|
|
||||||
|
<nav id="header" class="w-full z-30 top-0 text-white py-1 lg:py-1">
|
||||||
|
<div class="w-full container mx-auto flex flex-wrap items-center justify-between mt-0 px-2 py-2 lg:py-6">
|
||||||
|
<div class="pl-4 flex items-center">
|
||||||
|
<a class="text-white no-underline hover:no-underline font-bold text-2xl lg:text-4xl"
|
||||||
|
href="{{ url_for('home') }}">
|
||||||
|
<svg class="h-10 w-10 inline-block fill-current text-yellow-700"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||||
|
<path d="M13 8V0L8.11 5.87 3 12h4v8L17 8h-4z"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full flex-grow lg:flex lg:items-center lg:w-auto hidden lg:block mt-2 lg:mt-0 text-black p-4 lg:p-0 z-20"
|
||||||
|
id="nav-content">
|
||||||
|
<ul class="list-reset lg:flex justify-end flex-1 items-center">
|
||||||
|
<li class="mr-3">
|
||||||
|
<a class="inline-block text-black no-underline hover:text-gray-800 hover:text-underline py-2 px-4"
|
||||||
|
href="{{ url_for('home') }}">Home</a>
|
||||||
|
</li>
|
||||||
|
<li class="mr-3">
|
||||||
|
<a class="inline-block py-2 px-4 text-black font-bold no-underline"
|
||||||
|
href="{{ url_for('documentation') }}">Documentation</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<a href="{{ url_for('dashboard') }}"
|
||||||
|
class="mx-auto lg:mx-0 hover:underline gradient2 text-gray-800 font-extrabold rounded my-6 py-4 px-8 shadow-lg cursor-pointer">Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="text-center px-3 lg:px-0">
|
||||||
|
<h1 class="my-4 text-2xl md:text-3xl lg:text-5xl font-black leading-tight">Documentation</h1>
|
||||||
|
<p class="leading-normal text-gray-800 text-base md:text-xl lg:text-2xl mb-8">
|
||||||
|
Everything you need to know to build powerful functions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="py-8">
|
||||||
|
<div class="container mx-auto flex flex-wrap pt-4 pb-12">
|
||||||
|
|
||||||
|
<!-- The `request` Object -->
|
||||||
|
<div class="w-full p-6">
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-800 mb-2">The `request` Object</h3>
|
||||||
|
<p class="text-gray-600 mb-4">Your function receives a `request` object containing all the
|
||||||
|
details of the incoming HTTP request. It has the following structure:</p>
|
||||||
|
<pre class="bg-gray-800 text-white p-4 rounded-md"><code>{
|
||||||
|
"method": "GET",
|
||||||
|
"headers": { ... },
|
||||||
|
"url": "http://...",
|
||||||
|
"path": "/sub/path",
|
||||||
|
"query": { "param": "value" },
|
||||||
|
"json": { "key": "value" },
|
||||||
|
"form": { "field": "value" },
|
||||||
|
"text": "plain text body"
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The `environment` Object -->
|
||||||
|
<div class="w-full p-6">
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-800 mb-2">The `environment` Object</h3>
|
||||||
|
<p class="text-gray-600 mb-4">The `environment` object is a mutable JSON object that persists
|
||||||
|
across function executions. You can read from it and write to it to maintain state.</p>
|
||||||
|
<pre class="bg-gray-800 text-white p-4 rounded-md"><code>// Example: A simple counter
|
||||||
|
async (req) => {
|
||||||
|
if (!environment.counter) {
|
||||||
|
environment.counter = 0;
|
||||||
|
}
|
||||||
|
environment.counter++;
|
||||||
|
return JsonResponse({ count: environment.counter });
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Response Helpers -->
|
||||||
|
<div class="w-full p-6">
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-800 mb-2">Response Helpers</h3>
|
||||||
|
<p class="text-gray-600 mb-4">Several helper functions are available globally to make creating
|
||||||
|
responses easier:</p>
|
||||||
|
<ul class="list-disc list-inside text-gray-700">
|
||||||
|
<li>`Response(body, headers, status)`: The base response function.</li>
|
||||||
|
<li>`JsonResponse(body, headers, status)`: Creates a response with `Content-Type:
|
||||||
|
application/json`.</li>
|
||||||
|
<li>`HtmlResponse(body, headers, status)`: Creates a response with `Content-Type:
|
||||||
|
text/html`.</li>
|
||||||
|
<li>`TextResponse(body, headers, status)`: Creates a response with `Content-Type:
|
||||||
|
text/plain`.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Console Logging -->
|
||||||
|
<div class="w-full p-6">
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-800 mb-2">Console Logging</h3>
|
||||||
|
<p class="text-gray-600 mb-4">You can use `console.log()` and `console.error()` within your
|
||||||
|
function. The output will be captured and displayed in the function's logs, which you can
|
||||||
|
view in your dashboard.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="mr-3">
|
<li class="mr-3">
|
||||||
<a class="inline-block text-black no-underline hover:text-gray-800 hover:text-underline py-2 px-4"
|
<a class="inline-block text-black no-underline hover:text-gray-800 hover:text-underline py-2 px-4"
|
||||||
href="#">Documentation</a>
|
href="{{ url_for('documentation') }}">Documentation</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="mr-3">
|
<li class="mr-3">
|
||||||
<a class="inline-block text-black no-underline hover:text-gray-800 hover:text-underline py-2 px-4"
|
<a class="inline-block text-black no-underline hover:text-gray-800 hover:text-underline py-2 px-4"
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
href="/signup">
|
href="/signup">
|
||||||
Sign Up
|
Sign Up
|
||||||
</a>
|
</a>
|
||||||
<a href="#"
|
<a href="{{ url_for('documentation') }}"
|
||||||
class="inline-block mx-auto lg:mx-0 hover:underline bg-transparent text-gray-600 font-extrabold my-2 md:my-6 py-2 lg:py-4 px-8">
|
class="inline-block mx-auto lg:mx-0 hover:underline bg-transparent text-gray-600 font-extrabold my-2 md:my-6 py-2 lg:py-4 px-8">
|
||||||
View documentation</a>
|
View documentation</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user