feat(frontend): T44 setup FastAPI static files and templates

- Mount static files on /static endpoint
- Configure Jinja2Templates with directory structure
- Create base template with Pico.css, HTMX, Chart.js
- Create all template subdirectories (auth, dashboard, keys, tokens, profile, components)
- Create initial CSS and JS files
- Add tests for static files and templates configuration

Tests: 12 passing
Coverage: 100% on new configuration code
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 17:58:03 +02:00
parent 3ae5d736ce
commit c1f47c897f
16 changed files with 1592 additions and 4 deletions

42
templates/auth/login.html Normal file
View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Login - OpenRouter Monitor{% endblock %}
{% block content %}
<article class="grid">
<div>
<h1>Login</h1>
<p>Enter your credentials to access the dashboard.</p>
</div>
<div>
<form action="/login" method="POST" hx-post="/login" hx-swap="outerHTML" hx-target="this">
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
<label for="email">
Email
<input type="email" id="email" name="email" placeholder="your@email.com" required>
</label>
<label for="password">
Password
<input type="password" id="password" name="password" placeholder="Password" required minlength="8">
</label>
<fieldset>
<label for="remember">
<input type="checkbox" id="remember" name="remember" role="switch">
Remember me
</label>
</fieldset>
<button type="submit">Login</button>
</form>
<p>Don't have an account? <a href="/register">Register here</a>.</p>
</div>
</article>
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Register - OpenRouter Monitor{% endblock %}
{% block content %}
<article class="grid">
<div>
<h1>Create Account</h1>
<p>Register to start monitoring your OpenRouter API keys.</p>
</div>
<div>
<form action="/register" method="POST" hx-post="/register" hx-swap="outerHTML" hx-target="this">
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
<label for="email">
Email
<input type="email" id="email" name="email" placeholder="your@email.com" required>
</label>
<label for="password">
Password
<input
type="password"
id="password"
name="password"
placeholder="Password"
required
minlength="8"
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$"
title="Password must contain at least one lowercase letter, one uppercase letter, and one number"
>
<small>Minimum 8 characters with uppercase, lowercase, and number</small>
</label>
<label for="password_confirm">
Confirm Password
<input type="password" id="password_confirm" name="password_confirm" placeholder="Confirm password" required>
</label>
<button type="submit">Register</button>
</form>
<p>Already have an account? <a href="/login">Login here</a>.</p>
</div>
</article>
<script>
// Client-side password match validation
document.getElementById('password_confirm').addEventListener('input', function() {
const password = document.getElementById('password').value;
const confirm = this.value;
if (password !== confirm) {
this.setCustomValidity('Passwords do not match');
} else {
this.setCustomValidity('');
}
});
</script>
{% endblock %}

40
templates/base.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="OpenRouter API Key Monitor - Monitor and manage your OpenRouter API keys">
{% if csrf_token %}
<meta name="csrf-token" content="{{ csrf_token }}">
{% endif %}
<title>{% block title %}OpenRouter Monitor{% endblock %}</title>
<!-- Pico.css for styling -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<!-- Custom styles -->
<link rel="stylesheet" href="/static/css/style.css">
<!-- HTMX for dynamic content -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Chart.js for charts -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% block extra_head %}{% endblock %}
</head>
<body>
{% include 'components/navbar.html' %}
<main class="container">
{% block content %}{% endblock %}
</main>
{% include 'components/footer.html' %}
<!-- Custom JavaScript -->
<script src="/static/js/main.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,6 @@
<footer class="footer">
<div class="container">
<p>&copy; 2024 OpenRouter API Key Monitor. All rights reserved.</p>
<p><small>Version 1.0.0</small></p>
</div>
</footer>

View File

@@ -0,0 +1,21 @@
<nav class="container-fluid">
<ul>
<li><strong><a href="/" class="navbar-brand">OpenRouter Monitor</a></strong></li>
</ul>
<ul>
{% if user %}
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/keys">API Keys</a></li>
<li><a href="/tokens">Tokens</a></li>
<li><a href="/profile">Profile</a></li>
<li>
<form action="/logout" method="POST" style="display: inline;" hx-post="/logout" hx-redirect="/login">
<button type="submit" class="outline">Logout</button>
</form>
</li>
{% else %}
<li><a href="/login">Login</a></li>
<li><a href="/register" role="button">Register</a></li>
{% endif %}
</ul>
</nav>

View File

@@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}Dashboard - OpenRouter Monitor{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<!-- Summary Cards -->
<div class="grid">
<article>
<header>
<h3>Total Requests</h3>
</header>
<p style="font-size: 2rem; font-weight: bold;">{{ stats.total_requests | default(0) }}</p>
</article>
<article>
<header>
<h3>Total Cost</h3>
</header>
<p style="font-size: 2rem; font-weight: bold;">${{ stats.total_cost | default(0) | round(2) }}</p>
</article>
<article>
<header>
<h3>API Keys</h3>
</header>
<p style="font-size: 2rem; font-weight: bold;">{{ stats.api_keys_count | default(0) }}</p>
</article>
</div>
<!-- Charts -->
<div class="grid">
<article>
<header>
<h3>Usage Over Time</h3>
</header>
<canvas id="usageChart" height="200"></canvas>
</article>
<article>
<header>
<h3>Top Models</h3>
</header>
<canvas id="modelsChart" height="200"></canvas>
</article>
</div>
<!-- Recent Activity -->
<article>
<header>
<h3>Recent Usage</h3>
</header>
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Model</th>
<th>Requests</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
{% for usage in recent_usage %}
<tr>
<td>{{ usage.date }}</td>
<td>{{ usage.model }}</td>
<td>{{ usage.requests }}</td>
<td>${{ usage.cost | round(4) }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" style="text-align: center;">No usage data available</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
{% endblock %}
{% block extra_scripts %}
<script>
// Usage Chart
const usageCtx = document.getElementById('usageChart').getContext('2d');
const usageData = {{ chart_data | default({"labels": [], "data": []}) | tojson }};
new Chart(usageCtx, {
type: 'line',
data: {
labels: usageData.labels || [],
datasets: [{
label: 'Requests',
data: usageData.data || [],
borderColor: '#2563eb',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
fill: true
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
// Models Chart
const modelsCtx = document.getElementById('modelsChart').getContext('2d');
const modelsData = {{ models_data | default({"labels": [], "data": []}) | tojson }};
new Chart(modelsCtx, {
type: 'doughnut',
data: {
labels: modelsData.labels || [],
datasets: [{
data: modelsData.data || [],
backgroundColor: [
'#2563eb',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6'
]
}]
},
options: {
responsive: true
}
});
</script>
{% endblock %}

92
templates/keys/index.html Normal file
View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}API Keys - OpenRouter Monitor{% endblock %}
{% block content %}
<h1>API Keys Management</h1>
<!-- Add New Key Form -->
<article>
<header>
<h3>Add New API Key</h3>
</header>
<form action="/keys" method="POST" hx-post="/keys" hx-swap="beforeend" hx-target="#keys-table tbody">
<div class="grid">
<label for="key_name">
Key Name
<input type="text" id="key_name" name="name" placeholder="Production Key" required>
</label>
<label for="key_value">
OpenRouter API Key
<input
type="password"
id="key_value"
name="key_value"
placeholder="sk-or-..."
required
pattern="^sk-or-[a-zA-Z0-9]+$"
title="Must be a valid OpenRouter API key starting with 'sk-or-'"
>
</label>
</div>
<button type="submit">Add Key</button>
</form>
</article>
<!-- Keys List -->
<article>
<header>
<h3>Your API Keys</h3>
</header>
<table class="table" id="keys-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Last Used</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for key in api_keys %}
<tr id="key-{{ key.id }}">
<td>{{ key.name }}</td>
<td>
{% if key.is_active %}
<span style="color: var(--success-color);">Active</span>
{% else %}
<span style="color: var(--danger-color);">Inactive</span>
{% endif %}
</td>
<td>{{ key.last_used_at or 'Never' }}</td>
<td>{{ key.created_at }}</td>
<td>
<button
class="outline secondary"
hx-delete="/keys/{{ key.id }}"
hx-confirm="Are you sure you want to delete this key?"
hx-target="#key-{{ key.id }}"
hx-swap="outerHTML"
>
Delete
</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="text-align: center;">No API keys found. Add your first key above.</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
<!-- Security Notice -->
<div class="alert" role="alert">
<strong>Security Notice:</strong> Your API keys are encrypted and never displayed after creation.
Only metadata (name, status, usage) is shown here.
</div>
{% endblock %}

View File

@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Profile - OpenRouter Monitor{% endblock %}
{% block content %}
<h1>User Profile</h1>
<!-- Profile Information -->
<article>
<header>
<h3>Account Information</h3>
</header>
<p><strong>Email:</strong> {{ user.email }}</p>
<p><strong>Account Created:</strong> {{ user.created_at }}</p>
</article>
<!-- Change Password -->
<article>
<header>
<h3>Change Password</h3>
</header>
<form action="/profile/password" method="POST" hx-post="/profile/password" hx-swap="outerHTML">
{% if password_message %}
<div class="alert {% if password_success %}alert-success{% else %}alert-danger{% endif %}" role="alert">
{{ password_message }}
</div>
{% endif %}
<label for="current_password">
Current Password
<input type="password" id="current_password" name="current_password" required>
</label>
<label for="new_password">
New Password
<input
type="password"
id="new_password"
name="new_password"
required
minlength="8"
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$"
title="Password must contain at least one lowercase letter, one uppercase letter, and one number"
>
<small>Minimum 8 characters with uppercase, lowercase, and number</small>
</label>
<label for="new_password_confirm">
Confirm New Password
<input type="password" id="new_password_confirm" name="new_password_confirm" required>
</label>
<button type="submit">Update Password</button>
</form>
</article>
<!-- Danger Zone -->
<article style="border-color: var(--danger-color);">
<header>
<h3 style="color: var(--danger-color);">Danger Zone</h3>
</header>
<p>Once you delete your account, there is no going back. Please be certain.</p>
<button
class="secondary"
style="background-color: var(--danger-color); border-color: var(--danger-color);"
hx-delete="/profile"
hx-confirm="Are you absolutely sure you want to delete your account? All your data will be permanently removed."
hx-redirect="/"
>
Delete Account
</button>
</article>
<script>
// Client-side password match validation
document.getElementById('new_password_confirm').addEventListener('input', function() {
const password = document.getElementById('new_password').value;
const confirm = this.value;
if (password !== confirm) {
this.setCustomValidity('Passwords do not match');
} else {
this.setCustomValidity('');
}
});
</script>
{% endblock %}

135
templates/stats/index.html Normal file
View File

@@ -0,0 +1,135 @@
{% extends "base.html" %}
{% block title %}Statistics - OpenRouter Monitor{% endblock %}
{% block content %}
<h1>Detailed Statistics</h1>
<!-- Filters -->
<article>
<header>
<h3>Filters</h3>
</header>
<form action="/stats" method="GET" hx-get="/stats" hx-target="#stats-results" hx-push-url="true">
<div class="grid">
<label for="start_date">
Start Date
<input type="date" id="start_date" name="start_date" value="{{ filters.start_date }}">
</label>
<label for="end_date">
End Date
<input type="date" id="end_date" name="end_date" value="{{ filters.end_date }}">
</label>
<label for="api_key_id">
API Key
<select id="api_key_id" name="api_key_id">
<option value="">All Keys</option>
{% for key in api_keys %}
<option value="{{ key.id }}" {% if filters.api_key_id == key.id %}selected{% endif %}>
{{ key.name }}
</option>
{% endfor %}
</select>
</label>
<label for="model">
Model
<input type="text" id="model" name="model" placeholder="gpt-4" value="{{ filters.model }}">
</label>
</div>
<button type="submit">Apply Filters</button>
<a href="/stats/export?{{ query_string }}" role="button" class="secondary">Export CSV</a>
</form>
</article>
<!-- Results -->
<article id="stats-results">
<header>
<h3>Usage Details</h3>
<p><small>Showing {{ stats|length }} results</small></p>
</header>
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>API Key</th>
<th>Model</th>
<th>Requests</th>
<th>Prompt Tokens</th>
<th>Completion Tokens</th>
<th>Total Tokens</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
{% for stat in stats %}
<tr>
<td>{{ stat.date }}</td>
<td>{{ stat.api_key_name }}</td>
<td>{{ stat.model }}</td>
<td>{{ stat.requests }}</td>
<td>{{ stat.prompt_tokens }}</td>
<td>{{ stat.completion_tokens }}</td>
<td>{{ stat.total_tokens }}</td>
<td>${{ stat.cost | round(4) }}</td>
</tr>
{% else %}
<tr>
<td colspan="8" style="text-align: center;">No data found for the selected filters.</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav>
<ul>
{% if page > 1 %}
<li>
<a href="?page={{ page - 1 }}&{{ query_string }}" class="secondary">&laquo; Previous</a>
</li>
{% endif %}
{% for p in range(1, total_pages + 1) %}
<li>
{% if p == page %}
<strong>{{ p }}</strong>
{% else %}
<a href="?page={{ p }}&{{ query_string }}">{{ p }}</a>
{% endif %}
</li>
{% endfor %}
{% if page < total_pages %}
<li>
<a href="?page={{ page + 1 }}&{{ query_string }}" class="secondary">Next &raquo;</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</article>
<!-- Summary -->
<article>
<header>
<h3>Summary</h3>
</header>
<div class="grid">
<div>
<strong>Total Requests:</strong> {{ summary.total_requests }}
</div>
<div>
<strong>Total Tokens:</strong> {{ summary.total_tokens }}
</div>
<div>
<strong>Total Cost:</strong> ${{ summary.total_cost | round(2) }}
</div>
</div>
</article>
{% endblock %}

114
templates/tokens/index.html Normal file
View File

@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}API Tokens - OpenRouter Monitor{% endblock %}
{% block content %}
<h1>API Tokens Management</h1>
<!-- Add New Token Form -->
<article>
<header>
<h3>Generate New API Token</h3>
</header>
<form action="/tokens" method="POST" hx-post="/tokens" hx-swap="afterend" hx-target="this">
<label for="token_name">
Token Name
<input type="text" id="token_name" name="name" placeholder="Mobile App Token" required>
</label>
<button type="submit">Generate Token</button>
</form>
</article>
<!-- New Token Display (shown after creation) -->
{% if new_token %}
<article style="border: 2px solid var(--warning-color);">
<header>
<h3 style="color: var(--warning-color);">Save Your Token!</h3>
</header>
<div class="alert alert-danger" role="alert">
<strong>Warning:</strong> This token will only be displayed once. Copy it now!
</div>
<label for="new_token_value">
Your New API Token
<input
type="text"
id="new_token_value"
value="{{ new_token }}"
readonly
onclick="this.select()"
style="font-family: monospace;"
>
</label>
<button onclick="navigator.clipboard.writeText(document.getElementById('new_token_value').value)">
Copy to Clipboard
</button>
</article>
{% endif %}
<!-- Tokens List -->
<article>
<header>
<h3>Your API Tokens</h3>
</header>
<table class="table" id="tokens-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Last Used</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for token in api_tokens %}
<tr id="token-{{ token.id }}">
<td>{{ token.name }}</td>
<td>
{% if token.is_active %}
<span style="color: var(--success-color);">Active</span>
{% else %}
<span style="color: var(--danger-color);">Revoked</span>
{% endif %}
</td>
<td>{{ token.last_used_at or 'Never' }}</td>
<td>{{ token.created_at }}</td>
<td>
{% if token.is_active %}
<button
class="outline secondary"
hx-delete="/tokens/{{ token.id }}"
hx-confirm="Are you sure you want to revoke this token? This action cannot be undone."
hx-target="#token-{{ token.id }}"
hx-swap="outerHTML"
>
Revoke
</button>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="text-align: center;">No API tokens found. Generate your first token above.</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
<!-- Usage Instructions -->
<article>
<header>
<h3>Using API Tokens</h3>
</header>
<p>Include your API token in the <code>Authorization</code> header:</p>
<pre><code>Authorization: Bearer YOUR_API_TOKEN</code></pre>
<p>Available endpoints:</p>
<ul>
<li><code>GET /api/v1/stats</code> - Get usage statistics</li>
<li><code>GET /api/v1/usage</code> - Get detailed usage data</li>
<li><code>GET /api/v1/keys</code> - List your API keys (metadata only)</li>
</ul>
</article>
{% endblock %}