Add detailed user analytics page with comprehensive statistics
Features:
- New UserDetailsView component with expandable conversations
- Each conversation shows all messages with token usage and cost
- User information section (email, role, status, last login)
- Token usage statistics grid (8 stat cards)
- Message content truncation for long messages (500 chars)
- Role-based styling (user: blue, assistant: gold)
Backend:
- New GET /admin/users/{user_id}/details endpoint
- Complex SQL queries with joins for user stats and conversations
- Pydantic schemas: UserDetails, ConversationDetail, MessageDetail
- Per-message and per-conversation token tracking
Frontend:
- React Router integration for /admin/users/:userId route
- Navigation from Usage page "View" button to user details
- Back button to return to admin panel
- Proper error handling and loading states
- Responsive CSS styling with hover effects
Changes:
- backend/app/api/v1/endpoints/admin.py: Added getUserDetails endpoint
- frontend/src/components/UserDetailsView.tsx: New component
- frontend/src/App.tsx: Added route for user details page
- frontend/src/components/TokenUsageDashboard.tsx: Added navigation handler
- frontend/src/services/api.ts: Added adminAPI.getUserDetails method
- frontend/src/styles/admin.css: Added comprehensive styling for user details
- frontend/package.json: Added react-router-dom dependency
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2e6597ee08
commit
e43feb6163
8 changed files with 841 additions and 36 deletions
|
|
@ -71,6 +71,48 @@ class UserAnalytics(BaseModel):
|
|||
last_activity: datetime | None
|
||||
|
||||
|
||||
class MessageDetail(BaseModel):
|
||||
"""Message detail for user details view"""
|
||||
id: str
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
token_count: int
|
||||
cost_usd: float
|
||||
|
||||
|
||||
class ConversationDetail(BaseModel):
|
||||
"""Conversation detail for user details view"""
|
||||
id: str
|
||||
title: str
|
||||
created_at: datetime
|
||||
last_message_at: datetime | None
|
||||
message_count: int
|
||||
total_tokens: int
|
||||
total_cost_usd: float
|
||||
messages: List[MessageDetail]
|
||||
|
||||
|
||||
class UserDetails(BaseModel):
|
||||
"""Detailed user information with conversations and messages"""
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None
|
||||
total_conversations: int
|
||||
total_messages: int
|
||||
total_tokens: int
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
cached_tokens: int
|
||||
total_cost_usd: float
|
||||
avg_tokens_per_message: float
|
||||
conversations: List[ConversationDetail]
|
||||
|
||||
|
||||
# Endpoints
|
||||
|
||||
@router.get("/users", response_model=List[UserListItem])
|
||||
|
|
@ -445,3 +487,148 @@ async def get_all_conversations(
|
|||
}
|
||||
for conv, user in conversations
|
||||
]
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/details", response_model=UserDetails)
|
||||
async def get_user_details(
|
||||
user_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
limit_conversations: int = 20
|
||||
):
|
||||
"""
|
||||
Get detailed information for a specific user (admin only)
|
||||
|
||||
Args:
|
||||
user_id: User ID to get details for
|
||||
current_user: Current authenticated user
|
||||
db: Database session
|
||||
limit_conversations: Max number of conversations to include
|
||||
|
||||
Returns:
|
||||
Detailed user information with conversations and messages
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is not admin or user not found
|
||||
"""
|
||||
require_permission(current_user.role, Permission.READ_ALL_CHATS)
|
||||
|
||||
# Get user
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Get user's token usage stats
|
||||
token_stats = await db.execute(
|
||||
select(
|
||||
func.count(TokenUsage.id).label("total_messages"),
|
||||
func.sum(TokenUsage.total_tokens).label("total_tokens"),
|
||||
func.sum(TokenUsage.prompt_tokens).label("prompt_tokens"),
|
||||
func.sum(TokenUsage.completion_tokens).label("completion_tokens"),
|
||||
func.sum(TokenUsage.cached_tokens).label("cached_tokens"),
|
||||
func.sum(TokenUsage.cost_usd).label("total_cost_usd")
|
||||
).where(TokenUsage.user_id == user_id)
|
||||
)
|
||||
stats_row = token_stats.first()
|
||||
|
||||
total_messages = int(stats_row.total_messages or 0)
|
||||
total_tokens = int(stats_row.total_tokens or 0)
|
||||
prompt_tokens = int(stats_row.prompt_tokens or 0)
|
||||
completion_tokens = int(stats_row.completion_tokens or 0)
|
||||
cached_tokens = int(stats_row.cached_tokens or 0)
|
||||
total_cost_usd = float(stats_row.total_cost_usd or 0.0)
|
||||
avg_tokens_per_message = total_tokens / total_messages if total_messages > 0 else 0
|
||||
|
||||
# Get user's conversations with message counts and token stats
|
||||
conv_query = (
|
||||
select(
|
||||
Conversation,
|
||||
func.count(Message.id).label("message_count"),
|
||||
func.sum(TokenUsage.total_tokens).label("total_tokens"),
|
||||
func.sum(TokenUsage.cost_usd).label("total_cost")
|
||||
)
|
||||
.outerjoin(Message, Conversation.id == Message.conversation_id)
|
||||
.outerjoin(TokenUsage, Conversation.id == TokenUsage.conversation_id)
|
||||
.where(Conversation.user_id == user_id)
|
||||
.group_by(Conversation.id)
|
||||
.order_by(Conversation.last_message_at.desc().nullslast())
|
||||
.limit(limit_conversations)
|
||||
)
|
||||
|
||||
conv_result = await db.execute(conv_query)
|
||||
conversations_data = conv_result.all()
|
||||
|
||||
# Build conversation details with messages
|
||||
conversations = []
|
||||
for conv, message_count, conv_tokens, conv_cost in conversations_data:
|
||||
# Get messages for this conversation
|
||||
msg_query = (
|
||||
select(Message, TokenUsage)
|
||||
.outerjoin(TokenUsage,
|
||||
(TokenUsage.conversation_id == Message.conversation_id) &
|
||||
(TokenUsage.message_id == Message.id))
|
||||
.where(Message.conversation_id == conv.id)
|
||||
.order_by(Message.created_at.asc())
|
||||
)
|
||||
|
||||
msg_result = await db.execute(msg_query)
|
||||
messages_data = msg_result.all()
|
||||
|
||||
messages = [
|
||||
MessageDetail(
|
||||
id=str(msg.id),
|
||||
role=msg.role,
|
||||
content=msg.content,
|
||||
created_at=msg.created_at,
|
||||
token_count=int(token.total_tokens) if token else 0,
|
||||
cost_usd=float(token.cost_usd) if token else 0.0
|
||||
)
|
||||
for msg, token in messages_data
|
||||
]
|
||||
|
||||
conversations.append(
|
||||
ConversationDetail(
|
||||
id=str(conv.id),
|
||||
title=conv.title,
|
||||
created_at=conv.created_at,
|
||||
last_message_at=conv.last_message_at,
|
||||
message_count=int(message_count or 0),
|
||||
total_tokens=int(conv_tokens or 0),
|
||||
total_cost_usd=float(conv_cost or 0.0),
|
||||
messages=messages
|
||||
)
|
||||
)
|
||||
|
||||
# Get total conversation count
|
||||
total_conv_count = await db.scalar(
|
||||
select(func.count(Conversation.id))
|
||||
.where(Conversation.user_id == user_id)
|
||||
)
|
||||
|
||||
logger.info(f"Admin {current_user.id} viewed details for user {user_id}")
|
||||
|
||||
return UserDetails(
|
||||
user_id=str(user.id),
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
last_login_at=user.last_login_at,
|
||||
total_conversations=total_conv_count or 0,
|
||||
total_messages=total_messages,
|
||||
total_tokens=total_tokens,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
cached_tokens=cached_tokens,
|
||||
total_cost_usd=total_cost_usd,
|
||||
avg_tokens_per_message=round(avg_tokens_per_message, 1),
|
||||
conversations=conversations
|
||||
)
|
||||
|
|
|
|||
20
frontend/package-lock.json
generated
20
frontend/package-lock.json
generated
|
|
@ -15,7 +15,7 @@
|
|||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"recharts": "^2.10.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -4154,6 +4154,7 @@
|
|||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/q": {
|
||||
|
|
@ -4181,6 +4182,7 @@
|
|||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
|
|
@ -18899,22 +18901,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"extraneous": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -6,19 +6,19 @@
|
|||
"dependencies": {
|
||||
"@azure/msal-browser": "^3.7.1",
|
||||
"@azure/msal-react": "^2.0.11",
|
||||
"axios": "^1.6.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"axios": "^1.6.5",
|
||||
"react-markdown": "^9.0.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"recharts": "^2.10.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"eslint": "^8.56.0",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { ChatProvider, useChat } from './context/ChatContext';
|
||||
import SimpleLogin from './components/SimpleLogin';
|
||||
|
|
@ -12,16 +13,26 @@ import ChatInterface from './components/ChatInterface';
|
|||
import ChatList from './components/ChatList';
|
||||
import TokenUsageDashboard from './components/TokenUsageDashboard';
|
||||
import AdminPanel from './components/AdminPanel';
|
||||
import UserDetailsView from './components/UserDetailsView';
|
||||
|
||||
const AppContent: React.FC = () => {
|
||||
const { isAuthenticated, isLoading, user, logout, login } = useAuth();
|
||||
const { loadConversations, clearState } = useChat();
|
||||
const [activeView, setActiveView] = useState<'chat' | 'usage' | 'admin'>('chat');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [prevUserId, setPrevUserId] = useState<string | null>(null);
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin';
|
||||
|
||||
// Determine active view from current route
|
||||
const getActiveView = () => {
|
||||
if (location.pathname.startsWith('/usage')) return 'usage';
|
||||
if (location.pathname.startsWith('/admin')) return 'admin';
|
||||
return 'chat';
|
||||
};
|
||||
const activeView = getActiveView();
|
||||
|
||||
// Clear chat state when user logs out
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
|
|
@ -81,7 +92,7 @@ const AppContent: React.FC = () => {
|
|||
<div className="app-header-right">
|
||||
<button
|
||||
className={`btn-nav ${activeView === 'chat' ? 'active' : ''}`}
|
||||
onClick={() => setActiveView('chat')}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
💬 Chat
|
||||
</button>
|
||||
|
|
@ -89,13 +100,13 @@ const AppContent: React.FC = () => {
|
|||
<>
|
||||
<button
|
||||
className={`btn-nav ${activeView === 'usage' ? 'active' : ''}`}
|
||||
onClick={() => setActiveView('usage')}
|
||||
onClick={() => navigate('/usage')}
|
||||
>
|
||||
📊 Usage
|
||||
</button>
|
||||
<button
|
||||
className={`btn-nav ${activeView === 'admin' ? 'active' : ''}`}
|
||||
onClick={() => setActiveView('admin')}
|
||||
onClick={() => navigate('/admin')}
|
||||
>
|
||||
👨💼 Admin
|
||||
</button>
|
||||
|
|
@ -124,9 +135,12 @@ const AppContent: React.FC = () => {
|
|||
)}
|
||||
|
||||
<main className="app-content">
|
||||
{activeView === 'chat' && <ChatInterface />}
|
||||
{activeView === 'usage' && <TokenUsageDashboard />}
|
||||
{activeView === 'admin' && isAdmin && <AdminPanel />}
|
||||
<Routes>
|
||||
<Route path="/" element={<ChatInterface />} />
|
||||
<Route path="/usage" element={<TokenUsageDashboard />} />
|
||||
<Route path="/admin" element={<AdminPanel />} />
|
||||
<Route path="/admin/users/:userId" element={<UserDetailsView />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -135,11 +149,13 @@ const AppContent: React.FC = () => {
|
|||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ChatProvider>
|
||||
<AppContent />
|
||||
</ChatProvider>
|
||||
</AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ChatProvider>
|
||||
<AppContent />
|
||||
</ChatProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { tokenAPI } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ interface UserTokenStats {
|
|||
|
||||
const TokenUsageDashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [usage, setUsage] = useState<UsageSummary | null>(null);
|
||||
const [dailyUserUsage, setDailyUserUsage] = useState<DailyUserUsage[]>([]);
|
||||
const [usersStats, setUsersStats] = useState<UserTokenStats[]>([]);
|
||||
|
|
@ -95,9 +97,7 @@ const TokenUsageDashboard: React.FC = () => {
|
|||
}, [loadUsage]);
|
||||
|
||||
const handleViewUserDetails = (userId: string) => {
|
||||
// TODO: Navigate to user details page (to be implemented)
|
||||
console.log('View details for user:', userId);
|
||||
alert('User details view coming soon!');
|
||||
navigate(`/admin/users/${userId}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
|
|
|||
286
frontend/src/components/UserDetailsView.tsx
Normal file
286
frontend/src/components/UserDetailsView.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* User Details View Component
|
||||
*
|
||||
* Displays comprehensive analytics for a specific user (admin only)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { adminAPI } from '../services/api';
|
||||
|
||||
interface MessageDetail {
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
token_count: number;
|
||||
cost_usd: number;
|
||||
}
|
||||
|
||||
interface ConversationDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
last_message_at: string | null;
|
||||
message_count: number;
|
||||
total_tokens: number;
|
||||
total_cost_usd: number;
|
||||
messages: MessageDetail[];
|
||||
}
|
||||
|
||||
interface UserDetails {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
total_conversations: number;
|
||||
total_messages: number;
|
||||
total_tokens: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
cached_tokens: number;
|
||||
total_cost_usd: number;
|
||||
avg_tokens_per_message: number;
|
||||
conversations: ConversationDetail[];
|
||||
}
|
||||
|
||||
const UserDetailsView: React.FC = () => {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [userDetails, setUserDetails] = useState<UserDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedConversations, setExpandedConversations] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
loadUserDetails();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
const loadUserDetails = async () => {
|
||||
if (!userId) {
|
||||
setError('User ID is required');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await adminAPI.getUserDetails(userId);
|
||||
setUserDetails(response.data);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load user details:', err);
|
||||
setError(err.response?.data?.detail || 'Failed to load user details');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleConversation = (conversationId: string) => {
|
||||
setExpandedConversations((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(conversationId)) {
|
||||
newSet.delete(conversationId);
|
||||
} else {
|
||||
newSet.add(conversationId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/admin');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="user-details-view">
|
||||
<div className="user-details-loading">Loading user details...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !userDetails) {
|
||||
return (
|
||||
<div className="user-details-view">
|
||||
<div className="user-details-error">{error || 'No data available'}</div>
|
||||
<button onClick={handleBack} className="btn-back">Back to Admin</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-details-view">
|
||||
<div className="user-details-header">
|
||||
<button onClick={handleBack} className="btn-back">← Back to Admin</button>
|
||||
<h2>User Details: {userDetails.display_name}</h2>
|
||||
</div>
|
||||
|
||||
{/* User Information Section */}
|
||||
<div className="user-info-section">
|
||||
<h3>User Information</h3>
|
||||
<div className="user-info-grid">
|
||||
<div className="user-info-item">
|
||||
<span className="user-info-label">Email:</span>
|
||||
<span className="user-info-value">{userDetails.email}</span>
|
||||
</div>
|
||||
<div className="user-info-item">
|
||||
<span className="user-info-label">Role:</span>
|
||||
<span className="user-info-value">{userDetails.role}</span>
|
||||
</div>
|
||||
<div className="user-info-item">
|
||||
<span className="user-info-label">Status:</span>
|
||||
<span className={`status-badge ${userDetails.is_active ? 'active' : 'inactive'}`}>
|
||||
{userDetails.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="user-info-item">
|
||||
<span className="user-info-label">Created:</span>
|
||||
<span className="user-info-value">
|
||||
{new Date(userDetails.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="user-info-item">
|
||||
<span className="user-info-label">Last Login:</span>
|
||||
<span className="user-info-value">
|
||||
{userDetails.last_login_at
|
||||
? new Date(userDetails.last_login_at).toLocaleString()
|
||||
: 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Statistics Section */}
|
||||
<div className="user-stats-section">
|
||||
<h3>Token Usage Statistics</h3>
|
||||
<div className="user-stats-grid">
|
||||
<div className="user-stat-card">
|
||||
<div className="user-stat-label">Total Conversations</div>
|
||||
<div className="user-stat-value">{userDetails.total_conversations}</div>
|
||||
</div>
|
||||
<div className="user-stat-card">
|
||||
<div className="user-stat-label">Total Messages</div>
|
||||
<div className="user-stat-value">{userDetails.total_messages}</div>
|
||||
</div>
|
||||
<div className="user-stat-card">
|
||||
<div className="user-stat-label">Total Tokens</div>
|
||||
<div className="user-stat-value">{userDetails.total_tokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="user-stat-card">
|
||||
<div className="user-stat-label">Prompt Tokens</div>
|
||||
<div className="user-stat-value">{userDetails.prompt_tokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="user-stat-card">
|
||||
<div className="user-stat-label">Completion Tokens</div>
|
||||
<div className="user-stat-value">{userDetails.completion_tokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="user-stat-card">
|
||||
<div className="user-stat-label">Cached Tokens</div>
|
||||
<div className="user-stat-value">{userDetails.cached_tokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="user-stat-card">
|
||||
<div className="user-stat-label">Total Cost</div>
|
||||
<div className="user-stat-value">${userDetails.total_cost_usd.toFixed(4)}</div>
|
||||
</div>
|
||||
<div className="user-stat-card">
|
||||
<div className="user-stat-label">Avg Tokens/Message</div>
|
||||
<div className="user-stat-value">{userDetails.avg_tokens_per_message.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversations Section */}
|
||||
<div className="user-conversations-section">
|
||||
<h3>Conversations ({userDetails.conversations.length})</h3>
|
||||
{userDetails.conversations.length === 0 ? (
|
||||
<div className="empty-state">No conversations found</div>
|
||||
) : (
|
||||
<div className="conversations-list">
|
||||
{userDetails.conversations.map((conversation) => (
|
||||
<div key={conversation.id} className="conversation-card">
|
||||
<div
|
||||
className="conversation-header"
|
||||
onClick={() => toggleConversation(conversation.id)}
|
||||
>
|
||||
<div className="conversation-title">
|
||||
<span className="expand-icon">
|
||||
{expandedConversations.has(conversation.id) ? '▼' : '▶'}
|
||||
</span>
|
||||
<span>{conversation.title}</span>
|
||||
</div>
|
||||
<div className="conversation-stats">
|
||||
<span>{conversation.message_count} messages</span>
|
||||
<span>{conversation.total_tokens.toLocaleString()} tokens</span>
|
||||
<span>${conversation.total_cost_usd.toFixed(4)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedConversations.has(conversation.id) && (
|
||||
<div className="conversation-details">
|
||||
<div className="conversation-meta">
|
||||
<div>
|
||||
<strong>Created:</strong>{' '}
|
||||
{new Date(conversation.created_at).toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Last Activity:</strong>{' '}
|
||||
{conversation.last_message_at
|
||||
? new Date(conversation.last_message_at).toLocaleString()
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Total Tokens:</strong>{' '}
|
||||
{conversation.total_tokens.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Total Cost:</strong>{' '}
|
||||
${conversation.total_cost_usd.toFixed(4)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="messages-section">
|
||||
<h4>Messages</h4>
|
||||
<div className="messages-list">
|
||||
{conversation.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`message-item ${message.role}`}
|
||||
>
|
||||
<div className="message-header">
|
||||
<span className="message-role">
|
||||
{message.role === 'user' ? '👤 User' : '🤖 Assistant'}
|
||||
</span>
|
||||
<span className="message-date">
|
||||
{new Date(message.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="message-content">
|
||||
{message.content.length > 500
|
||||
? message.content.substring(0, 500) + '...'
|
||||
: message.content}
|
||||
</div>
|
||||
<div className="message-tokens">
|
||||
<span>Tokens: {message.token_count}</span>
|
||||
<span>Cost: ${message.cost_usd.toFixed(4)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDetailsView;
|
||||
|
|
@ -156,4 +156,8 @@ export const adminAPI = {
|
|||
|
||||
getAllConversations: (skip: number = 0, limit: number = 50) =>
|
||||
apiClient.get('/admin/conversations/all', { params: { skip, limit } }),
|
||||
|
||||
// User details
|
||||
getUserDetails: (userId: string) =>
|
||||
apiClient.get(`/admin/users/${userId}/details`),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -318,3 +318,329 @@
|
|||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* User Details View */
|
||||
.user-details-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 2rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.user-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.user-details-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary-gold);
|
||||
color: var(--primary-gold);
|
||||
}
|
||||
|
||||
.user-details-loading,
|
||||
.user-details-error {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.user-details-error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
/* User Information Section */
|
||||
.user-info-section {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.user-info-section h3 {
|
||||
margin: 0 0 1.25rem 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--primary-gold);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.user-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.user-info-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-info-value {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* User Stats Section */
|
||||
.user-stats-section {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.user-stats-section h3 {
|
||||
margin: 0 0 1.25rem 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--primary-gold);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.user-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.user-stat-card:hover {
|
||||
border-color: var(--primary-gold);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.user-stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-gold);
|
||||
}
|
||||
|
||||
/* Conversations Section */
|
||||
.user-conversations-section {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.user-conversations-section h3 {
|
||||
margin: 0 0 1.25rem 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--primary-gold);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.conversation-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.conversation-card:hover {
|
||||
border-color: var(--primary-gold);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
|
||||
.conversation-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.conversation-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: var(--primary-gold);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.archived-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--error-bg);
|
||||
color: var(--error-color);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conversation-stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.conversation-details {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.conversation-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conversation-meta strong {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Messages Section */
|
||||
.messages-section h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.message-item.user {
|
||||
border-left: 3px solid var(--primary-blue);
|
||||
}
|
||||
|
||||
.message-item.assistant {
|
||||
border-left: 3px solid var(--primary-gold);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.message-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-tokens {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.message-tokens span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue