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) logger.error(f"Failed to get user stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to retrieve user statistics") 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") @router.get("/users/threads/by-email")
async def get_user_threads_by_email( async def get_user_threads_by_email(
email: str = Query(..., description="User email to fetch threads for"), 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. 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 typing import Optional, List
from decimal import Decimal from decimal import Decimal
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@ -185,31 +185,60 @@ async def get_user_billing_summary(
@router.get("/user/{account_id}/transactions") @router.get("/user/{account_id}/transactions")
async def get_user_transactions( async def get_user_transactions(
account_id: str, account_id: str,
limit: int = 100, page: int = Query(1, ge=1, description="Page number"),
offset: int = 0, page_size: int = Query(20, ge=1, le=100, description="Items per page"),
type_filter: Optional[str] = None, type_filter: Optional[str] = None,
admin: dict = Depends(require_admin) admin: dict = Depends(require_admin)
): ):
"""Get transaction history for a specific user.""" try:
db = DBConnection() from core.utils.pagination import PaginationService, PaginationParams
client = await db.client
db = DBConnection()
query = client.from_('credit_ledger').select('*').eq('account_id', account_id).order('created_at', desc=True) client = await db.client
if type_filter: pagination_params = PaginationParams(page=page, page_size=page_size)
query = query.eq('type', type_filter)
# Get total count
if offset: count_query = client.from_('credit_ledger').select('*', count='exact').eq('account_id', account_id)
query = query.range(offset, offset + limit - 1) if type_filter:
else: count_query = count_query.eq('type', type_filter)
query = query.limit(limit) count_result = await count_query.execute()
total_count = count_result.count or 0
transactions_result = await query.execute()
# Get paginated transactions
return { offset = (pagination_params.page - 1) * pagination_params.page_size
'account_id': account_id, transactions_query = client.from_('credit_ledger').select('*').eq('account_id', account_id)
'transactions': transactions_result.data or [],
'count': len(transactions_result.data or []) 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, MessageSquare,
ExternalLink, ExternalLink,
} from 'lucide-react'; } 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 { import {
useUserBillingSummary, useUserBillingSummary,
useAdjustCredits, useAdjustCredits,
useProcessRefund, useProcessRefund,
useAdminUserTransactions,
} from '@/hooks/react-query/admin/use-admin-billing'; } from '@/hooks/react-query/admin/use-admin-billing';
import type { UserSummary } from '@/hooks/react-query/admin/use-admin-users'; import type { UserSummary } from '@/hooks/react-query/admin/use-admin-users';
@ -68,6 +69,8 @@ export function AdminUserDetailsDialog({
const [adjustIsExpiring, setAdjustIsExpiring] = useState(true); const [adjustIsExpiring, setAdjustIsExpiring] = useState(true);
const [refundIsExpiring, setRefundIsExpiring] = useState(false); const [refundIsExpiring, setRefundIsExpiring] = useState(false);
const [threadsPage, setThreadsPage] = useState(1); 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: userDetails, isLoading } = useAdminUserDetails(user?.id || null);
const { data: billingSummary, refetch: refetchBilling } = useUserBillingSummary(user?.id || null); const { data: billingSummary, refetch: refetchBilling } = useUserBillingSummary(user?.id || null);
@ -76,6 +79,16 @@ export function AdminUserDetailsDialog({
page: threadsPage, page: threadsPage,
page_size: 10, 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 adjustCreditsMutation = useAdjustCredits();
const processRefundMutation = useProcessRefund(); const processRefundMutation = useProcessRefund();
@ -385,40 +398,73 @@ export function AdminUserDetailsDialog({
</TabsContent> </TabsContent>
<TabsContent value="transactions" className="space-y-4"> <TabsContent value="transactions" className="space-y-4">
{billingSummary && ( <Card>
<Card className='border-0 shadow-none bg-transparent'> <CardHeader>
<CardContent className='p-0'> <CardTitle className="flex items-center gap-2">
<DollarSign className="h-4 w-4" />
Transactions
</CardTitle>
</CardHeader>
<CardContent>
{transactionsLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{billingSummary.recent_transactions?.length > 0 ? ( {[...Array(3)].map((_, i) => (
billingSummary.recent_transactions.map((transaction: any) => ( <Skeleton key={i} className="h-16 w-full" />
<div ))}
key={transaction.id} </div>
className="flex items-center justify-between p-3 border rounded-lg" ) : userTransactions && userTransactions.data?.length > 0 ? (
> <div className="space-y-2">
<div> {userTransactions.data.map((transaction: any) => (
<p className="text-sm font-medium">{transaction.description}</p> <div
<p className="text-xs text-muted-foreground"> key={transaction.id}
{formatDate(transaction.created_at)} className="flex items-center justify-between p-3 border rounded-lg"
</p> >
</div> <div>
<div className="text-right"> <p className="text-sm font-medium">{transaction.description}</p>
<p className={`font-semibold ${getTransactionColor(transaction.type)}`}> <p className="text-xs text-muted-foreground">
{transaction.amount > 0 ? '+' : ''} {formatDate(transaction.created_at)}
{formatCurrency(Math.abs(transaction.amount))} </p>
</p>
<p className="text-xs text-muted-foreground">
Balance: {formatCurrency(transaction.balance_after)}
</p>
</div>
</div> </div>
)) <div className="text-right">
) : ( <p className={`font-semibold ${getTransactionColor(transaction.type)}`}>
<p className="text-sm text-muted-foreground">No recent transactions</p> {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> </div>
</CardContent> ) : (
</Card> <p className="text-sm text-muted-foreground">No transactions found</p>
)} )}
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="activity" className="space-y-4"> <TabsContent value="activity" className="space-y-4">
@ -426,33 +472,77 @@ export function AdminUserDetailsDialog({
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
Recent Activity Activity
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{userDetails?.recent_activity?.length > 0 ? ( {activityLoading ? (
<div className="space-y-2"> <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 <div
key={activity.id} key={activity.id}
className="flex items-center justify-between p-3 border rounded-lg" className="flex items-center justify-between p-3 border rounded-lg"
> >
<div> <div className="flex-1 min-w-0">
<p className="text-sm font-medium">Agent Run</p> <div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground"> <p className="text-sm font-medium">{activity.agent_name}</p>
{formatDate(activity.created_at)} Thread {activity.thread_id.slice(-8)} <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> </p>
{activity.error && (
<p className="text-xs text-red-600 mt-1 truncate">
Error: {activity.error}
</p>
)}
</div> </div>
<Badge {activity.credit_cost > 0 && (
variant={activity.status === 'completed' ? 'default' : 'secondary'} <div className="text-right ml-2">
> <p className="text-sm font-medium text-muted-foreground">
{activity.status} {formatCurrency(activity.credit_cost)}
</Badge> </p>
</div>
)}
</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> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground">No recent activity</p> <p className="text-sm text-muted-foreground">No activity found</p>
)} )}
</CardContent> </CardContent>
</Card> </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({ 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 () => { queryFn: async () => {
if (!userId) return null; const searchParams = new URLSearchParams();
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});
if (typeFilter) params.append('type_filter', typeFilter);
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) { if (response.error) {
throw new Error(response.error.message); throw new Error(response.error.message);
} }
return response.data; 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() { export function useRefreshUserData() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();