Add home dashboard and Mithril rendering support

- Create new home route with comprehensive dashboard statistics
- Implement Mithril rendering support with new `mithril_loader.html` template
- Add new routes for home and test pages in `app.py`
- Create `lib/mithril.py` with Mithril rendering and error handling utilities
- Update dashboard template to use new home route
- Add detailed dashboard view with timer and HTTP function statistics
This commit is contained in:
Peter Stockings
2025-02-20 23:35:46 +11:00
parent 24a0c0ffef
commit d4c0c0f262
9 changed files with 571 additions and 5 deletions

5
app.py
View File

@@ -11,6 +11,8 @@ from werkzeug.security import check_password_hash, generate_password_hash
import os
from dotenv import load_dotenv
from routes.timer import timer
from routes.test import test
from routes.home import home
# Load environment variables from .env file in non-production environments
if os.environ.get('FLASK_ENV') != 'production':
@@ -24,7 +26,10 @@ login_manager.init_app(app)
login_manager.login_view = "login"
jinja_partials.register_extensions(app)
init_app(app)
app.register_blueprint(timer, url_prefix='/timer')
app.register_blueprint(test, url_prefix='/test')
app.register_blueprint(home, url_prefix='/home')
class User(UserMixin):
def __init__(self, id, username, password_hash, created_at):

83
lib/mithril.py Normal file
View File

@@ -0,0 +1,83 @@
from flask import render_template, jsonify, request, session
from functools import wraps
import time
class MithrilError(Exception):
def __init__(self, message, code=400):
self.message = message
self.code = code
class Mithril:
_shared_data = {}
@classmethod
def share(cls, key, value):
"""Add shared data that will be available to all components"""
cls._shared_data[key] = value
@staticmethod
def render(component_name, props=None):
"""Render a Mithril component"""
instance = Mithril(component_name, props or {})
return instance()
@classmethod
def error_handler(cls, error_handler):
"""Decorator to register a global error handler"""
cls._error_handler = error_handler
return error_handler
def __init__(self, component_name, props=None):
self.component_name = component_name
self.props = props or {}
self.version = int(time.time()) # Simple asset versioning
def _prepare_response_data(self):
"""Prepare the data to be sent to the frontend"""
return {
'component': self.component_name,
'props': self.props,
'shared': self._shared_data,
'version': self.version,
'errors': session.get('errors', {}), # Flash-style errors
'flash': session.get('flash', []) # Flash messages
}
def __call__(self):
try:
response_data = self._prepare_response_data()
# Clear flash data after preparing response
session.pop('errors', None)
session.pop('flash', None)
if request.headers.get('X-Mithril-Request'):
return jsonify(response_data)
return render_template('mithril_loader.html', **response_data)
except Exception as e:
if hasattr(self, '_error_handler'):
return self._error_handler(e)
raise
@classmethod
def form(cls, route):
"""Decorator for handling form submissions"""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
try:
if request.headers.get('X-Mithril-Request'):
form_data = request.get_json()
else:
form_data = request.form
return f(form_data, *args, **kwargs)
except MithrilError as e:
session['errors'] = {'message': e.message}
if request.headers.get('X-Mithril-Request'):
return jsonify({'errors': {'message': e.message}}), e.code
return cls.redirect_back()
return wrapped
return decorator

133
routes/home.py Normal file
View File

@@ -0,0 +1,133 @@
from flask import Blueprint, render_template, request
from flask_login import login_required, current_user
from extensions import db, htmx, environment
from jinja2_fragments import render_block
home = Blueprint('home', __name__)
@home.route('/')
@login_required
def index():
# Fetch user statistics
stats = db.execute("""
WITH timer_stats AS (
SELECT
COUNT(*) as total_timer_functions,
COUNT(*) FILTER (WHERE enabled = true) as active_timer_functions,
(SELECT COUNT(*) FROM timer_function_invocations tfi
JOIN timer_functions tf ON tf.id = tfi.timer_function_id
WHERE tf.user_id = %s) as timer_invocations,
(SELECT COUNT(*) FROM timer_function_invocations tfi
JOIN timer_functions tf ON tf.id = tfi.timer_function_id
WHERE tf.user_id = %s AND tfi.status = 'SUCCESS') as timer_successful_invocations,
MAX(last_run) as last_timer_invocation
FROM timer_functions
WHERE user_id = %s
),
http_stats AS (
SELECT
COUNT(*) as total_http_functions,
COUNT(*) FILTER (WHERE is_public = true) as public_http_functions,
SUM(invoked_count) as http_invocations,
(SELECT COUNT(*) FROM http_function_invocations hfi
JOIN http_functions hf ON hf.id = hfi.http_function_id
WHERE hf.user_id = %s AND hfi.status = 'SUCCESS') as http_successful_invocations,
(SELECT MAX(invocation_time)
FROM http_function_invocations hfi
JOIN http_functions hf ON hf.id = hfi.http_function_id
WHERE hf.user_id = %s) as last_http_invocation
FROM http_functions
WHERE user_id = %s
)
SELECT
*,
CASE
WHEN timer_invocations > 0 THEN
(timer_successful_invocations * 100.0 / timer_invocations)::numeric(5,1)
ELSE 0.0
END as timer_success_rate,
CASE
WHEN http_invocations > 0 THEN
(http_successful_invocations * 100.0 / http_invocations)::numeric(5,1)
ELSE 0.0
END as http_success_rate
FROM timer_stats, http_stats
""", [current_user.id, current_user.id, current_user.id, current_user.id, current_user.id, current_user.id], one=True)
# Get 24-hour distribution
hour_distribution = db.execute("""
WITH all_invocations AS (
SELECT date_trunc('hour', tfi.invocation_time) as hour_bucket
FROM timer_function_invocations tfi
JOIN timer_functions tf ON tf.id = tfi.timer_function_id
WHERE tf.user_id = %s
AND tfi.invocation_time > NOW() - INTERVAL '24 hours'
UNION ALL
SELECT date_trunc('hour', hfi.invocation_time) as hour_bucket
FROM http_function_invocations hfi
JOIN http_functions hf ON hf.id = hfi.http_function_id
WHERE hf.user_id = %s
AND hfi.invocation_time > NOW() - INTERVAL '24 hours'
)
SELECT
EXTRACT(HOUR FROM hour_bucket) as hour,
COUNT(*) as count
FROM all_invocations
GROUP BY hour
ORDER BY hour
""", [current_user.id, current_user.id])
# Get 7-day success rate trend
success_trend = db.execute("""
WITH daily_stats AS (
WITH timer_daily AS (
SELECT
date_trunc('day', tfi.invocation_time) as day,
COUNT(*) as total,
COUNT(*) FILTER (WHERE tfi.status = 'SUCCESS') as successes
FROM timer_function_invocations tfi
JOIN timer_functions tf ON tf.id = tfi.timer_function_id
WHERE tf.user_id = %s
AND tfi.invocation_time > NOW() - INTERVAL '7 days'
GROUP BY day
),
http_daily AS (
SELECT
date_trunc('day', hfi.invocation_time) as day,
COUNT(*) as total,
COUNT(*) FILTER (WHERE hfi.status = 'SUCCESS') as successes
FROM http_function_invocations hfi
JOIN http_functions hf ON hf.id = hfi.http_function_id
WHERE hf.user_id = %s
AND hfi.invocation_time > NOW() - INTERVAL '7 days'
GROUP BY day
)
SELECT
COALESCE(t.day, h.day) as day,
COALESCE(t.total, 0) + COALESCE(h.total, 0) as total,
COALESCE(t.successes, 0) + COALESCE(h.successes, 0) as successes
FROM timer_daily t
FULL OUTER JOIN http_daily h ON t.day = h.day
)
SELECT
to_char(day, 'Dy') as day_name,
CASE
WHEN total > 0 THEN
(successes * 100.0 / total)::float
ELSE 0.0
END as success_rate
FROM daily_stats
ORDER BY day DESC
LIMIT 7
""", [current_user.id, current_user.id])
if htmx:
return render_block(environment, 'dashboard/home.html', 'page',
stats=stats,
hour_distribution=hour_distribution,
success_trend=success_trend)
return render_template('dashboard/home.html',
stats=stats,
hour_distribution=hour_distribution,
success_trend=success_trend)

48
routes/test.py Normal file
View File

@@ -0,0 +1,48 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from jinja2_fragments import render_block
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import current_user, login_required
from extensions import db, htmx, environment
from datetime import datetime, timezone, timedelta
from lib.mithril import Mithril
import json
test = Blueprint('test', __name__)
@test.route('/mithril/<int:function_id>')
@login_required
def mithril(function_id):
# Fetch the timer function to verify ownership
timer_function = db.execute("""
SELECT id, name, code, version_number
FROM timer_functions
WHERE id = %s AND user_id = %s
""", [function_id, current_user.id], one=True)
if not timer_function:
flash('Timer function not found', 'error')
return redirect(url_for('timer.overview'))
# Fetch all versions
versions = db.execute("""
SELECT version_number, script, versioned_at
FROM timer_function_versions
WHERE timer_function_id = %s
ORDER BY version_number DESC
""", [function_id])
# Convert datetime objects to ISO format strings
for version in versions:
version['versioned_at'] = version['versioned_at'].isoformat() if version['versioned_at'] else None
args = {
'user_id': current_user.id,
'function_id': function_id,
'timer_function': timer_function,
'versions': versions
}
return Mithril.render('DiffView', args)
#return render_template('mithril_loader.html', args=args)

View File

@@ -153,7 +153,7 @@ def overview():
timer_functions = db.execute("""
SELECT id, name, code, environment, trigger_type,
frequency_minutes, run_date, next_run,
last_run, enabled
last_run, enabled, invocation_count
FROM timer_functions
WHERE user_id = %s
ORDER BY id DESC
@@ -237,7 +237,7 @@ def edit(function_id):
timer_function = db.execute("""
SELECT id, name, code, environment, version_number, trigger_type,
frequency_minutes, run_date, next_run,
last_run, enabled
last_run, enabled, invocation_count
FROM timer_functions
WHERE id = %s AND user_id = %s
""", [function_id, current_user.id], one=True)
@@ -369,7 +369,7 @@ def toggle(function_id):
timer_functions = db.execute("""
SELECT id, name, code, environment, trigger_type,
frequency_minutes, run_date, next_run,
last_run, enabled
last_run, enabled, invocation_count
FROM timer_functions
WHERE user_id = %s
ORDER BY id DESC

View File

@@ -23,8 +23,8 @@
<div class="flex-1 overflow-auto py-2" data-id="11">
<nav class="grid items-start px-4 text-sm font-medium" data-id="12"><a
class="flex items-center gap-3 rounded-lg px-3 py-2 text-gray-500 transition-all hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-50 cursor-pointer"
href="{{ url_for('home') }}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
href="{{ url_for('home.index') }}"><svg xmlns="http://www.w3.org/2000/svg" width="24"
height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" data-id="14">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>

View File

@@ -0,0 +1,282 @@
{% extends 'dashboard.html' %}
{% block page %}
<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>
</div>
<!-- Timer Functions Stats -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-700 mb-4">Timer Functions</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Total Timer Functions Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<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>
<div class="ml-4">
<h2 class="text-sm font-medium text-gray-500">Total Timer Functions</h2>
<p class="text-2xl font-semibold text-gray-900">{{ stats.total_timer_functions }}</p>
</div>
</div>
</div>
<!-- Active Timer Functions Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
<div class="ml-4">
<h2 class="text-sm font-medium text-gray-500">Active Timer Functions</h2>
<p class="text-2xl font-semibold text-gray-900">{{ stats.active_timer_functions }}</p>
</div>
</div>
</div>
<!-- Timer Function Invocations Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-purple-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-purple-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="ml-4">
<h2 class="text-sm font-medium text-gray-500">Timer Invocations</h2>
<p class="text-2xl font-semibold text-gray-900">{{ stats.timer_invocations }}</p>
</div>
</div>
</div>
<!-- Timer Success Rate Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-emerald-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-emerald-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
</div>
<div class="ml-4">
<h2 class="text-sm font-medium text-gray-500">Success Rate</h2>
<div class="flex items-baseline">
<p class="text-2xl font-semibold text-gray-900">{{ stats.timer_success_rate }}%</p>
<p class="ml-2 text-sm text-gray-500">({{ stats.timer_successful_invocations }}/{{
stats.timer_invocations }})</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- HTTP Functions Stats -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-700 mb-4">HTTP Functions</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Total HTTP Functions Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-indigo-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<div class="ml-4">
<h2 class="text-sm font-medium text-gray-500">Total HTTP Functions</h2>
<p class="text-2xl font-semibold text-gray-900">{{ stats.total_http_functions }}</p>
</div>
</div>
</div>
<!-- Public HTTP Functions Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-yellow-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-yellow-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064" />
</svg>
</div>
<div class="ml-4">
<h2 class="text-sm font-medium text-gray-500">Public HTTP Functions</h2>
<p class="text-2xl font-semibold text-gray-900">{{ stats.public_http_functions }}</p>
</div>
</div>
</div>
<!-- HTTP Function Invocations Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-red-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-red-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="ml-4">
<h2 class="text-sm font-medium text-gray-500">HTTP Invocations</h2>
<p class="text-2xl font-semibold text-gray-900">{{ stats.http_invocations }}</p>
</div>
</div>
</div>
<!-- HTTP Success Rate Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-emerald-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-emerald-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
</div>
<div class="ml-4">
<h2 class="text-sm font-medium text-gray-500">Success Rate</h2>
<div class="flex items-baseline">
<p class="text-2xl font-semibold text-gray-900">{{ stats.http_success_rate }}%</p>
<p class="ml-2 text-sm text-gray-500">({{ stats.http_successful_invocations }}/{{
stats.http_invocations }})</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add this after the stats cards and before the Last Activity section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- 24-Hour Distribution -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">24-Hour Invocation Distribution</h3>
<div class="relative h-64">
<svg class="w-full h-full" viewBox="0 0 400 200" preserveAspectRatio="none">
<!-- Y-axis -->
<line x1="40" y1="20" x2="40" y2="180" stroke="#E5E7EB" stroke-width="1" />
<!-- X-axis -->
<line x1="40" y1="180" x2="380" y2="180" stroke="#E5E7EB" stroke-width="1" />
{% set max_count = namespace(value=1) %}
{% for hour in hour_distribution %}
{% if hour.count > max_count.value %}
{% set max_count.value = hour.count %}
{% endif %}
{% endfor %}
<!-- Hour bars -->
{% for hour in hour_distribution %}
{% set x = 40 + (hour.hour * 14) %}
{% set height = (hour.count / max_count.value) * 160 %}
{% set y = 180 - height %}
<rect x="{{ x }}" y="{{ y }}" width="12" height="{{ height }}" fill="#60A5FA" opacity="0.8">
<title>{{ hour.hour }}:00 - {{ hour.count }} invocations</title>
</rect>
{% endfor %}
<!-- Hour labels (every 6 hours) -->
{% for h in [0, 6, 12, 18, 23] %}
<text x="{{ 40 + (h * 14) }}" y="195" text-anchor="middle" class="text-xs text-gray-500">{{ h
}}h</text>
{% endfor %}
</svg>
</div>
</div>
<!-- 7-Day Success Rate Trend -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">7-Day Success Rate Trend</h3>
<div class="relative h-64">
<svg class="w-full h-full" viewBox="0 0 400 200" preserveAspectRatio="none">
<!-- Y-axis -->
<line x1="40" y1="20" x2="40" y2="180" stroke="#E5E7EB" stroke-width="1" />
<!-- X-axis -->
<line x1="40" y1="180" x2="380" y2="180" stroke="#E5E7EB" stroke-width="1" />
<!-- Success rate line -->
{% set path = namespace(d='M') %}
{% for day in success_trend %}
{% set x = 380 - (loop.index0 * 48) %}
{% set y = 180 - (day.success_rate * 1.6) %}
{% if loop.first %}
{% set path.d = path.d ~ x ~ "," ~ y %}
{% else %}
{% set path.d = path.d ~ " L" ~ x ~ "," ~ y %}
{% endif %}
{% endfor %}
<path d="{{ path.d }}" stroke="#10B981" stroke-width="2" fill="none" />
<!-- Data points -->
{% for day in success_trend %}
{% set x = 380 - (loop.index0 * 48) %}
{% set y = 180 - (day.success_rate * 1.6) %}
<circle cx="{{ x }}" cy="{{ y }}" r="4" fill="#10B981">
<title>{{ day.day_name }}: {{ day.success_rate }}% success rate</title>
</circle>
<!-- Day labels -->
<text x="{{ x }}" y="195" text-anchor="middle" class="text-xs text-gray-500">{{ day.day_name
}}</text>
{% endfor %}
<!-- Y-axis labels -->
{% for percent in [0, 25, 50, 75, 100] %}
<text x="35" y="{{ 180 - (percent * 1.6) }}" text-anchor="end" class="text-xs text-gray-500">{{
percent }}%</text>
{% endfor %}
</svg>
</div>
</div>
</div>
<!-- Last Activity -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-medium text-gray-900">Last Activity</h2>
<div class="mt-1 space-y-1">
{% if stats.last_timer_invocation %}
<p class="text-sm text-gray-500">
Last Timer Invocation: {{ stats.last_timer_invocation.strftime('%Y-%m-%d %H:%M') }}
</p>
{% endif %}
{% if stats.last_http_invocation %}
<p class="text-sm text-gray-500">
Last HTTP Invocation: {{ stats.last_http_invocation.strftime('%Y-%m-%d %H:%M') }}
</p>
{% endif %}
</div>
</div>
<div class="space-x-4">
<a href="{{ url_for('timer.overview') }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors duration-200">
View Timer Functions
</a>
<a href="{{ url_for('dashboard_http_functions') }}"
class="inline-flex items-center px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors duration-200">
View HTTP Functions
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -36,6 +36,10 @@
hx-get="{{ url_for('timer.edit', function_id=function.id) }}" hx-target="#container"
hx-swap="innerHTML" hx-push-url="true">
<span class="font-medium text-gray-900">{{ function.name }}</span>
<span
class="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
{{ function.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">
Last run: {{ function.last_run.strftime('%Y-%m-%d %H:%M') }}

View File

@@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<div id="mithril-loader"></div>
<script>
m.mount(document.getElementById('mithril-loader'), {
view: () => m(DiffView, {{ props | tojson | safe }})
});
</script>
{% endblock %}