diff --git a/src/app/admin/api/requests/hooks.ts b/src/app/admin/api/requests/hooks.ts new file mode 100644 index 000000000..96643a7d5 --- /dev/null +++ b/src/app/admin/api/requests/hooks.ts @@ -0,0 +1,27 @@ +'use client'; + +import { useTRPC } from '@/lib/trpc/utils'; +import { useQuery } from '@tanstack/react-query'; + +type ListParams = { + page: number; + limit: number; + sortOrder: 'asc' | 'desc'; + requestId?: string; + startTime?: string; + endTime?: string; + query?: string; +}; + +export function useAdminRequests(params: ListParams) { + const trpc = useTRPC(); + return useQuery(trpc.admin.requests.list.queryOptions(params)); +} + +export function useAdminRequestById(id: string | null) { + const trpc = useTRPC(); + return useQuery({ + ...trpc.admin.requests.getById.queryOptions({ id: id ?? '' }), + enabled: !!id, + }); +} diff --git a/src/app/admin/components/AppSidebar.tsx b/src/app/admin/components/AppSidebar.tsx index d2ec1b7e6..a6a3debdc 100644 --- a/src/app/admin/components/AppSidebar.tsx +++ b/src/app/admin/components/AppSidebar.tsx @@ -15,6 +15,7 @@ import { MessageSquare, Sparkles, FileSearch, + FileText, GitPullRequest, UserX, Upload, @@ -159,6 +160,11 @@ const analyticsObservabilityItems: MenuItem[] = [ url: '/admin/alerting-ttfb', icon: () => , }, + { + title: () => 'API Requests', + url: '/admin/requests', + icon: () => , + }, ]; const menuSections: MenuSection[] = [ diff --git a/src/app/admin/components/RequestDetailDialog.tsx b/src/app/admin/components/RequestDetailDialog.tsx new file mode 100644 index 000000000..5d509a35e --- /dev/null +++ b/src/app/admin/components/RequestDetailDialog.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { useAdminRequestById } from '@/app/admin/api/requests/hooks'; + +type RequestDetailDialogProps = { + requestId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +function statusCodeVariant(code: number | null): 'default' | 'secondary' | 'destructive' { + if (code === null) return 'secondary'; + if (code >= 200 && code < 300) return 'default'; + if (code >= 400 && code < 500) return 'secondary'; + return 'destructive'; +} + +function statusCodeLabel(code: number | null): string { + if (code === null) return 'N/A'; + return String(code); +} + +export function RequestDetailDialog({ requestId, open, onOpenChange }: RequestDetailDialogProps) { + const [tab, setTab] = useState('overview'); + const { data: item, isLoading } = useAdminRequestById(requestId); + + return ( + + + + Request Detail {requestId ? `#${requestId}` : ''} + + + {isLoading ? ( +
Loading...
+ ) : !item ? ( +
Request not found
+ ) : ( + + + Overview + Raw JSON + + + +
+
+
ID
+
{item.id}
+
+
+
Created At
+
{item.created_at}
+
+
+
User ID
+
{item.kilo_user_id ?? 'N/A'}
+
+
+
Organization ID
+
{item.organization_id ?? 'N/A'}
+
+
+
Provider
+
{item.provider ?? 'N/A'}
+
+
+
Model
+
{item.model ?? 'N/A'}
+
+
+
Status Code
+ + {statusCodeLabel(item.status_code)} + +
+
+ + {item.request !== null && item.request !== undefined && ( +
+
Request Body
+
+                    {JSON.stringify(item.request, null, 2)}
+                  
+
+ )} + + {item.response !== null && item.response !== undefined && ( +
+
Response
+
+                    {item.response}
+                  
+
+ )} +
+ + +
+                {JSON.stringify(item, null, 2)}
+              
+
+
+ )} +
+
+ ); +} diff --git a/src/app/admin/components/RequestsTable.tsx b/src/app/admin/components/RequestsTable.tsx new file mode 100644 index 000000000..d52b94a01 --- /dev/null +++ b/src/app/admin/components/RequestsTable.tsx @@ -0,0 +1,348 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Card, CardContent, CardDescription, 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 { ChevronLeft, ChevronRight, X } from 'lucide-react'; +import { useAdminRequests } from '@/app/admin/api/requests/hooks'; +import { RequestDetailDialog } from './RequestDetailDialog'; + +type SortOrder = 'asc' | 'desc'; + +function toSortedSearchParams(obj: Record): URLSearchParams { + const params = new URLSearchParams(); + const keys = Object.keys(obj).sort(); + for (const key of keys) { + const value = obj[key]; + if (value) params.set(key, String(value)); + } + return params; +} + +function statusCodeVariant(code: number | null): 'default' | 'secondary' | 'destructive' { + if (code === null) return 'secondary'; + if (code >= 200 && code < 300) return 'default'; + if (code >= 400 && code < 500) return 'secondary'; + return 'destructive'; +} + +export function RequestsTable() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const queryStringState = useMemo( + () => ({ + page: parseInt(searchParams.get('page') || '1'), + limit: parseInt(searchParams.get('limit') || '25'), + sortOrder: (searchParams.get('sortOrder') || 'desc') as SortOrder, + requestId: searchParams.get('requestId') || '', + startTime: searchParams.get('startTime') || '', + endTime: searchParams.get('endTime') || '', + query: searchParams.get('query') || '', + }), + [searchParams] + ); + + const [queryInput, setQueryInput] = useState(queryStringState.query); + const [requestIdInput, setRequestIdInput] = useState(queryStringState.requestId); + const [selectedRequestId, setSelectedRequestId] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + + // Sync local inputs when URL params change externally + useEffect(() => { + setQueryInput(queryStringState.query); + }, [queryStringState.query]); + + useEffect(() => { + setRequestIdInput(queryStringState.requestId); + }, [queryStringState.requestId]); + + const { data, isLoading, error, isFetching } = useAdminRequests({ + page: queryStringState.page, + limit: queryStringState.limit, + sortOrder: queryStringState.sortOrder, + requestId: queryStringState.requestId || undefined, + startTime: queryStringState.startTime || undefined, + endTime: queryStringState.endTime || undefined, + query: queryStringState.query || undefined, + }); + + type QueryStringState = typeof queryStringState; + + const pushWith = useCallback( + (overrides: Partial) => { + const queryString = toSortedSearchParams({ + ...queryStringState, + ...overrides, + }); + router.push(`/admin/requests?${queryString.toString()}`); + }, + [router, queryStringState] + ); + + // Debounced search + useEffect(() => { + const timer = setTimeout(() => { + if (queryInput !== queryStringState.query) { + pushWith({ query: queryInput, page: 1 }); + } + }, 500); + return () => clearTimeout(timer); + }, [queryInput, queryStringState.query, pushWith]); + + const handleRequestIdSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + pushWith({ requestId: requestIdInput, page: 1 }); + }, + [pushWith, requestIdInput] + ); + + const handleClearFilters = useCallback(() => { + setQueryInput(''); + setRequestIdInput(''); + pushWith({ query: '', requestId: '', startTime: '', endTime: '', page: 1 }); + }, [pushWith]); + + const handlePageChange = useCallback( + (page: number) => { + pushWith({ page }); + }, + [pushWith] + ); + + const handleRowClick = useCallback((id: string) => { + setSelectedRequestId(id); + setDialogOpen(true); + }, []); + + const handleDialogClose = useCallback((open: boolean) => { + setDialogOpen(open); + if (!open) { + setSelectedRequestId(null); + } + }, []); + + if (error) { + return ( + + + Error + Failed to load API requests + + +

+ {error instanceof Error ? error.message : 'An error occurred'} +

+
+
+ ); + } + + const items = data?.items ?? []; + const pagination = data?.pagination ?? { + page: 1, + limit: 25, + total: 0, + totalPages: 1, + }; + + return ( +
+ {/* Filters */} +
+
+ +
+ setQueryInput(e.target.value)} + className="pr-8" + /> + {queryInput && ( + + )} +
+
+ +
+
+ + setRequestIdInput(e.target.value)} + className="w-40" + /> +
+ +
+ +
+ + { + const val = e.target.value; + pushWith({ startTime: val ? new Date(val).toISOString() : '', page: 1 }); + }} + className="w-48" + /> +
+ +
+ + { + const val = e.target.value; + pushWith({ endTime: val ? new Date(val).toISOString() : '', page: 1 }); + }} + className="w-48" + /> +
+ + +
+ + {/* Table */} +
+ + + + ID + Created At + User ID + Provider + Model + Status + + + + {isLoading ? ( + + + Loading API requests... + + + ) : items.length === 0 ? ( + + + No API requests found. + + + ) : ( + items.map(item => ( + handleRowClick(item.id)} + > + {item.id} + + {new Date(item.created_at).toLocaleString()} + + + {item.kilo_user_id ?? '—'} + + {item.provider ?? '—'} + + {item.model ?? '—'} + + + {item.status_code !== null ? ( + + {item.status_code} + + ) : ( + + )} + + + )) + )} + +
+
+ + {/* Pagination */} +
+
+ Showing {items.length > 0 ? (pagination.page - 1) * pagination.limit + 1 : 0} to{' '} + {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '} + requests +
+
+ +
+ Page {pagination.page} of {pagination.totalPages} +
+ +
+
+ + +
+ ); +} diff --git a/src/app/admin/requests/page.tsx b/src/app/admin/requests/page.tsx new file mode 100644 index 000000000..978058264 --- /dev/null +++ b/src/app/admin/requests/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from 'react'; +import { RequestsTable } from '../components/RequestsTable'; +import AdminPage from '../components/AdminPage'; +import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb'; + +const breadcrumbs = ( + <> + + API Requests + + +); + +export default async function RequestsPage() { + return ( + + Loading API requests...}> + + + + ); +} diff --git a/src/routers/__tests__/admin-requests-router.test.ts b/src/routers/__tests__/admin-requests-router.test.ts new file mode 100644 index 000000000..bf4cd24e7 --- /dev/null +++ b/src/routers/__tests__/admin-requests-router.test.ts @@ -0,0 +1,166 @@ +import { describe, test, expect, beforeAll } from '@jest/globals'; +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { db } from '@/lib/drizzle'; +import { api_request_log } from '@/db/schema'; +import type { User } from '@/db/schema'; + +let regularUser: User; +let adminUser: User; + +beforeAll(async () => { + regularUser = await insertTestUser({ + google_user_email: `regular-requests-${Date.now()}@example.com`, + google_user_name: 'Regular User', + is_admin: false, + }); + + adminUser = await insertTestUser({ + google_user_email: `admin-requests-${Date.now()}@admin.example.com`, + google_user_name: 'Admin User', + is_admin: true, + }); + + // Insert some test records + await db.insert(api_request_log).values([ + { + kilo_user_id: adminUser.id, + organization_id: null, + provider: 'openai', + model: 'gpt-4', + status_code: 200, + request: { prompt: 'hello' }, + response: '{"text":"world"}', + }, + { + kilo_user_id: adminUser.id, + organization_id: 'org-123', + provider: 'anthropic', + model: 'claude-3', + status_code: 429, + request: { prompt: 'test' }, + response: '{"error":"rate limited"}', + }, + { + kilo_user_id: regularUser.id, + organization_id: null, + provider: 'openai', + model: 'gpt-3.5-turbo', + status_code: 500, + request: null, + response: null, + }, + ]); +}); + +describe('admin.requests.list', () => { + test('throws FORBIDDEN for non-admin users', async () => { + const caller = await createCallerForUser(regularUser.id); + + await expect(caller.admin.requests.list({})).rejects.toThrow('Admin access required'); + }); + + test('returns expected shape with items and pagination', async () => { + const caller = await createCallerForUser(adminUser.id); + const result = await caller.admin.requests.list({}); + + expect(result).toHaveProperty('items'); + expect(result).toHaveProperty('pagination'); + expect(Array.isArray(result.items)).toBe(true); + expect(result.pagination).toHaveProperty('page'); + expect(result.pagination).toHaveProperty('limit'); + expect(result.pagination).toHaveProperty('total'); + expect(result.pagination).toHaveProperty('totalPages'); + expect(result.items.length).toBeGreaterThanOrEqual(3); + }); + + test('items have string id field', async () => { + const caller = await createCallerForUser(adminUser.id); + const result = await caller.admin.requests.list({ limit: 1 }); + + expect(result.items.length).toBe(1); + expect(typeof result.items[0].id).toBe('string'); + }); + + test('filters by requestId (exact match)', async () => { + const caller = await createCallerForUser(adminUser.id); + const allResult = await caller.admin.requests.list({ limit: 1 }); + const targetId = allResult.items[0].id; + + const filtered = await caller.admin.requests.list({ requestId: targetId }); + expect(filtered.items.length).toBe(1); + expect(filtered.items[0].id).toBe(targetId); + }); + + test('filters by date range', async () => { + const caller = await createCallerForUser(adminUser.id); + + // Use a future date to get all records + const futureDate = new Date(Date.now() + 86400000).toISOString(); + const pastDate = new Date(Date.now() - 86400000).toISOString(); + + const result = await caller.admin.requests.list({ + startTime: pastDate, + endTime: futureDate, + }); + expect(result.items.length).toBeGreaterThanOrEqual(3); + + // Use a very old date range to get no records + const veryOld = '2000-01-01T00:00:00.000Z'; + const veryOldEnd = '2000-01-02T00:00:00.000Z'; + const emptyResult = await caller.admin.requests.list({ + startTime: veryOld, + endTime: veryOldEnd, + }); + expect(emptyResult.items.length).toBe(0); + }); + + test('filters by query search', async () => { + const caller = await createCallerForUser(adminUser.id); + + const result = await caller.admin.requests.list({ query: 'anthropic' }); + expect(result.items.length).toBeGreaterThanOrEqual(1); + expect(result.items.every(item => item.provider === 'anthropic')).toBe(true); + }); + + test('respects pagination', async () => { + const caller = await createCallerForUser(adminUser.id); + + const page1 = await caller.admin.requests.list({ page: 1, limit: 2 }); + expect(page1.items.length).toBe(2); + expect(page1.pagination.page).toBe(1); + expect(page1.pagination.limit).toBe(2); + + const page2 = await caller.admin.requests.list({ page: 2, limit: 2 }); + expect(page2.items.length).toBeGreaterThanOrEqual(1); + expect(page2.pagination.page).toBe(2); + }); +}); + +describe('admin.requests.getById', () => { + test('throws FORBIDDEN for non-admin users', async () => { + const caller = await createCallerForUser(regularUser.id); + + await expect(caller.admin.requests.getById({ id: '1' })).rejects.toThrow( + 'Admin access required' + ); + }); + + test('returns a record by id', async () => { + const caller = await createCallerForUser(adminUser.id); + const listResult = await caller.admin.requests.list({ limit: 1 }); + const targetId = listResult.items[0].id; + + const item = await caller.admin.requests.getById({ id: targetId }); + expect(item).not.toBeNull(); + expect(item?.id).toBe(targetId); + expect(item).toHaveProperty('created_at'); + expect(item).toHaveProperty('provider'); + }); + + test('returns null for non-existent id', async () => { + const caller = await createCallerForUser(adminUser.id); + const item = await caller.admin.requests.getById({ id: '999999999' }); + expect(item).toBeNull(); + }); +}); diff --git a/src/routers/admin-requests-router.ts b/src/routers/admin-requests-router.ts new file mode 100644 index 000000000..33d6b3bcb --- /dev/null +++ b/src/routers/admin-requests-router.ts @@ -0,0 +1,102 @@ +import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { db } from '@/lib/drizzle'; +import { api_request_log } from '@/db/schema'; +import * as z from 'zod'; +import { eq, and, or, ilike, desc, asc, count, gte, lte, sql, type SQL } from 'drizzle-orm'; + +const ListRequestsSchema = z.object({ + page: z.number().min(1).default(1), + limit: z.number().min(1).max(100).default(25), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + requestId: z.string().optional(), + startTime: z.string().optional(), + endTime: z.string().optional(), + query: z.string().optional(), +}); + +const GetByIdSchema = z.object({ + id: z.string(), +}); + +export const adminRequestsRouter = createTRPCRouter({ + list: adminProcedure.input(ListRequestsSchema).query(async ({ input }) => { + const { page, limit, sortOrder, requestId, startTime, endTime, query } = input; + + const conditions: SQL[] = []; + + if (requestId) { + conditions.push(eq(api_request_log.id, BigInt(requestId))); + } + + if (startTime) { + conditions.push(gte(api_request_log.created_at, startTime)); + } + + if (endTime) { + conditions.push(lte(api_request_log.created_at, endTime)); + } + + if (query) { + const searchTerm = `%${query}%`; + const searchCondition = or( + ilike(sql`${api_request_log.id}::text`, searchTerm), + ilike(api_request_log.kilo_user_id, searchTerm), + ilike(api_request_log.organization_id, searchTerm), + ilike(api_request_log.provider, searchTerm), + ilike(api_request_log.model, searchTerm) + ); + if (searchCondition) { + conditions.push(searchCondition); + } + } + + const whereCondition = conditions.length > 0 ? and(...conditions) : undefined; + const orderFunction = sortOrder === 'asc' ? asc : desc; + + const items = await db + .select() + .from(api_request_log) + .where(whereCondition) + .orderBy(orderFunction(api_request_log.created_at)) + .limit(limit) + .offset((page - 1) * limit); + + const totalCountResult = await db + .select({ count: count() }) + .from(api_request_log) + .where(whereCondition); + + const total = totalCountResult[0]?.count ?? 0; + const totalPages = Math.ceil(total / limit); + + return { + items: items.map(item => ({ + ...item, + id: item.id.toString(), + })), + pagination: { + page, + limit, + total, + totalPages, + }, + }; + }), + + getById: adminProcedure.input(GetByIdSchema).query(async ({ input }) => { + const [item] = await db + .select() + .from(api_request_log) + .where(eq(api_request_log.id, BigInt(input.id))) + .limit(1); + + if (!item) { + return null; + } + + return { + ...item, + id: item.id.toString(), + }; + }), +}); diff --git a/src/routers/admin-router.ts b/src/routers/admin-router.ts index fa684cb0c..37c50d8ff 100644 --- a/src/routers/admin-router.ts +++ b/src/routers/admin-router.ts @@ -17,6 +17,7 @@ import { adminCodeReviewsRouter } from '@/routers/admin-code-reviews-router'; import { adminAIAttributionRouter } from '@/routers/admin-ai-attribution-router'; import { ossSponsorshipRouter } from '@/routers/admin/oss-sponsorship-router'; import { bulkUserCreditsRouter } from '@/routers/admin/bulk-user-credits-router'; +import { adminRequestsRouter } from '@/routers/admin-requests-router'; import { adminWebhookTriggersRouter } from '@/routers/admin-webhook-triggers-router'; import { adminAlertingRouter } from '@/routers/admin-alerting-router'; import * as z from 'zod'; @@ -584,6 +585,8 @@ export const adminRouter = createTRPCRouter({ deployments: adminDeploymentsRouter, + requests: adminRequestsRouter, + alerting: adminAlertingRouter, featureInterest: adminFeatureInterestRouter,