Merge pull request #1797 from escapade-mckv/show-all-runs-for-admin

show paginated details in admin dialog
This commit is contained in:
Bobbie 2025-10-09 16:08:10 +05:30 committed by GitHub
commit 8d17f779b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 304 additions and 78 deletions

View File

@ -314,6 +314,78 @@ async def get_user_stats_overview(
logger.error(f"Failed to get user stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to retrieve user statistics")
@router.get("/users/{user_id}/activity")
async def get_user_activity(
user_id: str,
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
status_filter: Optional[str] = Query(None, description="Filter by status"),
admin: dict = Depends(require_admin)
):
"""Get paginated activity (agent runs) for a specific user."""
try:
db = DBConnection()
client = await db.client
pagination_params = PaginationParams(page=page, page_size=page_size)
# Build base query for agent runs with thread info
base_query = client.from_('agent_runs').select(
'*, threads!inner(account_id, thread_id)'
).eq('threads.account_id', user_id)
if status_filter:
base_query = base_query.eq('status', status_filter)
# Get total count
count_result = await client.from_('agent_runs').select(
'id, threads!inner(account_id)', count='exact'
).eq('threads.account_id', user_id).execute()
total_count = count_result.count or 0
# Get paginated activity
offset = (pagination_params.page - 1) * pagination_params.page_size
activity_query = client.from_('agent_runs').select(
'*, threads!inner(account_id, thread_id)'
).eq('threads.account_id', user_id)
if status_filter:
activity_query = activity_query.eq('status', status_filter)
activity_result = await activity_query.order('created_at', desc=True).range(
offset, offset + pagination_params.page_size - 1
).execute()
# Format activity data
activities = []
for run in activity_result.data or []:
thread = run.get('threads', {})
activities.append({
'id': run.get('id'),
'created_at': run.get('created_at'),
'updated_at': run.get('updated_at'),
'status': run.get('status'),
'thread_id': run.get('thread_id'),
'thread_name': f"Thread {run.get('thread_id', '').split('-')[0] if run.get('thread_id') else 'Unknown'}",
'agent_id': run.get('agent_id'),
'agent_name': 'Agent', # We'll need to fetch agent names separately if needed
'credit_cost': float(run.get('credit_cost', 0) if run.get('credit_cost') else 0),
'error': run.get('error'),
'duration_ms': run.get('duration_ms')
})
return await PaginationService.paginate_with_total_count(
items=activities,
total_count=total_count,
params=pagination_params
)
except Exception as e:
logger.error(f"Failed to get user activity: {e}")
raise HTTPException(status_code=500, detail="Failed to retrieve user activity")
@router.get("/users/threads/by-email")
async def get_user_threads_by_email(
email: str = Query(..., description="User email to fetch threads for"),

View File

@ -4,7 +4,7 @@ Handles all administrative billing operations: credits, refunds, transactions.
User search has been moved to admin_api.py as it's user-focused, not billing-focused.
"""
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Query
from typing import Optional, List
from decimal import Decimal
from datetime import datetime, timezone, timedelta
@ -185,31 +185,60 @@ async def get_user_billing_summary(
@router.get("/user/{account_id}/transactions")
async def get_user_transactions(
account_id: str,
limit: int = 100,
offset: int = 0,
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
type_filter: Optional[str] = None,
admin: dict = Depends(require_admin)
):
"""Get transaction history for a specific user."""
db = DBConnection()
client = await db.client
query = client.from_('credit_ledger').select('*').eq('account_id', account_id).order('created_at', desc=True)
if type_filter:
query = query.eq('type', type_filter)
if offset:
query = query.range(offset, offset + limit - 1)
else:
query = query.limit(limit)
transactions_result = await query.execute()
return {
'account_id': account_id,
'transactions': transactions_result.data or [],
'count': len(transactions_result.data or [])
}
try:
from core.utils.pagination import PaginationService, PaginationParams
db = DBConnection()
client = await db.client
pagination_params = PaginationParams(page=page, page_size=page_size)
# Get total count
count_query = client.from_('credit_ledger').select('*', count='exact').eq('account_id', account_id)
if type_filter:
count_query = count_query.eq('type', type_filter)
count_result = await count_query.execute()
total_count = count_result.count or 0
# Get paginated transactions
offset = (pagination_params.page - 1) * pagination_params.page_size
transactions_query = client.from_('credit_ledger').select('*').eq('account_id', account_id)
if type_filter:
transactions_query = transactions_query.eq('type', type_filter)
transactions_result = await transactions_query.order('created_at', desc=True).range(
offset, offset + pagination_params.page_size - 1
).execute()
# Format transactions
transactions = []
for tx in transactions_result.data or []:
transactions.append({
'id': tx.get('id'),
'created_at': tx.get('created_at'),
'amount': float(tx.get('amount', 0)),
'balance_after': float(tx.get('balance_after', 0)),
'type': tx.get('type'),
'description': tx.get('description'),
'is_expiring': tx.get('is_expiring', False),
'expires_at': tx.get('expires_at'),
'metadata': tx.get('metadata', {})
})
return await PaginationService.paginate_with_total_count(
items=transactions,
total_count=total_count,
params=pagination_params
)
except Exception as e:
logger.error(f"Failed to get user transactions: {e}")
raise HTTPException(status_code=500, detail="Failed to retrieve transactions")

View File

@ -40,11 +40,12 @@ import {
MessageSquare,
ExternalLink,
} from 'lucide-react';
import { useAdminUserDetails, useAdminUserThreads } from '@/hooks/react-query/admin/use-admin-users';
import { useAdminUserDetails, useAdminUserThreads, useAdminUserActivity } from '@/hooks/react-query/admin/use-admin-users';
import {
useUserBillingSummary,
useAdjustCredits,
useProcessRefund,
useAdminUserTransactions,
} from '@/hooks/react-query/admin/use-admin-billing';
import type { UserSummary } from '@/hooks/react-query/admin/use-admin-users';
@ -68,6 +69,8 @@ export function AdminUserDetailsDialog({
const [adjustIsExpiring, setAdjustIsExpiring] = useState(true);
const [refundIsExpiring, setRefundIsExpiring] = useState(false);
const [threadsPage, setThreadsPage] = useState(1);
const [transactionsPage, setTransactionsPage] = useState(1);
const [activityPage, setActivityPage] = useState(1);
const { data: userDetails, isLoading } = useAdminUserDetails(user?.id || null);
const { data: billingSummary, refetch: refetchBilling } = useUserBillingSummary(user?.id || null);
@ -76,6 +79,16 @@ export function AdminUserDetailsDialog({
page: threadsPage,
page_size: 10,
});
const { data: userTransactions, isLoading: transactionsLoading } = useAdminUserTransactions({
userId: user?.id || '',
page: transactionsPage,
page_size: 10,
});
const { data: userActivity, isLoading: activityLoading } = useAdminUserActivity({
userId: user?.id || '',
page: activityPage,
page_size: 10,
});
const adjustCreditsMutation = useAdjustCredits();
const processRefundMutation = useProcessRefund();
@ -385,40 +398,73 @@ export function AdminUserDetailsDialog({
</TabsContent>
<TabsContent value="transactions" className="space-y-4">
{billingSummary && (
<Card className='border-0 shadow-none bg-transparent'>
<CardContent className='p-0'>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-4 w-4" />
Transactions
</CardTitle>
</CardHeader>
<CardContent>
{transactionsLoading ? (
<div className="space-y-2">
{billingSummary.recent_transactions?.length > 0 ? (
billingSummary.recent_transactions.map((transaction: any) => (
<div
key={transaction.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="text-sm font-medium">{transaction.description}</p>
<p className="text-xs text-muted-foreground">
{formatDate(transaction.created_at)}
</p>
</div>
<div className="text-right">
<p className={`font-semibold ${getTransactionColor(transaction.type)}`}>
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(Math.abs(transaction.amount))}
</p>
<p className="text-xs text-muted-foreground">
Balance: {formatCurrency(transaction.balance_after)}
</p>
</div>
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : userTransactions && userTransactions.data?.length > 0 ? (
<div className="space-y-2">
{userTransactions.data.map((transaction: any) => (
<div
key={transaction.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="text-sm font-medium">{transaction.description}</p>
<p className="text-xs text-muted-foreground">
{formatDate(transaction.created_at)}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No recent transactions</p>
<div className="text-right">
<p className={`font-semibold ${getTransactionColor(transaction.type)}`}>
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(Math.abs(transaction.amount))}
</p>
<p className="text-xs text-muted-foreground">
Balance: {formatCurrency(transaction.balance_after)}
</p>
</div>
</div>
))}
{userTransactions.pagination && userTransactions.pagination.total_pages > 1 && (
<div className="flex items-center justify-between pt-2">
<Button
variant="outline"
size="sm"
disabled={!userTransactions.pagination.has_prev}
onClick={() => setTransactionsPage(p => Math.max(1, p - 1))}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {userTransactions.pagination.page} of {userTransactions.pagination.total_pages}
</span>
<Button
variant="outline"
size="sm"
disabled={!userTransactions.pagination.has_next}
onClick={() => setTransactionsPage(p => p + 1)}
>
Next
</Button>
</div>
)}
</div>
</CardContent>
</Card>
)}
) : (
<p className="text-sm text-muted-foreground">No transactions found</p>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="activity" className="space-y-4">
@ -426,33 +472,77 @@ export function AdminUserDetailsDialog({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Recent Activity
Activity
</CardTitle>
</CardHeader>
<CardContent>
{userDetails?.recent_activity?.length > 0 ? (
{activityLoading ? (
<div className="space-y-2">
{userDetails.recent_activity.map((activity) => (
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : userActivity && userActivity.data?.length > 0 ? (
<div className="space-y-2">
{userActivity.data.map((activity: any) => (
<div
key={activity.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="text-sm font-medium">Agent Run</p>
<p className="text-xs text-muted-foreground">
{formatDate(activity.created_at)} Thread {activity.thread_id.slice(-8)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{activity.agent_name}</p>
<Badge
variant={activity.status === 'completed' ? 'default' : activity.status === 'failed' ? 'destructive' : 'secondary'}
className="text-xs"
>
{activity.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatDate(activity.created_at)} Thread: {activity.thread_name || activity.thread_id.slice(-8)}
</p>
{activity.error && (
<p className="text-xs text-red-600 mt-1 truncate">
Error: {activity.error}
</p>
)}
</div>
<Badge
variant={activity.status === 'completed' ? 'default' : 'secondary'}
>
{activity.status}
</Badge>
{activity.credit_cost > 0 && (
<div className="text-right ml-2">
<p className="text-sm font-medium text-muted-foreground">
{formatCurrency(activity.credit_cost)}
</p>
</div>
)}
</div>
))}
{userActivity.pagination && userActivity.pagination.total_pages > 1 && (
<div className="flex items-center justify-between pt-2">
<Button
variant="outline"
size="sm"
disabled={!userActivity.pagination.has_prev}
onClick={() => setActivityPage(p => Math.max(1, p - 1))}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {userActivity.pagination.page} of {userActivity.pagination.total_pages}
</span>
<Button
variant="outline"
size="sm"
disabled={!userActivity.pagination.has_next}
onClick={() => setActivityPage(p => p + 1)}
>
Next
</Button>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">No recent activity</p>
<p className="text-sm text-muted-foreground">No activity found</p>
)}
</CardContent>
</Card>

View File

@ -33,24 +33,31 @@ export function useUserBillingSummary(userId: string | null) {
});
}
export function useUserTransactions(userId: string | null, limit = 100, offset = 0, typeFilter?: string) {
interface TransactionParams {
userId: string;
page?: number;
page_size?: number;
type_filter?: string;
}
export function useAdminUserTransactions(params: TransactionParams) {
return useQuery({
queryKey: ['admin', 'billing', 'transactions', userId, limit, offset, typeFilter],
queryKey: ['admin', 'billing', 'transactions', params.userId, params.page, params.page_size, params.type_filter],
queryFn: async () => {
if (!userId) return null;
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});
if (typeFilter) params.append('type_filter', typeFilter);
const searchParams = new URLSearchParams();
const response = await backendApi.get(`/admin/billing/user/${userId}/transactions?${params}`);
if (params.page) searchParams.append('page', params.page.toString());
if (params.page_size) searchParams.append('page_size', params.page_size.toString());
if (params.type_filter) searchParams.append('type_filter', params.type_filter);
const response = await backendApi.get(`/admin/billing/user/${params.userId}/transactions?${searchParams.toString()}`);
if (response.error) {
throw new Error(response.error.message);
}
return response.data;
},
enabled: !!userId,
enabled: !!params.userId,
staleTime: 30000,
});
}

View File

@ -184,6 +184,34 @@ export function useAdminUserThreads(params: UserThreadsParams) {
});
}
interface UserActivityParams {
userId: string;
page?: number;
page_size?: number;
status_filter?: string;
}
export function useAdminUserActivity(params: UserActivityParams) {
return useQuery({
queryKey: ['admin', 'users', 'activity', params.userId, params.page, params.page_size, params.status_filter],
queryFn: async () => {
const searchParams = new URLSearchParams();
if (params.page) searchParams.append('page', params.page.toString());
if (params.page_size) searchParams.append('page_size', params.page_size.toString());
if (params.status_filter) searchParams.append('status_filter', params.status_filter);
const response = await backendApi.get(`/admin/users/${params.userId}/activity?${searchParams.toString()}`);
if (response.error) {
throw new Error(response.error.message);
}
return response.data;
},
enabled: !!params.userId,
staleTime: 30000,
});
}
export function useRefreshUserData() {
const queryClient = useQueryClient();