From f756046eae23ae3e4359f24118d4b2fafca15c2d Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:47:04 +0000 Subject: [PATCH] feat: add admin request logs page for searching API request logs --- src/app/admin/api/requests/hooks.ts | 25 + src/app/admin/components/AppSidebar.tsx | 6 + src/app/admin/components/RequestLogsTable.tsx | 585 ++++++++++++++++++ src/app/admin/requests/page.tsx | 22 + src/routers/admin-router.ts | 120 +++- 5 files changed, 757 insertions(+), 1 deletion(-) create mode 100644 src/app/admin/api/requests/hooks.ts create mode 100644 src/app/admin/components/RequestLogsTable.tsx create mode 100644 src/app/admin/requests/page.tsx diff --git a/src/app/admin/api/requests/hooks.ts b/src/app/admin/api/requests/hooks.ts new file mode 100644 index 000000000..caa56fac3 --- /dev/null +++ b/src/app/admin/api/requests/hooks.ts @@ -0,0 +1,25 @@ +'use client'; + +import { useTRPC } from '@/lib/trpc/utils'; +import { useQuery } from '@tanstack/react-query'; + +type RequestLogsListParams = { + page: number; + limit: number; + sortBy: 'created_at' | 'status_code' | 'provider' | 'model'; + sortOrder: 'asc' | 'desc'; + requestId?: string; + search?: string; + fromDate?: string; + toDate?: string; + provider?: string; + statusCode?: number; + kiloUserId?: string; + organizationId?: string; + model?: string; +}; + +export function useRequestLogsList(params: RequestLogsListParams) { + const trpc = useTRPC(); + return useQuery(trpc.admin.requestLogs.list.queryOptions(params)); +} diff --git a/src/app/admin/components/AppSidebar.tsx b/src/app/admin/components/AppSidebar.tsx index d2ec1b7e6..11788390f 100644 --- a/src/app/admin/components/AppSidebar.tsx +++ b/src/app/admin/components/AppSidebar.tsx @@ -19,6 +19,7 @@ import { UserX, Upload, Bell, + ScrollText, } from 'lucide-react'; import { useSession } from 'next-auth/react'; import type { Session } from 'next-auth'; @@ -159,6 +160,11 @@ const analyticsObservabilityItems: MenuItem[] = [ url: '/admin/alerting-ttfb', icon: () => , }, + { + title: () => 'Request Logs', + url: '/admin/requests', + icon: () => , + }, ]; const menuSections: MenuSection[] = [ diff --git a/src/app/admin/components/RequestLogsTable.tsx b/src/app/admin/components/RequestLogsTable.tsx new file mode 100644 index 000000000..0b30f7192 --- /dev/null +++ b/src/app/admin/components/RequestLogsTable.tsx @@ -0,0 +1,585 @@ +'use client'; + +import { useState, useCallback, useMemo, useEffect } from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Search, X, ChevronUp, ChevronDown, Eye, ArrowLeft, ArrowRight } from 'lucide-react'; +import { format } from 'date-fns'; +import { useRequestLogsList } from '@/app/admin/api/requests/hooks'; +import JsonView from '@uiw/react-json-view'; +import { darkTheme } from '@uiw/react-json-view/dark'; + +type SortBy = 'created_at' | 'status_code' | 'provider' | 'model'; +type SortOrder = 'asc' | 'desc'; + +type Filters = { + requestId: string; + search: string; + fromDate: string; + toDate: string; + provider: string; + model: string; + statusCode: string; + kiloUserId: string; + organizationId: string; +}; + +const emptyFilters: Filters = { + requestId: '', + search: '', + fromDate: '', + toDate: '', + provider: '', + model: '', + statusCode: '', + kiloUserId: '', + organizationId: '', +}; + +type RequestLog = { + id: string; + created_at: string; + kilo_user_id: string | null; + organization_id: string | null; + provider: string | null; + model: string | null; + status_code: number | null; + request?: unknown; + response: string | null; +}; + +function tryParseJson(text: string | null | undefined): unknown { + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function StatusBadge({ code }: { code: number | null }) { + if (code === null) return N/A; + + if (code >= 200 && code < 300) { + return {code}; + } + if (code >= 400 && code < 500) { + return {code}; + } + if (code >= 500) { + return {code}; + } + return {code}; +} + +function SortableHeader({ + label, + column, + currentSortBy, + currentSortOrder, + onSort, +}: { + label: string; + column: SortBy; + currentSortBy: SortBy; + currentSortOrder: SortOrder; + onSort: (column: SortBy) => void; +}) { + const isActive = currentSortBy === column; + return ( + onSort(column)} + > + + {label} + {isActive ? ( + currentSortOrder === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + + ); +} + +function RequestLogDetailDialog({ + log, + open, + onOpenChange, +}: { + log: RequestLog | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [viewMode, setViewMode] = useState<'formatted' | 'raw'>('formatted'); + + if (!log) return null; + + const parsedRequest = typeof log.request === 'string' ? tryParseJson(log.request) : log.request; + const parsedResponse = tryParseJson(log.response); + + return ( + + + + Request Log #{log.id} + + + + + setViewMode('formatted')} + > + Formatted + + setViewMode('raw')} + > + Raw JSON + + + + {viewMode === 'formatted' ? ( + + + + Details + + + + ID: {log.id} + + + Created:{' '} + {format(new Date(log.created_at), 'yyyy-MM-dd HH:mm:ss')} + + + Provider: {log.provider ?? 'N/A'} + + + Model: {log.model ?? 'N/A'} + + + Status:{' '} + + + + User ID:{' '} + {log.kilo_user_id ?? 'N/A'} + + + Org ID:{' '} + {log.organization_id ?? 'N/A'} + + + + + + + Request + + + {parsedRequest && typeof parsedRequest === 'object' ? ( + + + + ) : ( + + {JSON.stringify(log.request, null, 2)} + + )} + + + + + + Response + + + {parsedResponse && typeof parsedResponse === 'object' ? ( + + + + ) : ( + + {log.response ?? 'No response'} + + )} + + + + ) : ( + + + + Full Request + + + {parsedRequest && typeof parsedRequest === 'object' ? ( + + + + ) : ( + + {JSON.stringify(log.request, null, 2)} + + )} + + + + + + Full Response + + + {parsedResponse && typeof parsedResponse === 'object' ? ( + + + + ) : ( + + {log.response ?? 'No response'} + + )} + + + + )} + + + + ); +} + +export function RequestLogsTable() { + const [page, setPage] = useState(1); + const [limit] = useState(50); + const [sortBy, setSortBy] = useState('created_at'); + const [sortOrder, setSortOrder] = useState('desc'); + const [filters, setFilters] = useState(emptyFilters); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [selectedLog, setSelectedLog] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(filters.search); + }, 300); + return () => clearTimeout(timer); + }, [filters.search]); + + const queryParams = useMemo(() => { + const params: Parameters[0] = { + page, + limit, + sortBy, + sortOrder, + }; + + if (filters.requestId) params.requestId = filters.requestId; + if (debouncedSearch) params.search = debouncedSearch; + if (filters.fromDate) params.fromDate = new Date(filters.fromDate).toISOString(); + if (filters.toDate) params.toDate = new Date(filters.toDate).toISOString(); + if (filters.provider) params.provider = filters.provider; + if (filters.model) params.model = filters.model; + if (filters.statusCode) { + const parsed = parseInt(filters.statusCode, 10); + if (!isNaN(parsed)) params.statusCode = parsed; + } + if (filters.kiloUserId) params.kiloUserId = filters.kiloUserId; + if (filters.organizationId) params.organizationId = filters.organizationId; + + return params; + }, [page, limit, sortBy, sortOrder, filters, debouncedSearch]); + + const { data, isLoading } = useRequestLogsList(queryParams); + + const handleSort = useCallback( + (column: SortBy) => { + if (sortBy === column) { + setSortOrder(prev => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortBy(column); + setSortOrder('desc'); + } + setPage(1); + }, + [sortBy] + ); + + const updateFilter = useCallback((key: keyof Filters, value: string) => { + setFilters(prev => ({ ...prev, [key]: value })); + if (key !== 'search') setPage(1); + }, []); + + const clearFilters = useCallback(() => { + setFilters(emptyFilters); + setDebouncedSearch(''); + setPage(1); + }, []); + + const handleViewLog = useCallback((log: RequestLog) => { + setSelectedLog(log); + setDialogOpen(true); + }, []); + + const hasActiveFilters = Object.values(filters).some(v => v !== ''); + + const logs = data?.logs ?? []; + const pagination = data?.pagination ?? { page: 1, limit: 50, total: 0, totalPages: 0 }; + + return ( + + {/* Filters */} + + + + + + Filters + + {hasActiveFilters && ( + + + Clear filters + + )} + + + + + + Request ID + updateFilter('requestId', e.target.value)} + /> + + + Full-text Search + updateFilter('search', e.target.value)} + /> + + + From Date + updateFilter('fromDate', e.target.value)} + /> + + + To Date + updateFilter('toDate', e.target.value)} + /> + + + Provider + updateFilter('provider', e.target.value)} + /> + + + Model + updateFilter('model', e.target.value)} + /> + + + Status Code + updateFilter('statusCode', e.target.value)} + /> + + + User ID + updateFilter('kiloUserId', e.target.value)} + /> + + + Organization ID + updateFilter('organizationId', e.target.value)} + /> + + + + + + {/* Results Table */} + + + + + + ID + + User ID + Org ID + + + + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : logs.length === 0 ? ( + + + No request logs found + + + ) : ( + logs.map(log => ( + + {log.id} + + {format(new Date(log.created_at), 'yyyy-MM-dd HH:mm:ss')} + + + {log.kilo_user_id ?? '—'} + + + {log.organization_id ?? '—'} + + {log.provider ?? '—'} + {log.model ?? '—'} + + + + + handleViewLog(log)}> + + View + + + + )) + )} + + + + + + {/* Pagination */} + + + {pagination.total > 0 + ? `Showing ${(pagination.page - 1) * pagination.limit + 1}–${Math.min( + pagination.page * pagination.limit, + pagination.total + )} of ${pagination.total}` + : 'No results'} + + + + Page {pagination.page} of {pagination.totalPages || 1} + + + setPage(prev => prev - 1)} + disabled={pagination.page <= 1 || isLoading} + > + + Previous + + setPage(prev => prev + 1)} + disabled={pagination.page >= pagination.totalPages || isLoading} + > + Next + + + + + + + {/* Detail Dialog */} + + + ); +} diff --git a/src/app/admin/requests/page.tsx b/src/app/admin/requests/page.tsx new file mode 100644 index 000000000..477a41c25 --- /dev/null +++ b/src/app/admin/requests/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from 'react'; +import { RequestLogsTable } from '../components/RequestLogsTable'; +import AdminPage from '../components/AdminPage'; +import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb'; + +const breadcrumbs = ( + <> + + Request Logs + + > +); + +export default function RequestLogsPage() { + return ( + + Loading request logs...}> + + + + ); +} diff --git a/src/routers/admin-router.ts b/src/routers/admin-router.ts index fa684cb0c..4b08a8ca7 100644 --- a/src/routers/admin-router.ts +++ b/src/routers/admin-router.ts @@ -9,6 +9,7 @@ import { modelStats, cliSessions, credit_transactions, + api_request_log, } from '@/db/schema'; import { adminAppBuilderRouter } from '@/routers/admin-app-builder-router'; import { adminDeploymentsRouter } from '@/routers/admin-deployments-router'; @@ -20,7 +21,7 @@ import { bulkUserCreditsRouter } from '@/routers/admin/bulk-user-credits-router' import { adminWebhookTriggersRouter } from '@/routers/admin-webhook-triggers-router'; import { adminAlertingRouter } from '@/routers/admin-alerting-router'; import * as z from 'zod'; -import { eq, and, ne, or, ilike, desc, asc, sql, isNull } from 'drizzle-orm'; +import { eq, and, ne, or, ilike, desc, asc, sql, isNull, gte, lte } from 'drizzle-orm'; import { findUsersByIds, findUserById } from '@/lib/user'; import { getBlobContent } from '@/lib/r2/cli-sessions'; import { toNonNullish } from '@/lib/utils'; @@ -682,6 +683,123 @@ export const adminRouter = createTRPCRouter({ } }), }), + requestLogs: createTRPCRouter({ + list: adminProcedure + .input( + z.object({ + page: z.number().min(1).default(1), + limit: z.number().min(1).max(100).default(50), + sortBy: z.enum(['created_at', 'status_code', 'provider', 'model']).default('created_at'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + requestId: z.string().optional(), + search: z.string().optional(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + provider: z.string().optional(), + statusCode: z.number().optional(), + kiloUserId: z.string().optional(), + organizationId: z.string().optional(), + model: z.string().optional(), + }) + ) + .query(async ({ input }) => { + const { page, limit, sortBy, sortOrder, search, requestId } = input; + const offset = (page - 1) * limit; + + const conditions = []; + + if (requestId) { + conditions.push(eq(api_request_log.id, BigInt(requestId))); + } + + if (search) { + conditions.push( + or( + ilike(api_request_log.provider, `%${search}%`), + ilike(api_request_log.model, `%${search}%`), + ilike(api_request_log.kilo_user_id, `%${search}%`), + ilike(api_request_log.organization_id, `%${search}%`), + ilike(sql`${api_request_log.request}::text`, `%${search}%`) + ) + ); + } + + if (input.fromDate) { + conditions.push(gte(api_request_log.created_at, input.fromDate)); + } + + if (input.toDate) { + conditions.push(lte(api_request_log.created_at, input.toDate)); + } + + if (input.provider) { + conditions.push(eq(api_request_log.provider, input.provider)); + } + + if (input.statusCode !== undefined) { + conditions.push(eq(api_request_log.status_code, input.statusCode)); + } + + if (input.kiloUserId) { + conditions.push(eq(api_request_log.kilo_user_id, input.kiloUserId)); + } + + if (input.organizationId) { + conditions.push(eq(api_request_log.organization_id, input.organizationId)); + } + + if (input.model) { + conditions.push(eq(api_request_log.model, input.model)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const sortByMap = { + created_at: api_request_log.created_at, + status_code: api_request_log.status_code, + provider: api_request_log.provider, + model: api_request_log.model, + }; + + const orderByColumn = sortByMap[sortBy]; + const orderFn = sortOrder === 'asc' ? asc : desc; + + const results = await db + .select({ + id: api_request_log.id, + created_at: api_request_log.created_at, + kilo_user_id: api_request_log.kilo_user_id, + organization_id: api_request_log.organization_id, + provider: api_request_log.provider, + model: api_request_log.model, + status_code: api_request_log.status_code, + request: api_request_log.request, + response: api_request_log.response, + total: sql`count(*) OVER()::int`.as('total'), + }) + .from(api_request_log) + .where(whereClause) + .orderBy(orderFn(orderByColumn)) + .limit(limit) + .offset(offset); + + const total = results[0]?.total ?? 0; + + return { + logs: results.map(({ total: _, ...log }) => ({ + ...log, + id: log.id.toString(), + })), + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + }), + }), + appBuilder: adminAppBuilderRouter, aiAttribution: adminAIAttributionRouter, ossSponsorship: ossSponsorshipRouter,
+ {JSON.stringify(log.request, null, 2)} +
+ {log.response ?? 'No response'} +