mirror of https://github.com/kortix-ai/suna.git
Merge pull request #1797 from escapade-mckv/show-all-runs-for-admin
show paginated details in admin dialog
This commit is contained in:
commit
8d17f779b0
|
@ -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"),
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
Loading…
Reference in New Issue