first version

This commit is contained in:
2025-06-26 14:28:10 +08:00
parent 8e6ac6e0c3
commit a08d1c3287
8 changed files with 992 additions and 3 deletions

4
config.json Normal file
View File

@ -0,0 +1,4 @@
{
"admin_api_key": "admin_key_change_me",
"report_api_key": "report_key_change_me"
}

276
main.py
View File

@ -1,6 +1,276 @@
def main():
print("Hello from acmchat-dashboard!")
from fastapi import FastAPI, Request, HTTPException, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, HTTPBasic, HTTPBasicCredentials
import os
from loguru import logger
import sys
import uvicorn
import json
import aiosqlite
import time
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from contextlib import asynccontextmanager
from pydantic import BaseModel
import secrets
listen_port = os.getenv("LISTEN_PORT", "8000")
listen_host = os.getenv("LISTEN_HOST", "0.0.0.0")
config_path = os.getenv("CONFIG_PATH", "./config.json")
database_path = os.getenv("DATABASE_PATH", "./data.sqlite3")
logging_level = os.getenv("LOGGING_LEVEL", "TRACE")
# Configure loguru properly - remove default handler and add new one with correct level
logger.remove() # Remove the default handler
logger.add(sys.stderr, level=logging_level) # Add new handler with specified level
security_bearer = HTTPBearer()
security_basic = HTTPBasic()
class APICallRecord(BaseModel):
timestamp: int
model_id: str
user_email: str
input_tokens: int
output_tokens: int
cost_usd: float
def normalize_path(path: str) -> str:
"""
处理路径,支持绝对路径、用户目录和相对路径
Args:
path: 输入路径
Returns:
处理后的路径
"""
if path.startswith("/") or path.startswith("~"):
# 绝对路径,如果是~开头则展开为用户目录
return os.path.expanduser(path)
elif path.startswith("./"):
# 相对于脚本本身的相对路径
script_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(script_dir, path[2:]) # 去掉'./'前缀
else:
# 其他情况保持原样相对于pwd的相对路径
return path
# 处理config_path和database_path路径
config_path = normalize_path(config_path)
database_path = normalize_path(database_path)
with open(config_path, "r") as f:
config = json.load(f)
admin_api_key = config.get("admin_api_key", "")
report_api_key = config.get("report_api_key", "")
async def init_database():
"""Initialize the database and create tables if they don't exist"""
async with aiosqlite.connect(database_path) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS api_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
model_id TEXT NOT NULL,
user_email TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cost_usd REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
await db.commit()
logger.info("Database initialized successfully")
async def verify_admin_basic_auth(credentials: HTTPBasicCredentials = Depends(security_basic)):
"""Verify admin credentials for dashboard access using Basic HTTP authentication"""
# Check username and password
is_correct_username = secrets.compare_digest(credentials.username, "admin")
is_correct_password = secrets.compare_digest(credentials.password, admin_api_key)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
async def verify_report_key(credentials: HTTPAuthorizationCredentials = Depends(security_bearer)):
"""Verify report API key for API access using Bearer authentication"""
if credentials.credentials != report_api_key:
raise HTTPException(status_code=403, detail="Invalid report API key")
return credentials.credentials
async def get_24h_stats():
"""Get statistics for the past 24 hours"""
twenty_four_hours_ago = int(time.time()) - (24 * 60 * 60)
async with aiosqlite.connect(database_path) as db:
# Total chats (assuming each API call is a chat)
cursor = await db.execute(
"SELECT COUNT(*) FROM api_calls WHERE timestamp >= ?",
(twenty_four_hours_ago,)
)
total_chats = (await cursor.fetchone())[0]
# Total tokens
cursor = await db.execute(
"SELECT SUM(input_tokens + output_tokens) FROM api_calls WHERE timestamp >= ?",
(twenty_four_hours_ago,)
)
result = await cursor.fetchone()
total_tokens = result[0] if result[0] is not None else 0
# Total cost
cursor = await db.execute(
"SELECT SUM(cost_usd) FROM api_calls WHERE timestamp >= ?",
(twenty_four_hours_ago,)
)
result = await cursor.fetchone()
total_cost = result[0] if result[0] is not None else 0.0
return {
"total_chats": total_chats,
"total_tokens": total_tokens,
"total_cost": round(total_cost, 4)
}
async def get_recent_logs(limit: int = 100):
"""Get recent API call logs"""
async with aiosqlite.connect(database_path) as db:
cursor = await db.execute("""
SELECT timestamp, model_id, user_email, input_tokens, output_tokens, cost_usd
FROM api_calls
ORDER BY timestamp DESC
LIMIT ?
""", (limit,))
logs = []
async for row in cursor:
logs.append({
"timestamp": row[0],
"datetime": datetime.fromtimestamp(row[0]).strftime("%Y-%m-%d %H:%M:%S"),
"model_id": row[1],
"user_email": row[2],
"input_tokens": row[3],
"output_tokens": row[4],
"total_tokens": row[3] + row[4],
"cost_usd": round(row[5], 4)
})
return logs
async def get_users_report():
"""Get user consumption statistics"""
async with aiosqlite.connect(database_path) as db:
cursor = await db.execute("""
SELECT
user_email,
COUNT(*) as total_calls,
SUM(input_tokens) as total_input_tokens,
SUM(output_tokens) as total_output_tokens,
SUM(input_tokens + output_tokens) as total_tokens,
SUM(cost_usd) as total_cost
FROM api_calls
GROUP BY user_email
ORDER BY total_cost DESC
""")
users = []
async for row in cursor:
users.append({
"email": row[0],
"total_calls": row[1],
"total_input_tokens": row[2],
"total_output_tokens": row[3],
"total_tokens": row[4],
"total_cost": round(row[5], 4)
})
return users
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events"""
# Startup
await init_database()
logger.info("Application started")
yield
# Shutdown
logger.info("Application shutting down")
app = FastAPI(lifespan=lifespan)
# Set up templates
templates = Jinja2Templates(directory="templates")
# Routes
@app.get("/")
async def root():
"""Redirect to dashboard"""
return RedirectResponse(url="/dashboard", status_code=302)
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, _: str = Depends(verify_admin_basic_auth)):
"""Main dashboard page"""
stats = await get_24h_stats()
return templates.TemplateResponse("dashboard.html", {
"request": request,
"stats": stats
})
@app.get("/dashboard/logs", response_class=HTMLResponse)
async def dashboard_logs(request: Request, _: str = Depends(verify_admin_basic_auth)):
"""Dashboard logs page"""
logs = await get_recent_logs(200) # Get more logs for the logs page
return templates.TemplateResponse("logs.html", {
"request": request,
"logs": logs
})
@app.get("/dashboard/users_report", response_class=HTMLResponse)
async def dashboard_users_report(request: Request, _: str = Depends(verify_admin_basic_auth)):
"""Dashboard users report page"""
users = await get_users_report()
return templates.TemplateResponse("users_report.html", {
"request": request,
"users": users
})
@app.post("/api/record_api_call")
async def record_api_call(
record: APICallRecord,
_: str = Depends(verify_report_key)
):
"""Record an API call"""
try:
async with aiosqlite.connect(database_path) as db:
await db.execute("""
INSERT INTO api_calls (timestamp, model_id, user_email, input_tokens, output_tokens, cost_usd)
VALUES (?, ?, ?, ?, ?, ?)
""", (
record.timestamp,
record.model_id,
record.user_email,
record.input_tokens,
record.output_tokens,
record.cost_usd
))
await db.commit()
logger.info(f"Recorded API call: {record.user_email} - {record.model_id} - ${record.cost_usd}")
return {"status": "success", "message": "API call recorded successfully"}
except Exception as e:
logger.error(f"Error recording API call: {e}")
raise HTTPException(status_code=500, detail="Failed to record API call")
if __name__ == "__main__":
main()
uvicorn.run(app, host=listen_host, port=int(listen_port))

View File

@ -5,8 +5,11 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.12"
dependencies = [
"aiosqlite>=0.21.0",
"fastapi>=0.115.13",
"jinja2>=3.1.6",
"loguru>=0.7.3",
"python-multipart>=0.0.20",
"ruff>=0.12.0",
"uvicorn>=0.34.3",
]

111
templates/base.html Normal file
View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}OpenWebUI Monitoring Dashboard{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
.navbar-brand {
font-weight: bold;
}
.stats-card {
transition: transform 0.2s;
}
.stats-card:hover {
transform: translateY(-2px);
}
.table-responsive {
max-height: 600px;
overflow-y: auto;
}
.sidebar {
min-height: calc(100vh - 56px);
background-color: #f8f9fa;
}
.main-content {
padding: 2rem;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="/dashboard">
<i class="bi bi-speedometer2"></i> OpenWebUI Monitor
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<span class="navbar-text">
<i class="bi bi-clock"></i> Last Updated: <span id="last-updated">{{ "now" }}</span>
</span>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav class="col-md-3 col-lg-2 d-md-block sidebar">
<div class="position-sticky pt-3">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% if request.url.path == '/dashboard' %}active{% endif %}" href="/dashboard">
<i class="bi bi-house"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/logs' in request.url.path %}active{% endif %}" href="/dashboard/logs">
<i class="bi bi-journal-text"></i> Logs
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/users_report' in request.url.path %}active{% endif %}" href="/dashboard/users_report">
<i class="bi bi-people"></i> Users Report
</a>
</li>
</ul>
</div>
</nav>
<!-- Main Content -->
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
{% block content %}{% endblock %}
</main>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Auto refresh functionality -->
<script>
function updateTimestamp() {
document.getElementById('last-updated').textContent = new Date().toLocaleString();
}
// Update timestamp every 30 seconds
setInterval(updateTimestamp, 30000);
updateTimestamp();
// Auto refresh page every 5 minutes for dashboard
if (window.location.pathname === '/dashboard') {
setInterval(() => {
window.location.reload();
}, 300000);
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

157
templates/dashboard.html Normal file
View File

@ -0,0 +1,157 @@
{% extends "base.html" %}
{% block title %}Dashboard - OpenWebUI Monitor{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Dashboard</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="window.location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
<!-- 24 Hour Statistics -->
<div class="row mb-4">
<div class="col-md-4 mb-3">
<div class="card stats-card h-100 border-primary">
<div class="card-body text-center">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title text-primary">
<i class="bi bi-chat-dots"></i> Total Chats
</h5>
<h2 class="card-text">{{ stats.total_chats }}</h2>
<small class="text-muted">Last 24 hours</small>
</div>
<div class="text-primary">
<i class="bi bi-chat-dots display-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card stats-card h-100 border-success">
<div class="card-body text-center">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title text-success">
<i class="bi bi-cpu"></i> Total Tokens
</h5>
<h2 class="card-text">{{ "{:,}".format(stats.total_tokens) }}</h2>
<small class="text-muted">Last 24 hours</small>
</div>
<div class="text-success">
<i class="bi bi-cpu display-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card stats-card h-100 border-warning">
<div class="card-body text-center">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title text-warning">
<i class="bi bi-currency-dollar"></i> Total Cost
</h5>
<h2 class="card-text">${{ stats.total_cost }}</h2>
<small class="text-muted">Last 24 hours</small>
</div>
<div class="text-warning">
<i class="bi bi-currency-dollar display-4"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-lightning"></i> Quick Actions
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<div class="d-grid">
<a href="/dashboard/logs" class="btn btn-outline-primary btn-lg">
<i class="bi bi-journal-text"></i> View Recent Logs
</a>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="d-grid">
<a href="/dashboard/users_report" class="btn btn-outline-success btn-lg">
<i class="bi bi-people"></i> View Users Report
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- System Information -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-info-circle"></i> System Information
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">
<span class="badge bg-success">
<i class="bi bi-check-circle"></i> Online
</span>
</dd>
<dt class="col-sm-4">Database:</dt>
<dd class="col-sm-8">
<span class="badge bg-info">SQLite</span>
</dd>
</dl>
</div>
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">API Version:</dt>
<dd class="col-sm-8">v1.0</dd>
<dt class="col-sm-4">Last Sync:</dt>
<dd class="col-sm-8" id="current-time"></dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function updateCurrentTime() {
document.getElementById('current-time').textContent = new Date().toLocaleString();
}
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
</script>
{% endblock %}

163
templates/logs.html Normal file
View File

@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}Logs - OpenWebUI Monitor{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">API Call Logs</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="window.location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
{% if logs %}
<!-- Summary Info -->
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
Showing <strong>{{ logs|length }}</strong> most recent API calls.
Logs are ordered by timestamp (newest first).
</div>
</div>
</div>
<!-- Logs Table -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-journal-text"></i> Recent API Calls
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-dark">
<tr>
<th scope="col">
<i class="bi bi-calendar"></i> Timestamp
</th>
<th scope="col">
<i class="bi bi-envelope"></i> User Email
</th>
<th scope="col">
<i class="bi bi-cpu"></i> Model
</th>
<th scope="col">
<i class="bi bi-arrow-down"></i> Input Tokens
</th>
<th scope="col">
<i class="bi bi-arrow-up"></i> Output Tokens
</th>
<th scope="col">
<i class="bi bi-plus-circle"></i> Total Tokens
</th>
<th scope="col">
<i class="bi bi-currency-dollar"></i> Cost (USD)
</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>
<small class="text-muted">{{ log.datetime }}</small>
</td>
<td>
<span class="badge bg-secondary">{{ log.user_email }}</span>
</td>
<td>
<code>{{ log.model_id }}</code>
</td>
<td>
<span class="badge bg-info">{{ "{:,}".format(log.input_tokens) }}</span>
</td>
<td>
<span class="badge bg-success">{{ "{:,}".format(log.output_tokens) }}</span>
</td>
<td>
<strong>{{ "{:,}".format(log.total_tokens) }}</strong>
</td>
<td>
<span class="badge bg-warning text-dark">${{ log.cost_usd }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Statistics Summary -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-bar-chart"></i> Current Page Statistics
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="text-center">
<h4 class="text-primary">{{ logs|length }}</h4>
<small class="text-muted">Total Calls</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h4 class="text-success">{{ "{:,}".format(logs|sum(attribute='input_tokens')) }}</h4>
<small class="text-muted">Input Tokens</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h4 class="text-info">{{ "{:,}".format(logs|sum(attribute='output_tokens')) }}</h4>
<small class="text-muted">Output Tokens</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h4 class="text-warning">${{ "%.4f"|format(logs|sum(attribute='cost_usd')) }}</h4>
<small class="text-muted">Total Cost</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<!-- No Data -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-journal-x display-1 text-muted"></i>
<h3 class="mt-3">No Logs Available</h3>
<p class="text-muted">No API calls have been recorded yet.</p>
<a href="/dashboard" class="btn btn-primary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Auto-refresh every 30 seconds
setInterval(() => {
window.location.reload();
}, 30000);
</script>
{% endblock %}

219
templates/users_report.html Normal file
View File

@ -0,0 +1,219 @@
{% extends "base.html" %}
{% block title %}Users Report - OpenWebUI Monitor{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Users Report</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="window.location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
{% if users %}
<!-- Summary Info -->
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
Total of <strong>{{ users|length }}</strong> users found.
Users are sorted by total cost (highest first).
</div>
</div>
</div>
<!-- Summary Statistics -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card text-center border-primary">
<div class="card-body">
<h5 class="card-title text-primary">
<i class="bi bi-people"></i> Total Users
</h5>
<h3 class="card-text">{{ users|length }}</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center border-success">
<div class="card-body">
<h5 class="card-title text-success">
<i class="bi bi-chat-dots"></i> Total Calls
</h5>
<h3 class="card-text">{{ "{:,}".format(users|sum(attribute='total_calls')) }}</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center border-info">
<div class="card-body">
<h5 class="card-title text-info">
<i class="bi bi-cpu"></i> Total Tokens
</h5>
<h3 class="card-text">{{ "{:,}".format(users|sum(attribute='total_tokens')) }}</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center border-warning">
<div class="card-body">
<h5 class="card-title text-warning">
<i class="bi bi-currency-dollar"></i> Total Cost
</h5>
<h3 class="card-text">${{ "%.4f"|format(users|sum(attribute='total_cost')) }}</h3>
</div>
</div>
</div>
</div>
<!-- Users Table -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-table"></i> User Consumption Details
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-dark">
<tr>
<th scope="col">
<i class="bi bi-hash"></i> Rank
</th>
<th scope="col">
<i class="bi bi-envelope"></i> User Email
</th>
<th scope="col">
<i class="bi bi-chat-dots"></i> Total Calls
</th>
<th scope="col">
<i class="bi bi-arrow-down"></i> Input Tokens
</th>
<th scope="col">
<i class="bi bi-arrow-up"></i> Output Tokens
</th>
<th scope="col">
<i class="bi bi-plus-circle"></i> Total Tokens
</th>
<th scope="col">
<i class="bi bi-currency-dollar"></i> Total Cost (USD)
</th>
<th scope="col">
<i class="bi bi-bar-chart"></i> Avg Cost/Call
</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
{% if loop.index == 1 %}
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy"></i> {{ loop.index }}
</span>
{% elif loop.index <= 3 %}
<span class="badge bg-secondary">
<i class="bi bi-award"></i> {{ loop.index }}
</span>
{% else %}
<span class="badge bg-light text-dark">{{ loop.index }}</span>
{% endif %}
</td>
<td>
<div class="d-flex align-items-center">
<i class="bi bi-person-circle me-2"></i>
<strong>{{ user.email }}</strong>
</div>
</td>
<td>
<span class="badge bg-info">{{ "{:,}".format(user.total_calls) }}</span>
</td>
<td>
<span class="badge bg-primary">{{ "{:,}".format(user.total_input_tokens) }}</span>
</td>
<td>
<span class="badge bg-success">{{ "{:,}".format(user.total_output_tokens) }}</span>
</td>
<td>
<strong>{{ "{:,}".format(user.total_tokens) }}</strong>
</td>
<td>
<span class="badge bg-warning text-dark">${{ user.total_cost }}</span>
</td>
<td>
<small class="text-muted">${{ "%.4f"|format(user.total_cost / user.total_calls) }}</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Top Users Chart (Simple Visual) -->
{% if users|length > 0 %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-bar-chart"></i> Top Users by Cost
</h5>
</div>
<div class="card-body">
{% set max_cost = users[0].total_cost %}
{% for user in users[:10] %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="fw-bold">{{ user.email }}</small>
<small class="text-muted">${{ user.total_cost }}</small>
</div>
<div class="progress" style="height: 20px;">
<div class="progress-bar bg-primary" role="progressbar"
style="width: {{ (user.total_cost / max_cost * 100) if max_cost > 0 else 0 }}%"
aria-valuenow="{{ user.total_cost }}"
aria-valuemin="0"
aria-valuemax="{{ max_cost }}">
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}
{% else %}
<!-- No Data -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-people-fill display-1 text-muted"></i>
<h3 class="mt-3">No Users Found</h3>
<p class="text-muted">No user data available yet. Users will appear here after API calls are recorded.</p>
<a href="/dashboard" class="btn btn-primary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Auto-refresh every 60 seconds
setInterval(() => {
window.location.reload();
}, 60000);
</script>
{% endblock %}

62
uv.lock generated
View File

@ -7,18 +7,36 @@ name = "acmchat-dashboard"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "loguru" },
{ name = "python-multipart" },
{ name = "ruff" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.21.0" },
{ name = "fastapi", specifier = ">=0.115.13" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "ruff", specifier = ">=0.12.0" },
{ name = "uvicorn", specifier = ">=0.34.3" },
]
[[package]]
name = "aiosqlite"
version = "0.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" },
]
[[package]]
@ -45,6 +63,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@ -80,6 +110,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/4a/e17764385382062b0edbb35a26b7cf76d71e27e456546277a42ba6545c6e/fastapi-0.115.13-py3-none-any.whl", hash = "sha256:0a0cab59afa7bab22f5eb347f8c9864b681558c278395e94035a741fc10cd865", size = 95315, upload-time = "2025-06-17T11:49:44.106Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "idna"
version = "3.10"
@ -274,6 +313,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "ruff"
version = "0.12.0"
@ -341,6 +389,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]
[[package]]
name = "uvicorn"
version = "0.34.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"