WIP: Add light/dark theme with toggle in navbar (dark theme styling incomplete - dont care for now)

This commit is contained in:
Peter Stockings
2025-11-23 22:00:41 +11:00
parent fc494a9355
commit 89a17f68ab
15 changed files with 463 additions and 260 deletions

10
db.py
View File

@@ -166,18 +166,22 @@ ORDER BY invocation_time DESC""", [http_function_id])
def get_user(self, user_id):
user = self.execute(
'SELECT id, username, password_hash, created_at FROM users WHERE id=%s', [int(user_id)], one=True)
'SELECT id, username, password_hash, created_at, theme_preference FROM users WHERE id=%s', [int(user_id)], one=True)
return user
def get_user_by_username(self, username):
user = self.execute(
'SELECT id, username, password_hash, created_at FROM users WHERE username=%s', [username], one=True)
'SELECT id, username, password_hash, created_at, theme_preference FROM users WHERE username=%s', [username], one=True)
return user
def create_new_user(self, username, password_hash):
new_user = self.execute(
'INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username, password_hash, created_at', [username, password_hash], commit=True, one=True)
'INSERT INTO users (username, password_hash, theme_preference) VALUES (%s, %s, %s) RETURNING id, username, password_hash, created_at, theme_preference', [username, password_hash, 'light'], commit=True, one=True)
return new_user
def update_user_theme_preference(self, user_id, theme):
self.execute(
'UPDATE users SET theme_preference=%s WHERE id=%s', [theme, user_id], commit=True)
def get_http_function_history(self, function_id):
http_function_history = self.execute(

View File

@@ -7,25 +7,26 @@ from jinja2_fragments import render_block
auth = Blueprint('auth', __name__)
class User(UserMixin):
def __init__(self, id, username, password_hash, created_at):
def __init__(self, id, username, password_hash, created_at, theme_preference='light'):
self.id = id
self.username = username
self.password_hash = password_hash
self.created_at = created_at
self.theme_preference = theme_preference
@staticmethod
def get(user_id):
user_data = db.get_user(int(user_id))
if user_data:
return User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'])
return User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light'))
return None
@login_manager.user_loader
def load_user(user_id):
user_data = db.get_user(int(user_id))
if user_data:
return User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'])
return User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light'))
return None
@auth.route('/login', methods=['GET', 'POST'])
@@ -45,7 +46,7 @@ def login():
if not check_password_hash(user_data['password_hash'], password):
return render_template("login.html", error="Invalid username or password")
user = User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'])
user = User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light'))
login_user(user)
@@ -72,7 +73,7 @@ def signup():
hashed_password = generate_password_hash(password)
user_data = db.create_new_user(username, hashed_password)
user = User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'])
user = User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light'))
login_user(user)
return redirect(url_for('home.index'))

View File

@@ -62,3 +62,15 @@ def delete_api_key(key_id):
user_id = current_user.id
db.delete_api_key(user_id, key_id)
return "", 200
@settings.route("/theme", methods=["POST"])
@login_required
def toggle_theme():
user_id = current_user.id
theme = request.form.get("theme")
if theme in ['light', 'dark']:
db.update_user_theme_preference(user_id, theme)
# Return empty string as we'll handle the UI update via client-side JS or just let the class toggle persist
# Actually, for HTMX we might want to return something or just 200 OK.
return "", 200
return "Invalid theme", 400

14
static/js/chart.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -7,19 +7,30 @@ const FunctionHistory = {
vnode.state.rightVersion = versions[0];
vnode.state.editor = null;
vnode.state.aceDiffer = null;
// Listen for theme changes
vnode.state.themeListener = (e) => {
const newTheme = e.detail.theme;
const aceTheme = newTheme === 'dark' ? "ace/theme/monokai" : "ace/theme/chrome";
if (vnode.state.editor) {
vnode.state.editor.setTheme(aceTheme);
}
// Note: AceDiff might need a full re-init to change theme properly, or we can just let it be
};
window.addEventListener('themeChanged', vnode.state.themeListener);
},
view: function (vnode) {
const { versions } = vnode.attrs;
const { mode, selectedVersion, leftVersion, rightVersion } = vnode.state;
return m(".flex.flex-col.md:flex-row.h-full", [
return m(".flex.flex-col.md:flex-row.h-full.bg-white.dark:bg-gray-900.text-gray-900.dark:text-white", [
// Vertical Timeline
m(".w-full.md:w-64.border-b.md:border-r.md:border-b-0.overflow-y-auto", [
m("div.p-4.border-b.flex.justify-between.items-center", [
m(".w-full.md:w-64.border-b.md:border-r.md:border-b-0.border-gray-200.dark:border-gray-700.overflow-y-auto.bg-gray-50.dark:bg-gray-800", [
m("div.p-4.border-b.border-gray-200.dark:border-gray-700.flex.justify-between.items-center", [
m("h3.text-lg.font-semibold", "History"),
m(
"button.text-sm.bg-gray-200.hover:bg-gray-300.text-gray-800.font-semibold.py-1.px-2.rounded",
"button.text-sm.bg-gray-200.hover:bg-gray-300.dark:bg-gray-700.dark:hover:bg-gray-600.text-gray-800.dark:text-gray-200.font-semibold.py-1.px-2.rounded",
{
onclick: () => {
vnode.state.mode = mode === "view" ? "diff" : "view";
@@ -35,11 +46,11 @@ const FunctionHistory = {
selectedVersion &&
version.version_number === selectedVersion.version_number;
return m(
"li.p-2.cursor-pointer",
"li.p-2.cursor-pointer.mb-1",
{
class: isActive
? "bg-gray-200 rounded"
: "hover:bg-gray-100 rounded",
? "bg-blue-100 dark:bg-blue-900/50 rounded"
: "hover:bg-gray-200 dark:hover:bg-gray-700 rounded",
onclick: () => {
vnode.state.selectedVersion = version;
if (mode === "diff") {
@@ -50,7 +61,7 @@ const FunctionHistory = {
[
m("div.font-bold", `Version ${version.version_number}`),
m(
"div.text-sm.text-gray-600",
"div.text-sm.text-gray-600.dark:text-gray-400",
new Date(version.versioned_at).toLocaleString()
),
]
@@ -60,7 +71,7 @@ const FunctionHistory = {
]),
// Code Viewer or Differ
m(".flex-1.p-4", [
m(".flex-1.p-4.bg-white.dark:bg-gray-900", [
mode === "view"
? m("div", [
m(
@@ -72,9 +83,9 @@ const FunctionHistory = {
: m("div", [
m(".flex.flex-col.md:flex-row.gap-4.mb-4", [
m(".flex-1", [
m("label.block.text-sm.font-medium.text-gray-700", "Left"),
m("label.block.text-sm.font-medium.text-gray-700.dark:text-gray-300", "Left"),
m(
"select.mt-1.block.w-full.rounded-md.border-gray-300.shadow-sm",
"select.mt-1.block.w-full.rounded-md.border-gray-300.dark:border-gray-600.shadow-sm.bg-white.dark:bg-gray-700.text-gray-900.dark:text-white",
{
onchange: (e) =>
(vnode.state.leftVersion = versions.find(
@@ -92,9 +103,9 @@ const FunctionHistory = {
),
]),
m(".flex-1", [
m("label.block.text-sm.font-medium.text-gray-700", "Right"),
m("label.block.text-sm.font-medium.text-gray-700.dark:text-gray-300", "Right"),
m(
"select.mt-1.block.w-full.rounded-md.border-gray-300.shadow-sm",
"select.mt-1.block.w-full.rounded-md.border-gray-300.dark:border-gray-600.shadow-sm.bg-white.dark:bg-gray-700.text-gray-900.dark:text-white",
{
onchange: (e) =>
(vnode.state.rightVersion = versions.find(
@@ -128,6 +139,8 @@ const FunctionHistory = {
updateEditorOrDiffer: function (vnode) {
const { mode, selectedVersion, leftVersion, rightVersion } = vnode.state;
const isDark = document.documentElement.classList.contains('dark');
const theme = isDark ? "ace/theme/monokai" : "ace/theme/chrome";
if (mode === "view") {
if (vnode.state.aceDiffer) {
@@ -136,9 +149,11 @@ const FunctionHistory = {
}
if (!vnode.state.editor) {
vnode.state.editor = ace.edit("editor-history");
vnode.state.editor.setTheme("ace/theme/monokai");
vnode.state.editor.setTheme(theme);
vnode.state.editor.session.setMode("ace/mode/javascript");
vnode.state.editor.setReadOnly(true);
} else {
vnode.state.editor.setTheme(theme);
}
vnode.state.editor.setValue(selectedVersion.script, -1);
} else {
@@ -153,6 +168,7 @@ const FunctionHistory = {
vnode.state.aceDiffer = new AceDiff({
element: "#diff-container",
mode: "ace/mode/javascript",
theme: theme,
left: { content: leftVersion.script, editable: false },
right: { content: rightVersion.script, editable: false },
});
@@ -166,5 +182,8 @@ const FunctionHistory = {
if (vnode.state.aceDiffer) {
vnode.state.aceDiffer.destroy();
}
if (vnode.state.themeListener) {
window.removeEventListener('themeChanged', vnode.state.themeListener);
}
},
};

View File

@@ -57,7 +57,12 @@ const Editor = {
oncreate() {
this.editorJS = ace.edit("js-editor");
this.editorJS.setOptions({ maxLines: 100 });
this.editorJS.setTheme("ace/theme/github_dark");
// Determine initial theme
const isDark = document.documentElement.classList.contains('dark');
const theme = isDark ? "ace/theme/monokai" : "ace/theme/chrome";
this.editorJS.setTheme(theme);
this.editorJS.session.setMode(
this.runtime === "python" ? "ace/mode/python" : "ace/mode/javascript"
);
@@ -70,7 +75,7 @@ const Editor = {
this.editorJSON = ace.edit("json-editor");
this.editorJSON.setOptions({ maxLines: 100 });
this.editorJSON.setTheme("ace/theme/github_dark");
this.editorJSON.setTheme(theme);
this.editorJSON.session.setMode("ace/mode/json");
this.editorJSON.setValue(this.jsonValue, -1);
@@ -78,6 +83,20 @@ const Editor = {
this.jsonValue = this.editorJSON.getValue();
m.redraw();
});
// Listen for theme changes
this.themeListener = (e) => {
const newTheme = e.detail.theme === 'dark' ? "ace/theme/monokai" : "ace/theme/chrome";
this.editorJS.setTheme(newTheme);
this.editorJSON.setTheme(newTheme);
};
window.addEventListener('themeChanged', this.themeListener);
},
onremove() {
if (this.themeListener) {
window.removeEventListener('themeChanged', this.themeListener);
}
},
async execute() {

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html class="{% if current_user.is_authenticated and current_user.theme_preference == 'dark' %}dark{% endif %}">
<head>
<meta charset="utf-8">
@@ -10,6 +10,11 @@
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>
tailwind.config = {
darkMode: 'class',
}
</script>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/hyperscript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.1/ace.min.js"
@@ -21,6 +26,8 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.1/theme-monokai.min.js"
integrity="sha512-g9yptARGYXbHR9r3kTKIAzF+vvmgEieTxuuUUcHC5tKYFpLR3DR+lsisH2KZJG2Nwaou8jjYVRdbbbBQI3Bo5w=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.1/theme-chrome.min.js" crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<script src="https://unpkg.com/mithril/mithril.js"></script>
<script src="/static/js/mithril/editor.js"></script>
@@ -42,6 +49,16 @@
"Noto Color Emoji";
}
/* Dark mode transition */
html.dark {
color-scheme: dark;
}
html,
body {
transition: background-color 0.3s ease, color 0.3s ease;
}
.gradient {
background-image: linear-gradient(-225deg, #cbbacc 0%, #2580b3 100%);
}
@@ -52,13 +69,40 @@
background-image: linear-gradient(315deg, #f39f86 0%, #f9d976 74%);
}
</style>
<script>
function toggleTheme() {
const html = document.documentElement;
const isDark = html.classList.contains('dark');
const newTheme = isDark ? 'light' : 'dark';
if (newTheme === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
// Dispatch event for components to listen to
window.dispatchEvent(new CustomEvent('themeChanged', { detail: { theme: newTheme } }));
// Send preference to backend
const formData = new FormData();
formData.append('theme', newTheme);
fetch('/settings/theme', {
method: 'POST',
body: formData
});
}
</script>
</head>
<body class="leading-relaxed tracking-wide flex flex-col">
<body class="leading-relaxed tracking-wide flex flex-col bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-50">
<div class="container mx-auto p-4 lg:p-1 min-h-screen h-full">
{% block content %}
{% endblock %}
</div>
</body>
</body>
</html>

View File

@@ -122,14 +122,30 @@
</svg>
Home
</a>
</li>
<!-- Dynamic breadcrumbs could be injected here via blocks if needed -->
</ol>
</nav>
</svg>
<span class="sr-only">Sign out</span>
</a>
</div>
<!-- User Menu -->
<div class="flex items-center gap-4">
<button onclick="toggleTheme()"
class="p-2 rounded-full text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
title="Toggle theme">
<!-- Sun icon (shown in dark mode) -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 hidden dark:block" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<!-- Moon icon (shown in light mode) -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 block dark:hidden" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<div class="h-6 w-px bg-gray-200 dark:bg-gray-800"></div>
<a class="inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full w-9 h-9 text-gray-500 dark:text-gray-400"
href="{{ url_for('auth.logout') }}" title="Sign out">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"

View File

@@ -1,21 +1,22 @@
{% extends 'dashboard.html' %}
{% block page %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="/static/js/chart.js"></script>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900">Dashboard Overview</h1>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Dashboard Overview</h1>
</div>
<!-- Key Metrics Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Total Functions -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-500">Total Functions</h3>
<div class="p-2 bg-indigo-50 rounded-lg">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Functions</h3>
<div class="p-2 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg">
<svg class="w-6 h-6 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z">
</path>
@@ -23,37 +24,41 @@
</div>
</div>
<div class="flex items-baseline">
<p class="text-2xl font-bold text-gray-900">{{ stats.total_timer_functions + stats.total_http_functions
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.total_timer_functions +
stats.total_http_functions
}}</p>
<p class="ml-2 text-sm text-gray-500">
<p class="ml-2 text-sm text-gray-500 dark:text-gray-400">
({{ stats.total_http_functions }} HTTP, {{ stats.total_timer_functions }} Timer)
</p>
</div>
</div>
<!-- Total Invocations -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-500">Total Invocations</h3>
<div class="p-2 bg-blue-50 rounded-lg">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Invocations</h3>
<div class="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
</div>
<div class="flex items-baseline">
<p class="text-2xl font-bold text-gray-900">{{ stats.timer_invocations + stats.http_invocations }}</p>
<p class="ml-2 text-sm text-gray-500">All time</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.timer_invocations +
stats.http_invocations }}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-gray-400">All time</p>
</div>
</div>
<!-- Success Rate -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-500">Overall Success Rate</h3>
<div class="p-2 bg-green-50 rounded-lg">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Overall Success Rate</h3>
<div class="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg">
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
@@ -63,19 +68,20 @@
{% set total_success = stats.timer_successful_invocations + stats.http_successful_invocations %}
{% set success_rate = (total_success / total_invocations * 100)|round(1) if total_invocations > 0 else 0 %}
<div class="flex items-baseline">
<p class="text-2xl font-bold text-gray-900">{{ success_rate }}%</p>
<p class="ml-2 text-sm text-gray-500">
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ success_rate }}%</p>
<p class="ml-2 text-sm text-gray-500 dark:text-gray-400">
{{ total_success }}/{{ total_invocations }}
</p>
</div>
</div>
<!-- Avg Execution Time -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-500">Avg Execution Time</h3>
<div class="p-2 bg-yellow-50 rounded-lg">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Avg Execution Time</h3>
<div class="p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400" 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>
@@ -83,7 +89,7 @@
</div>
{% set avg_time = ((stats.avg_timer_execution_time or 0) + (stats.avg_http_execution_time or 0)) / 2 %}
<div class="flex items-baseline">
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(avg_time) }}s</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ "%.2f"|format(avg_time) }}s</p>
</div>
</div>
</div>
@@ -91,16 +97,17 @@
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Invocation History Chart -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100 lg:col-span-2">
<h3 class="text-lg font-semibold text-gray-900 mb-4">24-Hour Activity</h3>
<div
class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-100 dark:border-gray-700 lg:col-span-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">24-Hour Activity</h3>
<div class="h-64">
<canvas id="activityChart"></canvas>
</div>
</div>
<!-- Top Functions -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Top Functions</h3>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-100 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Top Functions</h3>
<div class="space-y-4">
{% for func in top_functions %}
<div class="flex items-center justify-between">
@@ -108,16 +115,16 @@
<div
class="w-2 h-2 rounded-full {% if func.type == 'HTTP' %}bg-indigo-500{% else %}bg-blue-500{% endif %} mr-2">
</div>
<span class="text-sm font-medium text-gray-700">{{ func.name }}</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ func.name }}</span>
</div>
<span class="text-sm text-gray-500">{{ func.invocation_count }} calls</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ func.invocation_count }} calls</span>
</div>
{% else %}
<p class="text-sm text-gray-500">No functions found.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">No functions found.</p>
{% endfor %}
</div>
<div class="mt-6 pt-6 border-t border-gray-100">
<h4 class="text-sm font-medium text-gray-900 mb-2">Success Trend (7 Days)</h4>
<div class="mt-6 pt-6 border-t border-gray-100 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Success Trend (7 Days)</h4>
<div class="h-32">
<canvas id="trendChart"></canvas>
</div>
@@ -126,62 +133,74 @@
</div>
<!-- Recent Activity Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">Recent Activity</h3>
<div
class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Activity</h3>
<div class="flex space-x-2">
<a href="{{ url_for('http.overview') }}"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">View HTTP</a>
<span class="text-gray-300">|</span>
class="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">View
HTTP</a>
<span class="text-gray-300 dark:text-gray-600">|</span>
<a href="{{ url_for('timer.overview') }}"
class="text-sm text-blue-600 hover:text-blue-800 font-medium">View Timers</a>
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium">View
Timers</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Function</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Duration</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Time
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for activity in recent_activity %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ activity.name }}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">{{
activity.name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if activity.type == 'HTTP' %}bg-indigo-100 text-indigo-800{% else %}bg-blue-100 text-blue-800{% endif %}">
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if activity.type == 'HTTP' %}bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300{% else %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300{% endif %}">
{{ activity.type }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if activity.status == 'SUCCESS' %}
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">Success</span>
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">Success</span>
{% else %}
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">{{
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">{{
activity.status }}</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{
"%.2f"|format(activity.execution_time or 0) }}s</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{
activity.invocation_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">No recent activity found.
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500 dark:text-gray-400">No recent
activity found.
</td>
</tr>
{% endfor %}
@@ -214,8 +233,22 @@
legend: { display: false }
},
scales: {
y: { beginAtZero: true, grid: { display: false } },
x: { grid: { display: false } }
y: {
beginAtZero: true,
grid: {
display: false,
color: document.documentElement.classList.contains('dark') ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
},
ticks: {
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#6b7280'
}
},
x: {
grid: { display: false },
ticks: {
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#6b7280'
}
}
}
}
});
@@ -256,5 +289,21 @@
}
}
});
// Update charts on theme change
window.addEventListener('themeChanged', (e) => {
const isDark = e.detail.theme === 'dark';
const textColor = isDark ? '#9ca3af' : '#6b7280';
const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
// Update Activity Chart
const activityChart = Chart.getChart('activityChart');
if (activityChart) {
activityChart.options.scales.x.ticks.color = textColor;
activityChart.options.scales.y.ticks.color = textColor;
activityChart.options.scales.y.grid.color = gridColor;
activityChart.update();
}
});
</script>
{% endblock %}

View File

@@ -3,26 +3,29 @@
{% for name, children in functions.items() %}
{% if children['_is_function'] is not defined %}
<details class="group" {% if path_prefix=='' %}open{% endif %}>
<summary class="flex items-center gap-2 py-2 cursor-pointer text-gray-700 hover:text-gray-900 font-medium">
<summary
class="flex items-center gap-2 py-2 cursor-pointer text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white font-medium">
<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 text-gray-400 group-open:rotate-90 transition-transform">
stroke="currentColor"
class="w-5 h-5 text-gray-400 dark:text-gray-500 group-open:rotate-90 transition-transform">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<span>{{ name }}</span>
</summary>
<div class="ml-4 border-l border-gray-200">
<div class="ml-4 border-l border-gray-200 dark:border-gray-700">
{{ render_functions(children, path_prefix + name + '/') }}
</div>
</details>
{% else %}
{% set function = children %}
<div class="flex items-center gap-2 py-2 pl-5 text-gray-800 hover:bg-gray-50 rounded-md">
<div
class="flex items-center gap-2 py-2 pl-5 text-gray-800 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md">
<div class="flex-grow flex items-center gap-2 cursor-pointer"
hx-get="{{ url_for('http.editor', function_id=function['id']) }}" hx-target="#container" hx-swap="innerHTML"
hx-push-url="true">
{% if function.is_public %}
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<svg class="w-2 h-2 mr-1.5 fill-current" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
@@ -30,18 +33,21 @@
</span>
{% endif %}
<span class="font-medium">{{ function.name.split('/')[-1] }}</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 dark:bg-blue-900/30 dark:text-blue-300 text-xs font-medium px-2.5 py-0.5 rounded-full">
v{{ function.version_number }}
</span>
<span class="bg-purple-100 text-purple-800 text-xs font-medium px-2.5 py-0.5 rounded-full uppercase">
<span
class="bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 text-xs font-medium px-2.5 py-0.5 rounded-full uppercase">
{{ function.runtime }}
</span>
<span class="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
<span
class="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 text-xs font-medium px-2.5 py-0.5 rounded-full">
{{ function.invoked_count }}
</span>
</div>
<div class="flex-shrink-0 flex items-center gap-2">
<button class="p-1 text-gray-400 hover:text-gray-600"
<button class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
hx-get="{{ url_for('http.logs', function_id=function['id']) }}" hx-target="#container"
hx-swap="innerHTML" hx-push-url="true">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
@@ -50,7 +56,7 @@
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" />
</svg>
</button>
<button class="p-1 text-gray-400 hover:text-gray-600"
<button class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
hx-get="{{ url_for('http.client', function_id=function['id']) }}" hx-target="#container"
hx-swap="innerHTML" hx-push-url="true">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
@@ -62,7 +68,7 @@
</svg>
</button>
<a href="{{ url_for('execute_http_function', user_id=function.user_id, function=function.name) }}"
target="_blank" class="p-1 text-gray-400 hover:text-gray-600">
target="_blank" class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>

View File

@@ -16,6 +16,54 @@ history_url=url_for('http.history', function_id=function_id)) }}
<div class="mx-auto w-full pt-4" id="client-u{{ user_id }}-f{{ function_id }}">
</div>
<script>
function formatTime(milliseconds) {
if (milliseconds < 1000) {
return `${milliseconds.toFixed(2)} ms`; // Display milliseconds directly if less than 1 second
}
const seconds = milliseconds / 1000;
if (seconds < 60) {
return `${seconds.toFixed(2)} s`; // Display seconds if less than 1 minute
}
const minutes = seconds / 60;
if (minutes < 60) {
return `${minutes.toFixed(2)} min`; // Display minutes if less than 1 hour
}
const hours = minutes / 60;
return `${hours.toFixed(2)} h`; // Display hours for longer durations
}
function formatSize(bytes) {
if (bytes < 1024) {
return `${bytes} bytes`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(2)} KB`;
} else if (bytes < 1024 * 1024 * 1024) {
{% extends 'dashboard.html' %}
{% block page %}
{
{
render_partial('dashboard/http_functions/header.html', user_id = user_id, function_id = function_id,
active_tab = 'client',
show_edit_form = True,
show_logs = True,
show_client = True,
show_history = True,
edit_url = url_for('http.editor', function_id = function_id),
cancel_url = url_for('http.overview'),
logs_url = url_for('http.logs', function_id = function_id),
history_url = url_for('http.history', function_id = function_id))
}
}
<div class="mx-auto w-full pt-4" id="client-u{{ user_id }}-f{{ function_id }}">
</div>
<script>
function formatTime(milliseconds) {
if (milliseconds < 1000) {
@@ -51,24 +99,24 @@ history_url=url_for('http.history', function_id=function_id)) }}
var KeyValueList = (initialVnode) => {
return {
view: (vnode) => {
return m("div.px-4.py-4.rounded-b-lg.border.border-t-0.border-gray-300", [
return m("div.px-4.py-4.rounded-b-lg.border.border-t-0.border-gray-300.dark:border-gray-700.bg-white.dark:bg-gray-800", [
m("div", [
m("div.flex-col.space-y-2.mb-3", vnode.attrs.list.map(({ key, value }, index) => m('div.flex', [
m("input.px-4.py-1.5.w-full.border.border-gray-300.rounded-md.hover:border-orange-500.focus:outline-orange-500", {
m("input.px-4.py-1.5.w-full.border.border-gray-300.dark:border-gray-600.rounded-md.hover:border-orange-500.focus:outline-orange-500.bg-white.dark:bg-gray-700.text-gray-900.dark:text-white", {
placeholder: "Key",
value: key,
oninput: (e) => vnode.attrs.list[index].key = e.target.value
}),
m("input.ml-3.px-4.py-1.5.w-full.border.border-gray-300.rounded-md.hover:border-orange-500.focus:outline-orange-500", {
m("input.ml-3.px-4.py-1.5.w-full.border.border-gray-300.dark:border-gray-600.rounded-md.hover:border-orange-500.focus:outline-orange-500.bg-white.dark:bg-gray-700.text-gray-900.dark:text-white", {
placeholder: "Value",
value: value,
oninput: (e) => vnode.attrs.list[index].value = e.target.value
}),
m("button.ml-4.px-4.rounded-md.text-red-500.border.border-red-300.hover:bg-red-100", {
m("button.ml-4.px-4.rounded-md.text-red-500.border.border-red-300.hover:bg-red-100.dark:border-red-800.dark:hover:bg-red-900/30", {
onclick: () => vnode.attrs.list.splice(index, 1)
}, "Remove")
]))),
m("button.px-6.py-1.rounded-md.text-orange-600.border.border-orange-400.hover:bg-orange-100", {
m("button.px-6.py-1.rounded-md.text-orange-600.border.border-orange-400.hover:bg-orange-100.dark:text-orange-400.dark:border-orange-500.dark:hover:bg-orange-900/30", {
onclick: () => vnode.attrs.list.push({ key: '', value: '' })
}, "Add")
])
@@ -85,7 +133,7 @@ history_url=url_for('http.history', function_id=function_id)) }}
oninput: (e) => this.body = e.target.value,
value: this.body,
rows: 4,
class: "block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300"
class: "block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
})
}
return tabMappings[this.activeTab];
@@ -150,13 +198,13 @@ history_url=url_for('http.history', function_id=function_id)) }}
view: function () {
return m("div.mx-auto.w-full.pt-4", [
m("form.flex", [
m("select.px-4.py-2.border.rounded-md.border-gray-300.hover:border-orange-500.focus:outline-none.bg-gray-100", {
m("select.px-4.py-2.border.rounded-md.border-gray-300.hover:border-orange-500.focus:outline-none.bg-gray-100.dark:bg-gray-700.dark:border-gray-600.dark:text-white", {
onchange: (e) => this.method = e.target.value,
value: this.method
}, ["GET", "POST", "PUT", "PATCH", "DELETE"].map(method =>
m("option", { value: method }, method)
)),
m("input.ml-3.w-full.px-4.py-2.border.rounded-md.border-gray-300.hover:border-orange-500.focus:outline-orange-500", {
m("input.ml-3.w-full.px-4.py-2.border.rounded-md.border-gray-300.hover:border-orange-500.focus:outline-orange-500.bg-white.dark:bg-gray-700.dark:border-gray-600.dark:text-white", {
onchange: (e) => this.url = e.target.value,
value: this.url,
placeholder: "URL"
@@ -175,14 +223,14 @@ history_url=url_for('http.history', function_id=function_id)) }}
// Tabs for query params, headers, and body
m("div", [
// Tab headers
m("ul.flex.mt-5.border.border-gray-300.rounded-t-lg", [
m("ul.flex.mt-5.border.border-gray-300.dark:border-gray-700.rounded-t-lg.bg-gray-50.dark:bg-gray-800", [
[
{ name: "Query Params", count: this.queryParams.filter(p => p.key && p.value).length },
{ name: "Headers", count: this.headers.filter(p => p.key && p.value).length },
{ name: "Body", count: null }
].map(({ name, count }, index) =>
m("li.mr-3.py-2.px-4.border-orange-400.focus:outline-none.hover:text-orange-500.cursor-pointer", {
class: this.activeTab === name ? "border-b-2 text-orange-600" : "",
m("li.mr-3.py-2.px-4.border-orange-400.focus:outline-none.hover:text-orange-500.cursor-pointer.text-gray-700.dark:text-gray-300", {
class: this.activeTab === name ? "border-b-2 text-orange-600 dark:text-orange-400" : "",
onclick: () => this.activeTab = name
}, `${name}${count ? ` (${count})` : ``}`))
]),
@@ -193,17 +241,17 @@ history_url=url_for('http.history', function_id=function_id)) }}
oninput: (e) => this.body = e.target.value,
value: this.body,
rows: 4,
class: "block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300"
class: "block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
}) : ''
]),
this.response ? m("div.my-4.p-4.shadow.rounded-lg", [
this.response ? m("div.my-4.p-4.shadow.rounded-lg.bg-white.dark:bg-gray-800.border.border-gray-200.dark:border-gray-700", [
m("div.flex.items-center.mb-4.justify-between", [
m("ul.flex.border.border-gray-300.rounded-t-lg", [{ name: 'Browser Preview', count: null }, { name: 'Headers', count: this.responseHeaders.length }, { name: 'Body', count: null }].map(({ name, count }) =>
m("li.mr-3.py-2.px-4.border-orange-400.focus:outline-none.hover:text-orange-500.cursor-pointer", {
class: this.activeResponseTab === name ? "border-b-2 text-orange-600" : "",
m("ul.flex.border.border-gray-300.dark:border-gray-700.rounded-t-lg.bg-gray-50.dark:bg-gray-800", [{ name: 'Browser Preview', count: null }, { name: 'Headers', count: this.responseHeaders.length }, { name: 'Body', count: null }].map(({ name, count }) =>
m("li.mr-3.py-2.px-4.border-orange-400.focus:outline-none.hover:text-orange-500.cursor-pointer.text-gray-700.dark:text-gray-300", {
class: this.activeResponseTab === name ? "border-b-2 text-orange-600 dark:text-orange-400" : "",
onclick: () => this.activeResponseTab = name
}, `${name}${count ? ` (${count})` : ``}`))),
m("svg", { class: "h-6 w-6 cursor-pointer", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", onclick: () => this.response = '' },
m("svg", { class: "h-6 w-6 cursor-pointer text-gray-500 dark:text-gray-400", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", onclick: () => this.response = '' },
m("path", { "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M6 18L18 6M6 6l12 12" })
)
]),
@@ -225,15 +273,15 @@ history_url=url_for('http.history', function_id=function_id)) }}
)
])
]) : null,
this.activeResponseTab == 'Body' ? m("div.overflow-auto.max-h-64.bg-gray-100.p-2.rounded-lg", [
m("pre.text-sm", this.response)
this.activeResponseTab == 'Body' ? m("div.overflow-auto.max-h-64.bg-gray-100.dark:bg-gray-900.p-2.rounded-lg", [
m("pre.text-sm.text-gray-900.dark:text-gray-300", this.response)
]) : null,
this.activeResponseTab == 'Browser Preview' ? m("div.p-2.rounded-lg", m.trust(this.response)) : null,
this.activeResponseTab == 'Browser Preview' ? m("div.p-2.rounded-lg.bg-white", m.trust(this.response)) : null,
m("div.flex", [
m("div.grow", ""),
m("div.flex.space-x-2.pt-1", [{ key: 'Status', value: this.status }, { key: 'Time', value: formatTime(this.duration) }, { key: 'Size', value: formatSize(this.responseSize) }].map(({ key, value }) =>
m("div.flex.items-center.justify-between", [
m("span.text-gray-600", `${key}:`),
m("span.text-gray-600.dark:text-gray-400", `${key}:`),
m("span.font-semibold.pl-1.text-green-400", value)
])))
])

View File

@@ -2,6 +2,15 @@
{% block page %}
{{ render_partial('dashboard/http_functions/header.html', user_id=user_id, function_id=function_id,
active_tab='logs',
show_edit_form=True,
show_logs=True,
show_client=True,
{% extends 'dashboard.html' %}
{% block page %}
{{ render_partial('dashboard/http_functions/header.html', user_id=user_id, function_id=function_id,
active_tab='logs',
show_edit_form=True,
@@ -15,23 +24,25 @@ history_url=url_for('http.history', function_id=function_id)) }}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{{ render_partial('dashboard/analytics.html', invocations=http_function_invocations) }}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Headers -->
<div class="grid md:grid-cols-4 gap-4 bg-gray-50 p-4 border-b border-gray-200">
<div class="font-semibold text-gray-600 md:block hidden">Timestamp</div>
<div class="font-semibold text-gray-600 md:block hidden">Request</div>
<div class="font-semibold text-gray-600 md:block hidden">Response</div>
<div class="font-semibold text-gray-600 md:block hidden">Logs</div>
<div
class="grid md:grid-cols-4 gap-4 bg-gray-50 dark:bg-gray-700 p-4 border-b border-gray-200 dark:border-gray-600">
<div class="font-semibold text-gray-600 dark:text-gray-300 md:block hidden">Timestamp</div>
<div class="font-semibold text-gray-600 dark:text-gray-300 md:block hidden">Request</div>
<div class="font-semibold text-gray-600 dark:text-gray-300 md:block hidden">Response</div>
<div class="font-semibold text-gray-600 dark:text-gray-300 md:block hidden">Logs</div>
</div>
<!-- Data Rows -->
{% for invocation in http_function_invocations %}
<div
class="log-row grid md:grid-cols-4 gap-4 p-4 hover:bg-gray-50 transition-colors duration-150 border-b border-gray-200">
class="log-row grid md:grid-cols-4 gap-4 p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 border-b border-gray-200 dark:border-gray-700">
<!-- Timestamp and Status -->
<div class="flex items-center space-x-2">
<div
class="{{ 'bg-green-100 text-green-700' if invocation.status == 'SUCCESS' else 'bg-red-100 text-red-700' }} p-2 rounded-full">
class="{{ 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' if invocation.status == 'SUCCESS' else 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' }} p-2 rounded-full">
<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="{{ 'M5 13l4 4L19 7' if invocation.status == 'SUCCESS' else 'M6 18L18 6M6 6l12 12' }}">
@@ -39,28 +50,31 @@ history_url=url_for('http.history', function_id=function_id)) }}
</svg>
</div>
<div class="flex flex-col">
<span class="text-sm text-gray-900">{{ invocation.invocation_time.strftime('%Y-%m-%d %H:%M:%S')
<span class="text-sm text-gray-900 dark:text-gray-200">{{
invocation.invocation_time.strftime('%Y-%m-%d %H:%M:%S')
}}</span>
<span
class="inline-flex items-center w-fit px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 rounded-full">
class="inline-flex items-center w-fit px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">
v{{ invocation.version_number }}
</span>
</div>
</div>
<!-- Request Data -->
<div class="bg-gray-50 rounded-lg p-3 overflow-auto max-h-40">
<pre class="text-sm font-mono text-gray-600 whitespace-pre-wrap">{{ invocation.request_data }}</pre>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 overflow-auto max-h-40">
<pre
class="text-sm font-mono text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{{ invocation.request_data }}</pre>
</div>
<!-- Response Data -->
<div class="bg-gray-50 rounded-lg p-3 overflow-auto max-h-40">
<pre class="text-sm font-mono text-gray-600 whitespace-pre-wrap">{{ invocation.response_data }}</pre>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 overflow-auto max-h-40">
<pre
class="text-sm font-mono text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{{ invocation.response_data }}</pre>
</div>
<!-- Logs -->
<div class="bg-gray-50 rounded-lg p-3 overflow-auto max-h-40">
<pre class="text-sm font-mono text-gray-600 whitespace-pre-wrap">{% if invocation.logs and invocation.logs[0] is iterable and invocation.logs[0] is not string -%}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 overflow-auto max-h-40">
<pre class="text-sm font-mono text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{% if invocation.logs and invocation.logs[0] is iterable and invocation.logs[0] is not string -%}
{{- invocation.logs | map('join', ' ') | join('\n') -}}
{%- elif invocation.logs is iterable and invocation.logs is not string -%}
{{- invocation.logs | join('\n') -}}
@@ -68,7 +82,7 @@ history_url=url_for('http.history', function_id=function_id)) }}
{{- invocation.logs | string -}}
{%- endif -%}</pre>
{% if not invocation.logs %}
<div class="text-sm text-gray-400 italic">No logs available</div>
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No logs available</div>
{% endif %}
</div>
</div>
@@ -76,14 +90,14 @@ history_url=url_for('http.history', function_id=function_id)) }}
{% if not http_function_invocations %}
<div class="p-8 text-center">
<div class="text-gray-400">
<div class="text-gray-400 dark:text-gray-500">
<svg class="mx-auto h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
</div>
<h3 class="mt-2 text-sm font-medium text-gray-900">No logs found</h3>
<p class="mt-1 text-sm text-gray-500">No function invocations have been recorded yet.</p>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No logs found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No function invocations have been recorded yet.</p>
</div>
{% endif %}
</div>

View File

@@ -1,82 +1,25 @@
{% extends 'dashboard.html' %}
const isExpanding = btn.textContent.trim() === 'Expand All';
{% block page %}
details.forEach(detail => {
if (isExpanding) {
detail.setAttribute('open', '');
} else {
detail.removeAttribute('open');
}
});
<div class="px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center mb-6" data-id="51">
<h1 class="text-2xl font-bold text-gray-900">HTTP Functions</h1>
<button
class="inline-flex items-center px-4 py-2 ml-auto bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors duration-200"
hx-get="{{ url_for('http.new') }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-5 h-5 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path>
</svg>
Add Function
</button>
</div>
btn.textContent = isExpanding ? 'Collapse All' : 'Expand All';
}
<div class="mb-6 flex gap-4">
<input type="text" name="q" placeholder="Search functions..."
class="flex-grow px-4 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
value="{{ search_query }}" hx-get="{{ url_for('http.overview') }}" hx-trigger="keyup changed delay:500ms"
hx-target="#function-list" hx-headers='{"HX-Target": "function-list"}' hx-push-url="true">
<button onclick="toggleAllDetails()" id="toggle-btn"
class="px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium transition-colors duration-200 whitespace-nowrap">
Expand All
</button>
</div>
<div id="function-list">
{% block function_list %}
{% from 'dashboard/http_functions/_function_list.html' import render_functions %}
{{ render_functions(http_functions) }}
{% if http_functions|length == 0 %}
<div class="py-12">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"
aria-hidden="true">
<path vector-effect="non-scaling-stroke" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
<h3 class="mt-2 text-lg font-medium text-gray-900">No functions found</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a new function.</p>
</div>
</div>
{% endif %}
{% endblock %}
</div>
</div>
<script>
function toggleAllDetails() {
const details = document.querySelectorAll('#function-list details');
const btn = document.getElementById('toggle-btn');
const isExpanding = btn.textContent.trim() === 'Expand All';
details.forEach(detail => {
if (isExpanding) {
detail.setAttribute('open', '');
} else {
detail.removeAttribute('open');
}
});
btn.textContent = isExpanding ? 'Collapse All' : 'Expand All';
}
// Reset button state when HTMX updates the list
document.body.addEventListener('htmx:afterSwap', function (evt) {
if (evt.detail.target.id === 'function-list') {
const btn = document.getElementById('toggle-btn');
if (btn) {
btn.textContent = 'Expand All';
}
}
});
// Reset button state when HTMX updates the list
document.body.addEventListener('htmx:afterSwap', function (evt) {
if (evt.detail.target.id === 'function-list') {
const btn = document.getElementById('toggle-btn');
if (btn) {
btn.textContent = 'Expand All';
}
}
});
</script>
{% endblock %}

View File

@@ -14,25 +14,28 @@ history_url=url_for('timer.history', function_id=function_id)) }}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{{ render_partial('dashboard/analytics.html', invocations=timer_function_invocations) }}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Headers -->
<div class="grid md:grid-cols-3 gap-4 bg-gray-50 p-4 border-b border-gray-200">
<div class="font-semibold text-gray-600 md:block hidden">Invocation Time</div>
<div class="font-semibold text-gray-600 md:block hidden">Status</div>
<div class="font-semibold text-gray-600 md:block hidden">Logs</div>
<div
class="grid md:grid-cols-3 gap-4 bg-gray-50 dark:bg-gray-700 p-4 border-b border-gray-200 dark:border-gray-600">
<div class="font-semibold text-gray-600 dark:text-gray-300 md:block hidden">Invocation Time</div>
<div class="font-semibold text-gray-600 dark:text-gray-300 md:block hidden">Status</div>
<div class="font-semibold text-gray-600 dark:text-gray-300 md:block hidden">Logs</div>
</div>
<!-- Data Rows -->
{% for invocation in timer_function_invocations %}
<div
class="log-row grid md:grid-cols-3 gap-4 p-4 hover:bg-gray-50 transition-colors duration-150 border-b border-gray-200">
class="log-row grid md:grid-cols-3 gap-4 p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 border-b border-gray-200 dark:border-gray-700">
<!-- Timestamp -->
<div class="flex items-center space-x-2">
<div class="flex flex-col">
<span class="text-sm text-gray-900">{{ invocation.invocation_time.strftime('%Y-%m-%d %H:%M:%S')
<span class="text-sm text-gray-900 dark:text-gray-200">{{
invocation.invocation_time.strftime('%Y-%m-%d %H:%M:%S')
}}</span>
<span
class="inline-flex items-center w-fit px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 rounded-full">
class="inline-flex items-center w-fit px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">
v{{ invocation.version_number }}
</span>
</div>
@@ -41,14 +44,14 @@ history_url=url_for('timer.history', function_id=function_id)) }}
<!-- Status -->
<div class="flex items-center">
<div
class="{{ 'bg-green-100 text-green-700' if invocation.status == 'SUCCESS' else 'bg-red-100 text-red-700' }} px-3 py-1 rounded-full text-sm">
class="{{ 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' if invocation.status == 'SUCCESS' else 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' }} px-3 py-1 rounded-full text-sm">
{{ invocation.status }}
</div>
</div>
<!-- Logs -->
<div class="bg-gray-50 rounded-lg p-3 overflow-auto max-h-40">
<pre class="text-sm font-mono text-gray-600 whitespace-pre-wrap">{% if invocation.logs and invocation.logs[0] is iterable and invocation.logs[0] is not string -%}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 overflow-auto max-h-40">
<pre class="text-sm font-mono text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{% if invocation.logs and invocation.logs[0] is iterable and invocation.logs[0] is not string -%}
{{- invocation.logs | map('join', ' ') | join('\n') -}}
{%- elif invocation.logs is iterable and invocation.logs is not string -%}
{{- invocation.logs | join('\n') -}}
@@ -56,7 +59,7 @@ history_url=url_for('timer.history', function_id=function_id)) }}
{{- invocation.logs | string -}}
{%- endif -%}</pre>
{% if not invocation.logs %}
<div class="text-sm text-gray-400 italic">No logs available</div>
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No logs available</div>
{% endif %}
</div>
</div>
@@ -64,14 +67,15 @@ history_url=url_for('timer.history', function_id=function_id)) }}
{% if not timer_function_invocations %}
<div class="p-8 text-center">
<div class="text-gray-400">
<div class="text-gray-400 dark:text-gray-500">
<svg class="mx-auto h-12 w-12" 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" />
</svg>
</div>
<h3 class="mt-2 text-sm font-medium text-gray-900">No logs found</h3>
<p class="mt-1 text-sm text-gray-500">No timer function invocations have been recorded yet.</p>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No logs found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No timer function invocations have been recorded
yet.</p>
</div>
{% endif %}
</div>

View File

@@ -4,7 +4,7 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<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 dark:text-white">Timer Functions</h1>
<button
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors duration-200"
hx-get="{{ url_for('timer.new') }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true">
@@ -16,32 +16,38 @@
</button>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden min-h-[200px]">
<div
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm overflow-hidden min-h-[200px]">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Name</th>
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Schedule</th>
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Next Run</th>
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Status</th>
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900">Actions</th>
<tr class="bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Name</th>
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Schedule
</th>
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Next Run
</th>
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Status
</th>
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for function in timer_functions %}
<tr class="hover:bg-gray-50">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4">
<div class="flex items-center gap-2 cursor-pointer"
hx-get="{{ url_for('timer.edit', function_id=function.id) }}" hx-target="#container"
hx-swap="innerHTML" hx-push-url="true">
<span class="font-medium text-gray-900">{{ function.name }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ function.name }}</span>
<span
class="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
class="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 text-xs font-medium px-2.5 py-0.5 rounded-full">
{{ function.invocation_count }}
</span>
{% if function.last_run %}
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
<span
class="bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 text-xs font-medium px-2.5 py-0.5 rounded-full">
Last run: {{ function.last_run.strftime('%Y-%m-%d %H:%M') }}
</span>
{% endif %}
@@ -49,18 +55,20 @@
</td>
<td class="px-6 py-4">
{% if function.trigger_type == 'interval' %}
<span class="inline-flex items-center">
<svg class="w-4 h-4 mr-1.5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<span class="inline-flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Every {{ function.frequency_minutes }} minutes
</span>
{% else %}
<span class="inline-flex items-center">
<svg class="w-4 h-4 mr-1.5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<span class="inline-flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
@@ -70,15 +78,16 @@
</td>
<td class="px-6 py-4">
{% if function.next_run %}
<span class="text-gray-900">{{ function.next_run.strftime('%Y-%m-%d %H:%M') }}</span>
<span class="text-gray-900 dark:text-gray-300">{{ function.next_run.strftime('%Y-%m-%d
%H:%M') }}</span>
{% else %}
<span class="text-gray-500">-</span>
<span class="text-gray-500 dark:text-gray-400">-</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">
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<svg class="w-2 h-2 mr-1.5 fill-current" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
@@ -86,7 +95,7 @@
</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">
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
<svg class="w-2 h-2 mr-1.5 fill-current" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
@@ -97,7 +106,7 @@
<td class="px-6 py-4">
<div class="flex gap-2">
<button
class="inline-flex items-center p-1.5 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100"
class="inline-flex items-center p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Edit" hx-get="{{ url_for('timer.edit', function_id=function.id) }}"
hx-target="#container" hx-swap="innerHTML" hx-push-url="true">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none"
@@ -107,7 +116,7 @@
</svg>
</button>
<button
class="inline-flex items-center p-1.5 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100"
class="inline-flex items-center p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="{{ 'Pause' if function.enabled else 'Resume' }}"
hx-post="{{ url_for('timer.toggle', function_id=function.id) }}"
hx-target="#container" hx-swap="innerHTML">
@@ -134,9 +143,10 @@
{% if timer_functions|length == 0 %}
<tr>
<td colspan="3" class="px-6 py-16 text-center">
<p class="text-gray-500 text-lg">No functions found</p>
<p class="text-gray-400 text-sm mt-2">Click the "Add Function" button to create your first
<td colspan="5" class="px-6 py-16 text-center">
<p class="text-gray-500 dark:text-gray-400 text-lg">No functions found</p>
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">Click the "Add Function" button to
create your first
function</p>
</td>
</tr>