Add token tracking, audit filter, and high usage email notifications

- Add audit status filter (Audited/Not Audited) to agent management and admin dashboard
- Add token usage tracking: token_count per timeline entry, total_tokens on agents
- Token badge on agent cards, Total Tokens stat in usage modal, dual-axis chart
- Sort by Total Tokens option, total_tokens in CSV export
- Mailgun email notifications for high token usage (optional, non-blocking)
- Cooldown-based notification tracking in MongoDB token_notifications collection
- Add promote_admin.py utility script for user promotion
- Update CLAUDE.md documentation for all new features

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nickviljoen 2026-02-15 21:48:04 +02:00
parent 1d53c33d07
commit 1e926da807
8 changed files with 324 additions and 48 deletions

View file

@ -27,6 +27,13 @@ Create `.env` file with:
- `ALGORITHM`: JWT algorithm (default: HS256)
- `ACCESS_TOKEN_EXPIRE_MINUTES`: Token expiration (default: 60)
#### Optional: Email Notifications (Mailgun)
- `MAILGUN_API_KEY`: Mailgun API key (if not set, notifications are silently disabled)
- `MAILGUN_DOMAIN`: Mailgun sending domain (e.g. your-domain.mailgun.org)
- `MAILGUN_FROM_EMAIL`: Sender address (default: `AgentHub <noreply@{MAILGUN_DOMAIN}>`)
- `TOKEN_USAGE_THRESHOLD`: Token count that triggers an alert (default: 100000)
- `NOTIFICATION_COOLDOWN_HOURS`: Hours between repeat alerts for the same agent (default: 24)
### Default Login Credentials
- Admin: `admin@agenthub.com` / `admin123`
- Test User: `test@example.com` / `testpass123`
@ -51,8 +58,11 @@ Create `.env` file with:
**models.py**: Pydantic models for:
- `AiAgent`: Core agent model with comprehensive fields
- `UsageTimelineEntry`: Daily usage data including `message_count` and `token_count`
- `UserCreate/UserResponse`: User management models
- `AiAgentCreate/AiAgentResponse`: API request/response models
- `AiAgentCreate/AiAgentResponse`: API request/response models (includes `total_tokens`)
- `AgentCollectorCreate`: Collector API input model (includes `total_tokens`)
- `AgentUsageStatsResponse`: Usage statistics response (includes `total_tokens`)
**crud.py**: Database operations using Motor (async MongoDB driver):
- User CRUD: authentication, creation, management
@ -61,6 +71,13 @@ Create `.env` file with:
- All operations use ObjectId for MongoDB document IDs
**database.py**: MongoDB connection setup with Motor async client
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`
**notifications.py**: Optional Mailgun email notification system:
- `is_mailgun_configured()`: Returns False if env vars not set (gracefully disabled)
- `send_mailgun_email()`: POST to Mailgun HTTP API with 10s timeout
- `build_threshold_email()`: HTML email template for threshold alerts
- `check_and_notify_threshold()`: Checks token usage against threshold, enforces cooldown via `token_notifications` collection, sends to admin users
**auth.py**: JWT authentication with:
- bcrypt password hashing
@ -101,8 +118,25 @@ Located in `templates/` directory:
- Status tracking (Active, Inactive, Development, Deprecated)
- Rich metadata: tags, userbase, department, contact person
- Search functionality across multiple fields
- Filtering by status and audit status (Audited / Not Audited)
- Admin can view/manage all agents
### Token Usage Tracking
- `total_tokens` field on agents tracks cumulative LLM token consumption
- `token_count` per day in usage timeline entries alongside `message_count`
- Token badge displayed on agent cards (gold/coins icon)
- Usage modal shows Total Tokens stat alongside messages/conversations/users
- Dual-axis chart (messages left axis, tokens right axis) when token data exists
- Sort agents by Total Tokens
- CSV export includes `total_tokens` column
### High Usage Email Notifications
- Entirely optional — silently disabled when Mailgun env vars are not set
- Triggered from the Agent Collector endpoint (POST `/agents`) when `total_tokens` exceeds threshold
- Non-blocking — notification failure never breaks the collector API
- Cooldown tracking in MongoDB `token_notifications` collection (default 24h, configurable)
- Sends to all active admin users' email addresses
### User Management
- User registration with validation
- Admin user creation capabilities
@ -114,6 +148,7 @@ Located in `templates/` directory:
- Async operations using Motor driver
- Indexed queries for performance
- Data aggregation for statistics
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`
## Development Guidelines
@ -156,4 +191,5 @@ Key dependencies from requirements.txt:
- **bcrypt**: Password encryption
- **pydantic**: Data validation
- **jinja2**: Template engine
- **python-multipart**: Form handling
- **python-multipart**: Form handling
- **requests**: HTTP client (used for Mailgun API calls)

View file

@ -12,6 +12,7 @@ db = client[MONGODB_DBNAME]
users_collection = db.get_collection("users")
agents_collection = db.get_collection("agents")
agent_usage_collection = db.get_collection("agent_usage")
notifications_collection = db.get_collection("token_notifications")
async def check_database_health():
"""Check MongoDB connection health"""

71
main.py
View file

@ -10,6 +10,7 @@ import models
import auth
import config
import msal_auth
import notifications
from datetime import datetime
import os
import re
@ -225,13 +226,14 @@ def create_agent_response(agent: dict) -> models.AiAgentResponse:
risk_factor=agent.get("risk_factor"),
last_edited_by=agent.get("last_edited_by"),
created_by=agent["created_by"],
# Usage tracking fields (new)
# Usage tracking fields
usage_timeline=agent.get("usage_timeline"),
conversation_count=agent.get("conversation_count"),
unique_users=agent.get("unique_users"),
total_messages=agent.get("total_messages"),
first_used=agent.get("first_used"),
last_used=agent.get("last_used")
last_used=agent.get("last_used"),
total_tokens=agent.get("total_tokens")
)
async def verify_agent_collector_api_key(x_api_key: str = Header(alias="X-API-Key")):
@ -259,7 +261,7 @@ def map_agent_collector_to_internal(collector_data: models.AgentCollectorCreate)
# Convert usage_timeline from Pydantic models to dicts
usage_timeline = None
if collector_data.usage_timeline:
usage_timeline = [{"date": entry.date, "message_count": entry.message_count}
usage_timeline = [{"date": entry.date, "message_count": entry.message_count, "token_count": entry.token_count}
for entry in collector_data.usage_timeline]
return {
@ -277,13 +279,14 @@ def map_agent_collector_to_internal(collector_data: models.AgentCollectorCreate)
"agent_tags": collector_data.tags,
"agent_metadata": collector_data.metadata,
"url": collector_data.url,
# Usage tracking fields (new)
# Usage tracking fields
"usage_timeline": usage_timeline,
"conversation_count": collector_data.conversation_count,
"unique_users": collector_data.unique_users,
"total_messages": collector_data.total_messages,
"first_used": collector_data.first_used,
"last_used": collector_data.last_used,
"total_tokens": collector_data.total_tokens,
}
@ -1082,7 +1085,8 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)):
"quality_audit_updated_by_name",
"risk_factor",
"last_edited_by",
"created_by"
"created_by",
"total_tokens"
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
@ -1116,7 +1120,8 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)):
"quality_audit_updated_by_name": agent.get("quality_audit_updated_by_name", ""),
"risk_factor": str(agent.get("risk_factor", "")) if agent.get("risk_factor") is not None else "",
"last_edited_by": agent.get("last_edited_by", ""),
"created_by": agent.get("created_by", "")
"created_by": agent.get("created_by", ""),
"total_tokens": str(agent.get("total_tokens", "")) if agent.get("total_tokens") is not None else ""
}
writer.writerow(row)
@ -1324,6 +1329,7 @@ async def create_agent_collector(
"total_messages": internal_data.get("total_messages"),
"first_used": internal_data.get("first_used"),
"last_used": internal_data.get("last_used"),
"total_tokens": internal_data.get("total_tokens"),
"updated_at": datetime.utcnow()
}
@ -1340,6 +1346,13 @@ async def create_agent_collector(
)
print(f"🔗 URL DEBUG: Agent '{agent.name}' - Update executed: matched={result.matched_count}, modified={result.modified_count}")
# Check token threshold and send notification if needed (non-blocking)
if internal_data.get("total_tokens"):
try:
await notifications.check_and_notify_threshold(agent.name, internal_data["total_tokens"])
except Exception as notify_err:
print(f"Notification check failed for '{agent.name}': {notify_err}")
return models.AgentUsageTrackingResponse(
status="usage_logged",
message="Agent already exists, usage tracked",
@ -1357,7 +1370,14 @@ async def create_agent_collector(
# Create agent using collector-specific function
created_agent = await crud.create_agent_from_collector(internal_data)
# Check token threshold and send notification if needed (non-blocking)
if internal_data.get("total_tokens"):
try:
await notifications.check_and_notify_threshold(agent.name, internal_data["total_tokens"])
except Exception as notify_err:
print(f"Notification check failed for '{agent.name}': {notify_err}")
return models.AgentCollectorResponse(
status="success",
message="Agent data collected successfully",
@ -1424,7 +1444,8 @@ async def get_agent_usage(
last_usage=agent.get("last_used"),
usage_by_period=usage_by_period,
conversation_count=agent.get("conversation_count"),
unique_users=agent.get("unique_users")
unique_users=agent.get("unique_users"),
total_tokens=agent.get("total_tokens")
)
else:
# FALLBACK TO CALCULATED DATA (old system)
@ -1449,7 +1470,8 @@ async def get_agent_usage(
last_usage=stats["last_usage"].isoformat() if stats["last_usage"] else None,
usage_by_period=usage_by_period,
conversation_count=None, # Old system doesn't track this
unique_users=None # Old system doesn't track this
unique_users=None, # Old system doesn't track this
total_tokens=None # Old system doesn't track this
)
except HTTPException:
raise
@ -1479,16 +1501,31 @@ async def get_agent_usage_chart(
# USE PRE-COMPUTED TIMELINE (new system)
usage_timeline = agent.get("usage_timeline", [])
# Format for Chart.js
# Format for Chart.js with dual datasets
datasets = [{
"label": "Message Count",
"data": [entry["message_count"] for entry in usage_timeline],
"backgroundColor": "rgba(54, 162, 235, 0.2)",
"borderColor": "rgba(54, 162, 235, 1)",
"borderWidth": 1,
"yAxisID": "y"
}]
# Add token dataset if any entries have token data
token_data = [entry.get("token_count", 0) for entry in usage_timeline]
if any(t > 0 for t in token_data):
datasets.append({
"label": "Token Count",
"data": token_data,
"backgroundColor": "rgba(255, 193, 7, 0.2)",
"borderColor": "rgba(255, 193, 7, 1)",
"borderWidth": 1,
"yAxisID": "y1"
})
chart_data = {
"labels": [entry["date"] for entry in usage_timeline],
"datasets": [{
"label": "Message Count",
"data": [entry["message_count"] for entry in usage_timeline],
"backgroundColor": "rgba(54, 162, 235, 0.2)",
"borderColor": "rgba(54, 162, 235, 1)",
"borderWidth": 1
}]
"datasets": datasets
}
return chart_data

View file

@ -5,6 +5,7 @@ from typing import Optional, List
class UsageTimelineEntry(BaseModel):
date: str = Field(..., description="Date in YYYY-MM-DD format")
message_count: int = Field(..., ge=0, description="Number of messages on this date")
token_count: int = Field(0, ge=0, description="Number of tokens consumed on this date")
class AiAgent(BaseModel):
@ -131,6 +132,7 @@ class AiAgentResponse(BaseModel):
total_messages: Optional[int] = None
first_used: Optional[str] = None
last_used: Optional[str] = None
total_tokens: Optional[int] = None
# Agent Collector API Models (for compatibility with agent_collector app)
class AgentCollectorCreate(BaseModel):
@ -158,6 +160,7 @@ class AgentCollectorCreate(BaseModel):
total_messages: Optional[int] = Field(default=None, ge=0)
first_used: Optional[str] = None # ISO 8601 datetime string
last_used: Optional[str] = None # ISO 8601 datetime string
total_tokens: Optional[int] = Field(default=None, ge=0)
class AgentCollectorResponse(BaseModel):
status: str = "success"
@ -189,4 +192,4 @@ class AgentUsageStatsResponse(BaseModel):
usage_by_period: dict
conversation_count: Optional[int] = None
unique_users: Optional[int] = None
total_tokens: Optional[int] = None

107
notifications.py Normal file
View file

@ -0,0 +1,107 @@
import os
import requests
from datetime import datetime, timedelta
from database import notifications_collection, users_collection
def is_mailgun_configured() -> bool:
"""Check if Mailgun environment variables are set."""
return bool(os.getenv("MAILGUN_API_KEY")) and bool(os.getenv("MAILGUN_DOMAIN"))
def send_mailgun_email(to_emails: list[str], subject: str, html_body: str) -> bool:
"""Send email via Mailgun HTTP API. Returns True on success."""
api_key = os.getenv("MAILGUN_API_KEY")
domain = os.getenv("MAILGUN_DOMAIN")
from_email = os.getenv("MAILGUN_FROM_EMAIL", f"AgentHub <noreply@{domain}>")
response = requests.post(
f"https://api.mailgun.net/v3/{domain}/messages",
auth=("api", api_key),
data={
"from": from_email,
"to": to_emails,
"subject": subject,
"html": html_body,
},
timeout=10,
)
return response.status_code == 200
def build_threshold_email(agent_name: str, total_tokens: int, threshold: int) -> str:
"""Build HTML email body for a token threshold notification."""
return f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #f3ae3e, #e09520); padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="color: white; margin: 0;">AgentHub - High Token Usage Alert</h2>
</div>
<div style="padding: 20px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<p>The following agent has exceeded the token usage threshold:</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Agent Name</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_name}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Total Tokens</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{total_tokens:,}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Threshold</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{threshold:,}</td>
</tr>
</table>
<p style="color: #666; font-size: 0.9em;">
This is an automated notification from AgentHub. Please review the agent's token consumption.
</p>
</div>
</body>
</html>
"""
async def check_and_notify_threshold(agent_name: str, total_tokens: int):
"""Check token threshold and send notification if exceeded. Non-blocking, safe to call."""
if not is_mailgun_configured():
return
threshold = int(os.getenv("TOKEN_USAGE_THRESHOLD", "100000"))
if total_tokens < threshold:
return
cooldown_hours = int(os.getenv("NOTIFICATION_COOLDOWN_HOURS", "24"))
cooldown_cutoff = datetime.utcnow() - timedelta(hours=cooldown_hours)
# Check cooldown - skip if we already notified within the cooldown period
recent = await notifications_collection.find_one({
"agent_name": agent_name,
"sent_at": {"$gte": cooldown_cutoff},
})
if recent:
return
# Get admin user emails
admin_cursor = users_collection.find(
{"is_admin": True, "is_active": True},
{"email": 1},
)
admin_emails = [doc["email"] async for doc in admin_cursor]
if not admin_emails:
return
# Send email
subject = f"[AgentHub] High Token Usage: {agent_name} ({total_tokens:,} tokens)"
html_body = build_threshold_email(agent_name, total_tokens, threshold)
success = send_mailgun_email(admin_emails, subject, html_body)
# Record the notification attempt
await notifications_collection.insert_one({
"agent_name": agent_name,
"total_tokens": total_tokens,
"threshold": threshold,
"recipients": admin_emails,
"success": success,
"sent_at": datetime.utcnow(),
})

35
promote_admin.py Normal file
View file

@ -0,0 +1,35 @@
"""One-time script to promote a user to admin. Run on the server where .env has the correct MONGODB_URI.
Usage: python promote_admin.py nick.viljoen@brandtech.plus
"""
import asyncio
import sys
from dotenv import load_dotenv
load_dotenv()
from database import users_collection
async def promote(email: str):
user = await users_collection.find_one({"email": email})
if not user:
print(f"User '{email}' not found in database.")
return
if user.get("is_admin"):
print(f"User '{email}' is already an admin.")
return
await users_collection.update_one(
{"email": email},
{"$set": {"is_admin": True}},
)
print(f"User '{email}' promoted to admin successfully.")
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python promote_admin.py <email>")
sys.exit(1)
asyncio.run(promote(sys.argv[1]))

View file

@ -145,6 +145,11 @@
<option value="Inactive">Inactive</option>
<option value="Deprecated">Deprecated</option>
</select>
<select class="form-select" id="agentAuditFilter" style="width: auto;">
<option value="">All Audit</option>
<option value="audited">Audited</option>
<option value="not_audited">Not Audited</option>
</select>
<div class="input-group" style="width: 300px;">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" id="agentSearch" placeholder="Search agents...">
@ -663,6 +668,7 @@ function setupEventListeners() {
document.getElementById('userSearch').addEventListener('input', filterUsers);
document.getElementById('agentSearch').addEventListener('input', filterAgents);
document.getElementById('agentStatusFilter').addEventListener('change', filterAgents);
document.getElementById('agentAuditFilter').addEventListener('change', filterAgents);
document.getElementById('editUserForm').addEventListener('submit', handleEditUserSubmit);
document.getElementById('editAgentForm').addEventListener('submit', handleEditAgentSubmit);
document.getElementById('createUserForm').addEventListener('submit', handleCreateUserSubmit);
@ -841,14 +847,18 @@ function filterUsers() {
function filterAgents() {
const searchTerm = document.getElementById('agentSearch').value.toLowerCase();
const statusFilter = document.getElementById('agentStatusFilter').value;
const auditFilter = document.getElementById('agentAuditFilter').value;
let filtered = allAgents.filter(agent => {
const matchesSearch = agent.agent_name.toLowerCase().includes(searchTerm) ||
(agent.agent_description || '').toLowerCase().includes(searchTerm);
const matchesStatus = !statusFilter || agent.agent_status === statusFilter;
return matchesSearch && matchesStatus;
const matchesAudit = !auditFilter ||
(auditFilter === 'audited' && agent.quality_audit_status) ||
(auditFilter === 'not_audited' && !agent.quality_audit_status);
return matchesSearch && matchesStatus && matchesAudit;
});
displayAgents(filtered);
}

View file

@ -77,13 +77,13 @@
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-4 mb-3 mb-md-0">
<div class="col-md-3 mb-3 mb-md-0">
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" id="searchInput" placeholder="Search agents...">
</div>
</div>
<div class="col-md-3 mb-3 mb-md-0">
<div class="col-md-2 mb-3 mb-md-0">
<select class="form-select" id="statusFilter">
<option value="">All Statuses</option>
<option value="Active">Active</option>
@ -92,12 +92,20 @@
<option value="Deprecated">Deprecated</option>
</select>
</div>
<div class="col-md-2 mb-3 mb-md-0">
<select class="form-select" id="auditFilter">
<option value="">All Audit</option>
<option value="audited">Audited</option>
<option value="not_audited">Not Audited</option>
</select>
</div>
<div class="col-md-3 mb-3 mb-md-0">
<select class="form-select" id="sortBy">
<option value="created_at">Sort by Created Date</option>
<option value="name">Sort by Name</option>
<option value="status">Sort by Status</option>
<option value="total_messages">Sort by Total Messages</option>
<option value="total_tokens">Sort by Total Tokens</option>
<option value="unique_users">Sort by Unique Users</option>
</select>
</div>
@ -459,6 +467,7 @@ document.addEventListener('DOMContentLoaded', function() {
function setupEventListeners() {
document.getElementById('searchInput').addEventListener('input', filterAgents);
document.getElementById('statusFilter').addEventListener('change', filterAgents);
document.getElementById('auditFilter').addEventListener('change', filterAgents);
document.getElementById('sortBy').addEventListener('change', sortAndDisplayAgents);
document.getElementById('refreshBtn').addEventListener('click', function() {
// Reload both datasets to ensure counts are accurate
@ -616,6 +625,7 @@ function displayAgents(agentsToShow) {
${agent.quality_audit_status ? '<span class="badge bg-success" title="Quality Audited"><i class="fas fa-certificate"></i></span>' : ''}
${agent.quality_audit_status && agent.risk_factor ? getRiskFactorBadge(agent.risk_factor) : ''}
${agent.total_messages ? `<span class="badge bg-info text-white" title="Total Messages"><i class="fas fa-comments me-1"></i>${agent.total_messages.toLocaleString()}</span>` : ''}
${agent.total_tokens ? `<span class="badge bg-warning text-dark" title="Total Tokens"><i class="fas fa-coins me-1"></i>${agent.total_tokens.toLocaleString()}</span>` : ''}
${agent.unique_users ? `<span class="badge bg-primary" title="Unique Users"><i class="fas fa-users me-1"></i>${agent.unique_users}</span>` : ''}
${agent.url ? `<a href="${agent.url}" target="_blank" class="btn btn-sm btn-success" onclick="event.stopPropagation();" title="Start a conversation with this agent"><i class="fas fa-external-link-alt me-1"></i>Open Agent</a>` : ''}
</div>
@ -1053,13 +1063,17 @@ async function deleteAgent() {
function filterAgents() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const statusFilter = document.getElementById('statusFilter').value;
const auditFilter = document.getElementById('auditFilter').value;
let filtered = agents.filter(agent => {
const matchesSearch = agent.agent_name.toLowerCase().includes(searchTerm) ||
(agent.agent_description || '').toLowerCase().includes(searchTerm) ||
(agent.agent_tags || []).some(tag => tag.toLowerCase().includes(searchTerm));
const matchesStatus = !statusFilter || agent.agent_status === statusFilter;
return matchesSearch && matchesStatus;
const matchesAudit = !auditFilter ||
(auditFilter === 'audited' && agent.quality_audit_status) ||
(auditFilter === 'not_audited' && !agent.quality_audit_status);
return matchesSearch && matchesStatus && matchesAudit;
});
displayAgents(filtered);
@ -1077,6 +1091,10 @@ function sortAndDisplayAgents() {
const aVal = a.total_messages || 0;
const bVal = b.total_messages || 0;
return bVal - aVal; // Descending order
} else if (sortBy === 'total_tokens') {
const aVal = a.total_tokens || 0;
const bVal = b.total_tokens || 0;
return bVal - aVal; // Descending order
} else if (sortBy === 'unique_users') {
// High to low (most users first), treat null/undefined as 0
const aVal = a.unique_users || 0;
@ -1219,33 +1237,38 @@ async function loadUsageChart(agentName, period = 'daily', startDate = null, end
// Display statistics
const statsHtml = `
<div class="row text-center mb-3">
<div class="col-3">
<div class="col">
<div class="border rounded p-2">
<h6 class="mb-1 text-primary">${stats.total_usage_count || 0}</h6>
<small class="text-muted">Total Messages</small>
</div>
</div>
<div class="col-3">
<div class="col">
<div class="border rounded p-2">
<h6 class="mb-1 text-success">${stats.conversation_count !== undefined ? stats.conversation_count : 'N/A'}</h6>
<h6 class="mb-1 text-warning">${stats.total_tokens != null ? stats.total_tokens.toLocaleString() : 'N/A'}</h6>
<small class="text-muted">Total Tokens</small>
</div>
</div>
<div class="col">
<div class="border rounded p-2">
<h6 class="mb-1 text-success">${stats.conversation_count != null ? stats.conversation_count : 'N/A'}</h6>
<small class="text-muted">Conversations</small>
</div>
</div>
<div class="col-3">
<div class="col">
<div class="border rounded p-2">
<h6 class="mb-1 text-info">${stats.unique_users !== undefined ? stats.unique_users : 'N/A'}</h6>
<h6 class="mb-1 text-info">${stats.unique_users != null ? stats.unique_users : 'N/A'}</h6>
<small class="text-muted">Unique Users</small>
</div>
</div>
<div class="col-3">
<div class="border rounded p-2">
<h6 class="mb-1 text-warning">${stats.first_usage ? formatDate(stats.first_usage) : 'Never'}</h6>
<small class="text-muted">First Used</small>
</div>
</div>
</div>
<div class="row text-center mb-3">
<div class="col-12">
<div class="col-6">
<div class="border rounded p-2">
<small class="text-muted">First Used: ${stats.first_usage ? formatDate(stats.first_usage) : 'Never'}</small>
</div>
</div>
<div class="col-6">
<div class="border rounded p-2">
<small class="text-muted">Last Used: ${stats.last_usage ? formatDate(stats.last_usage) : 'Never'}</small>
</div>
@ -1277,12 +1300,43 @@ async function loadUsageChart(agentName, period = 'daily', startDate = null, end
function renderUsageChart(chartData) {
const ctx = document.getElementById('usageChart').getContext('2d');
// Destroy existing chart if it exists
if (usageChart) {
usageChart.destroy();
}
// Check if we have a second dataset (tokens) for dual-axis
const hasDualAxis = chartData.datasets.length > 1 && chartData.datasets.some(ds => ds.yAxisID === 'y1');
const scales = {
y: {
beginAtZero: true,
position: 'left',
title: {
display: hasDualAxis,
text: 'Messages'
},
ticks: {
stepSize: 1
}
}
};
if (hasDualAxis) {
scales.y1 = {
beginAtZero: true,
position: 'right',
title: {
display: true,
text: 'Tokens'
},
grid: {
drawOnChartArea: false
}
};
}
usageChart = new Chart(ctx, {
type: 'line',
data: chartData,
@ -1295,14 +1349,7 @@ function renderUsageChart(chartData) {
position: 'top'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
scales: scales
}
});
}