diff --git a/web/studio/src/app/admin/page.tsx b/web/studio/src/app/admin/page.tsx index 1b8c599..334f058 100644 --- a/web/studio/src/app/admin/page.tsx +++ b/web/studio/src/app/admin/page.tsx @@ -9,15 +9,24 @@ import { Zap, Server, Clock, - ShieldAlert + ShieldAlert, + Gauge, + TrendingUp } from "lucide-react"; +interface TokenStats { + total_tokens: number; + total_requests: number; + total_cost_usd: number; +} + export default function AdminDashboard() { const [stats, setStats] = useState({ orgs: 0, users: 0, courses: 0 }); + const [tokenStats, setTokenStats] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { @@ -25,9 +34,14 @@ export default function AdminDashboard() { try { // In a real app we'd have a specific stats endpoint, // but for now we'll calculate from lists - const [org, users] = await Promise.all([ + const [org, users, tokenResp] = await Promise.all([ cmsApi.getOrganization(), - cmsApi.getAllUsers() + cmsApi.getAllUsers(), + fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/token-usage`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + }) ]); setStats({ @@ -35,6 +49,16 @@ export default function AdminDashboard() { users: users.length, courses: 0 // We'd need a global courses count }); + + // Load token stats + if (tokenResp.ok) { + const tokenData = await tokenResp.json(); + setTokenStats({ + total_tokens: tokenData.stats?.total_tokens || 0, + total_requests: tokenData.stats?.total_requests || 0, + total_cost_usd: tokenData.stats?.total_cost_usd || 0, + }); + } } catch (err) { console.error("Failed to load admin stats", err); } finally { @@ -79,7 +103,7 @@ export default function AdminDashboard() { {/* Stat Grid */} -
+
{cards.map((card) => (
@@ -92,6 +116,25 @@ export default function AdminDashboard() {
))} + + {/* AI Token Usage Card */} +
+
+ +
+
+
AI Token Usage
+
+ {loading ? "..." : tokenStats ? new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(tokenStats.total_tokens) : 'N/A'} +
+

+ {tokenStats ? `${new Intl.NumberFormat('en-US').format(tokenStats.total_requests)} requests • $${tokenStats.total_cost_usd.toFixed(2)}` : 'Loading...'} +

+
+ + View Details + +
{/* System Health */} diff --git a/web/studio/src/app/admin/token-usage/page.tsx b/web/studio/src/app/admin/token-usage/page.tsx index 766bc10..2c1b41f 100644 --- a/web/studio/src/app/admin/token-usage/page.tsx +++ b/web/studio/src/app/admin/token-usage/page.tsx @@ -1,7 +1,8 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { ShieldCheck, TrendingUp, Users, AlertTriangle, DollarSign, Activity } from 'lucide-react'; +import { ShieldCheck, TrendingUp, Users, AlertTriangle, DollarSign, Activity, Edit2, Save, X, Gauge } from 'lucide-react'; +import { cmsApi } from '@/lib/api'; interface TokenUsage { user_id: string; @@ -14,6 +15,8 @@ interface TokenUsage { ai_requests: number; last_used: string; estimated_cost_usd: number; + monthly_token_limit?: number; + token_limit_reset_day?: number; } interface TokenStats { @@ -26,12 +29,24 @@ interface TokenStats { avg_tokens_per_user: number; } +interface UserLimit { + user_id: string; + monthly_limit: number; + used_tokens: number; + remaining_tokens: number; + percentage_used: number; + reset_day: number; +} + export default function AdminTokenTracking() { const [usage, setUsage] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [filterRole, setFilterRole] = useState(''); - const [sortBy, setSortBy] = useState<'total_tokens' | 'ai_requests' | 'estimated_cost_usd'>('total_tokens'); + const [sortBy, setSortBy] = useState<'total_tokens' | 'ai_requests' | 'estimated_cost_usd' | 'percentage_used'>('total_tokens'); + const [editingLimit, setEditingLimit] = useState(null); + const [editValue, setEditValue] = useState(0); + const [userLimits, setUserLimits] = useState>({}); useEffect(() => { loadTokenUsage(); @@ -49,6 +64,37 @@ export default function AdminTokenTracking() { const data = await response.json(); setUsage(data.usage || []); setStats(data.stats); + + // Load limits for each user + const limits: Record = {}; + for (const user of data.usage || []) { + try { + const limitResp = await fetch( + `${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.user_id}/token-limit/check`, + { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + } + ); + if (limitResp.ok) { + const limitData = await limitResp.json(); + limits[user.user_id] = { + user_id: user.user_id, + monthly_limit: limitData.monthly_limit, + used_tokens: limitData.used_tokens, + remaining_tokens: limitData.remaining_tokens, + percentage_used: limitData.monthly_limit > 0 + ? Math.round((limitData.used_tokens / limitData.monthly_limit) * 100) + : 0, + reset_day: 1, + }; + } + } catch (err) { + console.error(`Failed to load limit for user ${user.user_id}:`, err); + } + } + setUserLimits(limits); } } catch (error) { console.error('Failed to load token usage:', error); @@ -57,17 +103,84 @@ export default function AdminTokenTracking() { } }; + const handleUpdateLimit = async (userId: string) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${userId}/token-limit`, + { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + monthly_token_limit: editValue, + token_limit_reset_day: 1, + }), + } + ); + + if (response.ok) { + // Reload limits + const limitResp = await fetch( + `${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${userId}/token-limit/check`, + { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + } + ); + if (limitResp.ok) { + const limitData = await limitResp.json(); + setUserLimits(prev => ({ + ...prev, + [userId]: { + user_id: userId, + monthly_limit: limitData.monthly_limit, + used_tokens: limitData.used_tokens, + remaining_tokens: limitData.remaining_tokens, + percentage_used: limitData.monthly_limit > 0 + ? Math.round((limitData.used_tokens / limitData.monthly_limit) * 100) + : 0, + reset_day: 1, + }, + })); + } + setEditingLimit(null); + } + } catch (error) { + console.error('Failed to update limit:', error); + alert('Failed to update token limit'); + } + }; + + const getLimitColor = (percentage: number) => { + if (percentage >= 100) return 'text-red-600 bg-red-50 dark:bg-red-900/20'; + if (percentage >= 90) return 'text-orange-600 bg-orange-50 dark:bg-orange-900/20'; + if (percentage >= 80) return 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20'; + return 'text-green-600 bg-green-50 dark:bg-green-900/20'; + }; + + const getProgressBarColor = (percentage: number) => { + if (percentage >= 100) return 'bg-red-600'; + if (percentage >= 90) return 'bg-orange-600'; + if (percentage >= 80) return 'bg-yellow-600'; + return 'bg-green-600'; + }; + const filteredUsage = usage .filter(u => !filterRole || u.role === filterRole) - .sort((a, b) => b[sortBy] - a[sortBy]); + .sort((a, b) => { + if (sortBy === 'percentage_used') { + const aPct = userLimits[a.user_id]?.percentage_used || 0; + const bPct = userLimits[b.user_id]?.percentage_used || 0; + return bPct - aPct; + } + return b[sortBy] - a[sortBy]; + }); - const formatNumber = (num: number) => { - return new Intl.NumberFormat('en-US').format(num); - }; - - const formatCurrency = (num: number) => { - return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num); - }; + const formatNumber = (num: number) => new Intl.NumberFormat('en-US').format(num); + const formatCurrency = (num: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num); return (
@@ -82,7 +195,7 @@ export default function AdminTokenTracking() { Control Global - Token Usage

- Monitoreo de tokens de IA y costos del sistema + Monitoreo de tokens de IA, límites mensuales y costos del sistema

@@ -157,16 +270,29 @@ export default function AdminTokenTracking() { {/* Alerts */} - {usage.some(u => u.total_tokens > 1000000) && ( + {Object.values(userLimits).some(ul => ul.percentage_used >= 80) && (

- Usuarios con alto consumo detectado + Usuarios cerca del límite

- {usage.filter(u => u.total_tokens > 1000000).length} usuario(s) han superado 1M de tokens. - Considere implementar límites de uso. + {Object.values(userLimits).filter(ul => ul.percentage_used >= 80 && ul.percentage_used < 100).length} usuario(s) han usado ≥80% de su límite mensual. +

+
+
+ )} + + {Object.values(userLimits).some(ul => ul.percentage_used >= 100) && ( +
+ +
+

+ Límite excedido +

+

+ {Object.values(userLimits).filter(ul => ul.percentage_used >= 100).length} usuario(s) han excedido su límite mensual.

@@ -174,7 +300,7 @@ export default function AdminTokenTracking() { {/* Filters */}
-
+
@@ -210,10 +337,14 @@ export default function AdminTokenTracking() { {/* Usage Table */}
-
+

Uso por Usuario

+
+ + Límites mensuales configurables +
@@ -225,11 +356,11 @@ export default function AdminTokenTracking() { - - - - {filteredUsage.map((user) => ( - - + - - - - - - - - - ))} + + + + + + + + + ); + })}
Rol - Input Tokens + + Límite Mensual - Output Tokens + + % Usado Total Tokens @@ -240,59 +371,119 @@ export default function AdminTokenTracking() { Costo USD - Última Actividad -
-
-
- {user.full_name} + {filteredUsage.map((user) => { + const limit = userLimits[user.user_id]; + const percentage = limit?.percentage_used || 0; + const isUnlimited = limit?.monthly_limit === 0; + + return ( +
+
+
+ {user.full_name} +
+
+ {user.email} +
-
- {user.email} -
- -
- - {user.role} - - - {formatNumber(user.input_tokens)} - - {formatNumber(user.output_tokens)} - - 1000000 ? 'text-red-600' : - user.total_tokens > 500000 ? 'text-yellow-600' : - 'text-gray-900 dark:text-white' - }`}> - {formatNumber(user.total_tokens)} - - - {formatNumber(user.ai_requests)} - - {formatCurrency(user.estimated_cost_usd)} - - {new Date(user.last_used).toLocaleDateString()} -
+ + {user.role} + + + {editingLimit === user.user_id ? ( +
+ setEditValue(parseInt(e.target.value) || 0)} + className="w-24 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm dark:bg-gray-700 dark:text-white" + placeholder="0 = unlimited" + autoFocus + /> + + +
+ ) : ( +
+ = 100 ? 'text-red-600' : + percentage >= 80 ? 'text-yellow-600' : + 'text-gray-900 dark:text-white' + }`}> + {isUnlimited ? '∞' : formatNumber(limit?.monthly_limit || 0)} + + {!isUnlimited && limit && ( + + )} +
+ )} +
+ {isUnlimited ? ( + Unlimited + ) : ( +
+ + {percentage}% + +
+
+
+
+ )} +
+ 1000000 ? 'text-red-600' : + user.total_tokens > 500000 ? 'text-yellow-600' : + 'text-gray-900 dark:text-white' + }`}> + {formatNumber(user.total_tokens)} + + + {formatNumber(user.ai_requests)} + + {formatCurrency(user.estimated_cost_usd)} +
diff --git a/web/studio/src/app/admin/users/page.tsx b/web/studio/src/app/admin/users/page.tsx index 92d728c..c5b3ab2 100644 --- a/web/studio/src/app/admin/users/page.tsx +++ b/web/studio/src/app/admin/users/page.tsx @@ -3,15 +3,21 @@ import { useState, useEffect } from 'react'; import { cmsApi, User, Organization } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; -import { UserCog, Mail, Search, Filter, ShieldCheck, Plus, X, UserPlus, Key, User as UserIcon, Building2 } from 'lucide-react'; +import { UserCog, Mail, Search, Filter, ShieldCheck, Plus, X, UserPlus, Key, User as UserIcon, Building2, Gauge } from 'lucide-react'; + +interface UserWithLimit extends User { + monthly_token_limit?: number; + token_usage_percentage?: number; +} export default function UsersPage() { - const [users, setUsers] = useState([]); + const [users, setUsers] = useState([]); const [organizations, setOrganizations] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [roleFilter, setRoleFilter] = useState(''); const { user: currentUser } = useAuth(); + const [tokenLimits, setTokenLimits] = useState>({}); // Create User States const [isModalOpen, setIsModalOpen] = useState(false); @@ -34,7 +40,34 @@ export default function UsersPage() { cmsApi.getOrganization() ]); setUsers(usersData); - setOrganizations([orgData]); // Single tenant - wrap in array for compatibility + setOrganizations([orgData]); + + // Load token limits for each user + const limits: Record = {}; + for (const user of usersData) { + try { + const resp = await fetch( + `${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.id}/token-limit/check`, + { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + } + ); + if (resp.ok) { + const data = await resp.json(); + limits[user.id] = { + limit: data.monthly_limit, + percentage: data.monthly_limit > 0 + ? Math.round((data.used_tokens / data.monthly_limit) * 100) + : 0, + }; + } + } catch (err) { + console.error(`Failed to load limit for user ${user.id}:`, err); + } + } + setTokenLimits(limits); } catch (error) { console.error('Failed to load data', error); } finally { @@ -71,6 +104,8 @@ export default function UsersPage() { return matchesSearch && matchesRole; }); + const formatNumber = (num: number) => new Intl.NumberFormat('en-US').format(num); + if (currentUser?.role !== 'admin') { return (
@@ -135,6 +170,7 @@ export default function UsersPage() { User Role Organization + Token Limit Actions @@ -188,6 +224,38 @@ export default function UsersPage() {
+ + {tokenLimits[u.id] ? ( +
+ = 100 ? 'text-red-600 bg-red-50 dark:bg-red-900/20' : + tokenLimits[u.id].percentage >= 80 ? 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20' : + tokenLimits[u.id].percentage >= 50 ? 'text-blue-600 bg-blue-50 dark:bg-blue-900/20' : + 'text-green-600 bg-green-50 dark:bg-green-900/20' + }`}> + {tokenLimits[u.id].limit === 0 ? '∞' : `${tokenLimits[u.id].percentage}%`} + + {tokenLimits[u.id].limit > 0 && ( +
+
= 100 ? 'bg-red-600' : + tokenLimits[u.id].percentage >= 80 ? 'bg-yellow-600' : + tokenLimits[u.id].percentage >= 50 ? 'bg-blue-600' : + 'bg-green-600' + }`} + style={{ width: `${Math.min(tokenLimits[u.id].percentage, 100)}%` }} + /> +
+ )} + + {tokenLimits[u.id].limit === 0 ? 'Unlimited' : formatNumber(tokenLimits[u.id].limit)} + +
+ ) : ( + - + )} +