feat: Add user/company rankings and period filters to AI dashboard

- Add period filter buttons (day/week/month/all)
- Add user ranking table with name, company, requests, tokens, cost
- Add company ranking table with unique users and costs
- Show user names in recent logs
- Add all-time statistics
- Rankings filtered by selected period

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-11 08:35:00 +01:00
parent 3bbe2a36dd
commit 3e77ffd206
2 changed files with 398 additions and 36 deletions

135
app.py
View File

@ -5727,10 +5727,13 @@ def admin_ai_usage():
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
from database import AIUsageLog, AIUsageDaily
from sqlalchemy import func, desc
from database import AIUsageLog, AIUsageDaily, User, Company
from sqlalchemy import func, desc, case
from datetime import timedelta
# Get period filter from query params
period = request.args.get('period', 'month') # day, week, month, all
db = SessionLocal()
try:
now = datetime.now()
@ -5739,7 +5742,22 @@ def admin_ai_usage():
month_ago = today - timedelta(days=30)
day_ago = now - timedelta(hours=24)
# Today's stats
# Determine date filter based on period
period_labels = {
'day': ('Dzisiaj', today),
'week': ('Ten tydzień', week_ago),
'month': ('Ten miesiąc', month_ago),
'all': ('Od początku', None)
}
period_label, period_start = period_labels.get(period, period_labels['month'])
# Base query filter for period
def period_filter(query):
if period_start:
return query.filter(func.date(AIUsageLog.created_at) >= period_start)
return query
# Today's stats (always show)
today_stats = db.query(
func.count(AIUsageLog.id).label('requests'),
func.coalesce(func.sum(AIUsageLog.tokens_input), 0).label('tokens_input'),
@ -5762,6 +5780,12 @@ def admin_ai_usage():
func.date(AIUsageLog.created_at) >= month_ago
).first()
# All-time stats
all_time_stats = db.query(
func.count(AIUsageLog.id).label('requests'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).first()
# Error rate (last 24h)
last_24h_total = db.query(func.count(AIUsageLog.id)).filter(
AIUsageLog.created_at >= day_ago
@ -5780,13 +5804,13 @@ def admin_ai_usage():
AIUsageLog.success == True
).scalar() or 0
# Usage by type (last 30 days)
type_stats = db.query(
# Usage by type (filtered by period)
type_query = db.query(
AIUsageLog.request_type,
func.count(AIUsageLog.id).label('count')
).filter(
func.date(AIUsageLog.created_at) >= month_ago
).group_by(AIUsageLog.request_type).order_by(desc('count')).all()
)
type_query = period_filter(type_query)
type_stats = type_query.group_by(AIUsageLog.request_type).order_by(desc('count')).all()
# Calculate percentages for type breakdown
total_type_count = sum(t.count for t in type_stats) if type_stats else 0
@ -5809,11 +5833,94 @@ def admin_ai_usage():
'percentage': round(percentage, 1)
})
# Recent logs
recent_logs = db.query(AIUsageLog).order_by(desc(AIUsageLog.created_at)).limit(20).all()
# ========================================
# USER STATISTICS (filtered by period)
# ========================================
user_query = db.query(
User.id,
User.first_name,
User.last_name,
User.email,
Company.name.label('company_name'),
func.count(AIUsageLog.id).label('requests'),
func.coalesce(func.sum(AIUsageLog.tokens_input), 0).label('tokens_input'),
func.coalesce(func.sum(AIUsageLog.tokens_output), 0).label('tokens_output'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).join(
AIUsageLog, AIUsageLog.user_id == User.id
).outerjoin(
Company, User.company_id == Company.id
)
user_query = period_filter(user_query)
user_stats = user_query.group_by(
User.id, User.first_name, User.last_name, User.email, Company.name
).order_by(desc('cost_cents')).limit(20).all()
# Format user stats
user_rankings = []
for u in user_stats:
user_rankings.append({
'id': u.id,
'name': f"{u.first_name or ''} {u.last_name or ''}".strip() or u.email,
'email': u.email,
'company': u.company_name or '-',
'requests': u.requests,
'tokens': int(u.tokens_input) + int(u.tokens_output),
'cost_cents': float(u.cost_cents or 0),
'cost_usd': float(u.cost_cents or 0) / 100
})
# ========================================
# COMPANY STATISTICS (filtered by period)
# ========================================
company_query = db.query(
Company.id,
Company.name,
func.count(AIUsageLog.id).label('requests'),
func.count(func.distinct(AIUsageLog.user_id)).label('unique_users'),
func.coalesce(func.sum(AIUsageLog.tokens_input), 0).label('tokens_input'),
func.coalesce(func.sum(AIUsageLog.tokens_output), 0).label('tokens_output'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).join(
User, User.company_id == Company.id
).join(
AIUsageLog, AIUsageLog.user_id == User.id
)
company_query = period_filter(company_query)
company_stats = company_query.group_by(
Company.id, Company.name
).order_by(desc('cost_cents')).limit(20).all()
# Format company stats
company_rankings = []
for c in company_stats:
company_rankings.append({
'id': c.id,
'name': c.name,
'requests': c.requests,
'unique_users': c.unique_users,
'tokens': int(c.tokens_input) + int(c.tokens_output),
'cost_cents': float(c.cost_cents or 0),
'cost_usd': float(c.cost_cents or 0) / 100
})
# Recent logs with user info
recent_logs = db.query(AIUsageLog).options(
db.query(AIUsageLog).with_entities(AIUsageLog.user_id)
).order_by(desc(AIUsageLog.created_at)).limit(20).all()
# Enrich recent logs with user names
for log in recent_logs:
label, _ = type_labels.get(log.request_type, (log.request_type, 'other'))
log.type_label = label
if log.user_id:
user = db.query(User).filter_by(id=log.user_id).first()
if user:
log.user_name = f"{user.first_name or ''} {user.last_name or ''}".strip() or user.email
else:
log.user_name = None
else:
log.user_name = None
# Daily history (last 14 days)
daily_history = db.query(AIUsageDaily).filter(
@ -5828,6 +5935,8 @@ def admin_ai_usage():
'week_requests': week_requests,
'month_requests': month_stats.requests or 0,
'month_cost': float(month_stats.cost_cents or 0) / 100,
'all_requests': all_time_stats.requests or 0,
'all_cost': float(all_time_stats.cost_cents or 0) / 100,
'error_rate': error_rate,
'avg_response_time': int(avg_response_time)
}
@ -5837,7 +5946,11 @@ def admin_ai_usage():
stats=stats,
usage_by_type=usage_by_type,
recent_logs=recent_logs,
daily_history=daily_history
daily_history=daily_history,
user_rankings=user_rankings,
company_rankings=company_rankings,
current_period=period,
period_label=period_label
)
finally:
db.close()

View File

@ -15,9 +15,39 @@
font-size: var(--font-size-2xl);
}
/* Period filter buttons */
.period-filters {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
}
.period-btn {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition);
}
.period-btn:hover {
background: var(--background);
color: var(--text-primary);
}
.period-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
@ -46,8 +76,12 @@
border-left: 4px solid var(--error);
}
.stat-card.info {
border-left: 4px solid #6366f1;
}
.stat-value {
font-size: var(--font-size-3xl);
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-primary);
}
@ -56,6 +90,7 @@
.stat-value.success { color: var(--success); }
.stat-value.warning { color: var(--warning); }
.stat-value.error { color: var(--error); }
.stat-value.info { color: #6366f1; }
.stat-label {
color: var(--text-secondary);
@ -92,6 +127,15 @@
font-size: var(--font-size-2xl);
}
.section h2 .badge {
font-size: var(--font-size-xs);
background: var(--primary);
color: white;
padding: 2px 8px;
border-radius: var(--radius);
margin-left: auto;
}
/* Usage breakdown bars */
.usage-breakdown {
display: flex;
@ -106,7 +150,7 @@
}
.usage-label {
width: 150px;
width: 120px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
@ -140,36 +184,62 @@
.usage-bar.other { background: #6b7280; }
.usage-count {
width: 80px;
width: 60px;
text-align: right;
font-weight: 600;
font-size: var(--font-size-sm);
}
/* Daily history table */
.daily-table {
/* Ranking tables */
.ranking-table {
width: 100%;
border-collapse: collapse;
}
.daily-table th,
.daily-table td {
.ranking-table th,
.ranking-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.daily-table th {
.ranking-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.daily-table tr:hover {
.ranking-table tr:hover {
background: var(--background);
}
.ranking-position {
width: 40px;
text-align: center;
font-weight: 700;
color: var(--text-muted);
}
.ranking-position.top-1 { color: #f59e0b; }
.ranking-position.top-2 { color: #94a3b8; }
.ranking-position.top-3 { color: #b45309; }
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-weight: 600;
color: var(--text-primary);
}
.user-company {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.cost-badge {
display: inline-block;
padding: 2px 8px;
@ -190,9 +260,26 @@
color: #991b1b;
}
/* Cost bar visualization */
.cost-bar-container {
width: 100px;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.cost-bar {
height: 100%;
background: linear-gradient(90deg, #10b981, #f59e0b, #ef4444);
border-radius: 4px;
}
/* Recent logs */
.log-list {
list-style: none;
max-height: 400px;
overflow-y: auto;
}
.log-item {
@ -201,12 +288,19 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md);
}
.log-item:last-child {
border-bottom: none;
}
.log-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.log-type {
display: inline-block;
padding: 2px 8px;
@ -221,9 +315,16 @@
.log-type.image_analysis { background: #ede9fe; color: #5b21b6; }
.log-type.other { background: #f3f4f6; color: #374151; }
.log-user {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.log-meta {
display: flex;
gap: var(--spacing-lg);
flex-direction: column;
align-items: flex-end;
gap: 2px;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
@ -234,15 +335,15 @@
.log-time {
color: var(--text-muted);
font-size: var(--font-size-xs);
}
.log-error {
color: var(--error);
font-size: var(--font-size-xs);
margin-top: 4px;
}
/* No data state */
/* Empty state */
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
@ -261,12 +362,39 @@
gap: var(--spacing-xl);
}
/* Daily history table */
.daily-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
.daily-table th,
.daily-table td {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.daily-table th {
background: var(--background);
font-weight: 600;
color: var(--text-secondary);
}
.daily-table tr:hover {
background: var(--background);
}
@media (max-width: 768px) {
.sections-grid {
grid-template-columns: 1fr;
}
.usage-label {
width: 100px;
width: 80px;
}
.ranking-table {
font-size: var(--font-size-sm);
}
}
</style>
@ -281,21 +409,35 @@
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Powrót</a>
</div>
<!-- Period Filters -->
<div class="period-filters">
<span class="text-muted" style="padding: var(--spacing-sm) 0;">Okres:</span>
<a href="{{ url_for('admin_ai_usage', period='day') }}" class="period-btn {% if current_period == 'day' %}active{% endif %}">Dzisiaj</a>
<a href="{{ url_for('admin_ai_usage', period='week') }}" class="period-btn {% if current_period == 'week' %}active{% endif %}">Tydzień</a>
<a href="{{ url_for('admin_ai_usage', period='month') }}" class="period-btn {% if current_period == 'month' %}active{% endif %}">Miesiąc</a>
<a href="{{ url_for('admin_ai_usage', period='all') }}" class="period-btn {% if current_period == 'all' %}active{% endif %}">Od początku</a>
</div>
<!-- Main Stats -->
<div class="stats-grid">
<div class="stat-card highlight">
<div class="stat-value primary">{{ stats.today_requests }}</div>
<div class="stat-label">Dzisiaj</div>
<div class="stat-sublabel">zapytań do AI</div>
<div class="stat-sublabel">zapytań</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.week_requests }}</div>
<div class="stat-label">Ten tydzień</div>
<div class="stat-label">Tydzień</div>
<div class="stat-sublabel">zapytań</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.month_requests }}</div>
<div class="stat-label">Ten miesiąc</div>
<div class="stat-label">Miesiąc</div>
<div class="stat-sublabel">zapytań</div>
</div>
<div class="stat-card info">
<div class="stat-value info">{{ stats.all_requests }}</div>
<div class="stat-label">Od początku</div>
<div class="stat-sublabel">zapytań</div>
</div>
<div class="stat-card success">
@ -308,10 +450,15 @@
<div class="stat-label">Koszt miesiąc</div>
<div class="stat-sublabel">USD</div>
</div>
<div class="stat-card info">
<div class="stat-value info">${{ "%.4f"|format(stats.all_cost) }}</div>
<div class="stat-label">Koszt całkowity</div>
<div class="stat-sublabel">USD</div>
</div>
<div class="stat-card {% if stats.error_rate > 10 %}error{% elif stats.error_rate > 5 %}warning{% else %}success{% endif %}">
<div class="stat-value {% if stats.error_rate > 10 %}error{% elif stats.error_rate > 5 %}warning{% else %}success{% endif %}">{{ "%.1f"|format(stats.error_rate) }}%</div>
<div class="stat-label">Błędy</div>
<div class="stat-sublabel">ostatnie 24h</div>
<div class="stat-sublabel">24h</div>
</div>
</div>
@ -329,15 +476,114 @@
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.avg_response_time }}ms</div>
<div class="stat-label">Avg czas odpowiedzi</div>
<div class="stat-sublabel">ostatnie 24h</div>
<div class="stat-label">Avg czas</div>
<div class="stat-sublabel">24h</div>
</div>
</div>
<!-- User and Company Rankings -->
<div class="sections-grid">
<!-- User Rankings -->
<div class="section">
<h2>
<span class="icon">👤</span> Ranking użytkowników
<span class="badge">{{ period_label }}</span>
</h2>
{% if user_rankings %}
<table class="ranking-table">
<thead>
<tr>
<th>#</th>
<th>Użytkownik</th>
<th>Zapytania</th>
<th>Tokeny</th>
<th>Koszt</th>
</tr>
</thead>
<tbody>
{% for user in user_rankings %}
<tr>
<td class="ranking-position {% if loop.index == 1 %}top-1{% elif loop.index == 2 %}top-2{% elif loop.index == 3 %}top-3{% endif %}">
{{ loop.index }}
</td>
<td>
<div class="user-info">
<span class="user-name">{{ user.name }}</span>
<span class="user-company">{{ user.company }}</span>
</div>
</td>
<td>{{ user.requests }}</td>
<td>{{ "{:,}".format(user.tokens) }}</td>
<td>
<span class="cost-badge {% if user.cost_cents > 100 %}high{% elif user.cost_cents > 10 %}medium{% endif %}">
${{ "%.4f"|format(user.cost_usd) }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="icon">👤</div>
<p>Brak danych o użytkownikach</p>
</div>
{% endif %}
</div>
<!-- Company Rankings -->
<div class="section">
<h2>
<span class="icon">🏢</span> Ranking firm
<span class="badge">{{ period_label }}</span>
</h2>
{% if company_rankings %}
<table class="ranking-table">
<thead>
<tr>
<th>#</th>
<th>Firma</th>
<th>Użytkownicy</th>
<th>Zapytania</th>
<th>Koszt</th>
</tr>
</thead>
<tbody>
{% for company in company_rankings %}
<tr>
<td class="ranking-position {% if loop.index == 1 %}top-1{% elif loop.index == 2 %}top-2{% elif loop.index == 3 %}top-3{% endif %}">
{{ loop.index }}
</td>
<td>
<span class="user-name">{{ company.name }}</span>
</td>
<td>{{ company.unique_users }}</td>
<td>{{ company.requests }}</td>
<td>
<span class="cost-badge {% if company.cost_cents > 100 %}high{% elif company.cost_cents > 10 %}medium{% endif %}">
${{ "%.4f"|format(company.cost_usd) }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="icon">🏢</div>
<p>Brak danych o firmach</p>
</div>
{% endif %}
</div>
</div>
<div class="sections-grid">
<!-- Usage by Type -->
<div class="section">
<h2><span class="icon">📊</span> Wykorzystanie wg typu</h2>
<h2>
<span class="icon">📊</span> Wykorzystanie wg typu
<span class="badge">{{ period_label }}</span>
</h2>
{% if usage_by_type %}
<div class="usage-breakdown">
{% for item in usage_by_type %}
@ -367,15 +613,18 @@
<ul class="log-list">
{% for log in recent_logs %}
<li class="log-item">
<div>
<div class="log-info">
<span class="log-type {{ log.request_type }}">{{ log.type_label }}</span>
{% if log.user_name %}
<span class="log-user">{{ log.user_name }}</span>
{% endif %}
{% if not log.success %}
<div class="log-error">{{ log.error_message[:50] }}...</div>
<span class="log-error">{{ log.error_message[:30] }}...</span>
{% endif %}
</div>
<div class="log-meta">
<span class="log-tokens">{{ log.tokens_input }}+{{ log.tokens_output }}</span>
<span class="log-time">{{ log.created_at.strftime('%H:%M:%S') }}</span>
<span class="log-tokens">{{ log.tokens_input or 0 }}+{{ log.tokens_output or 0 }}</span>
<span class="log-time">{{ log.created_at.strftime('%d.%m %H:%M') }}</span>
</div>
</li>
{% endfor %}