diff --git a/docs/superpowers/specs/2026-04-04-admin-dashboard-design.md b/docs/superpowers/specs/2026-04-04-admin-dashboard-design.md new file mode 100644 index 0000000..e545561 --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-admin-dashboard-design.md @@ -0,0 +1,108 @@ +# Admin Dashboard Design Spec + +## Overview + +Add an admin web dashboard to the existing Agent Fox SPA, accessible at `/admin/*` routes. Provides real-time platform statistics, user management, project management, and MCP call log viewing. + +## Database Changes + +### User model additions +- `role`: enum `Role` (`USER` | `ADMIN`), default `USER` +- `disabled`: Boolean, default `false` + +### New model: McpCallLog +| Field | Type | Description | +|-------|------|-------------| +| id | String (UUID) | Primary key | +| projectId | String (FK) | Reference to Project | +| toolName | String | MCP tool name called | +| calledAt | DateTime | Timestamp | +| durationMs | Int | Response time in ms | +| success | Boolean | Whether call succeeded | +| requestParams | Json | Request parameters | +| responseSize | Int | Response size in bytes | +| clientIp | String | Caller IP address | +| estimatedTokens | Int? | Estimated token consumption | + +Indexes: `projectId`, `calledAt`, `toolName` + +## Backend API + +### New middleware +- `requireAdmin`: verifies `role === 'ADMIN'` from JWT payload. Returns 403 if not admin. +- Login check: `disabled === true` users get 403 on login. + +### New routes (`/api/admin/`) +| Method | Path | Description | +|--------|------|-------------| +| GET | /stats | Aggregate stats (user count, project count, call count, today's active) | +| GET | /stats/trends | Time-series data (7d/30d call trends) | +| GET | /users | Paginated user list with search/sort | +| GET | /users/:id | User detail + their projects | +| PATCH | /users/:id/disable | Toggle user disabled status | +| GET | /projects | Global paginated project list | +| GET | /projects/:id | Project detail | +| DELETE | /projects/:id | Delete project | +| GET | /call-logs | Paginated call logs with filters | + +### MCP call logging +In `packages/mcp`, wrap each tool handler to record a `McpCallLog` entry with timing, params, success/failure, response size, client IP, and token estimate. + +## Frontend + +### Routes +``` +/admin → Dashboard (stats overview) +/admin/users → User management list +/admin/users/:id → User detail +/admin/projects → Project management list +/admin/projects/:id → Project detail +/admin/logs → Call log viewer +``` + +### AdminLayout +- Left sidebar (200px): nav links for Dashboard / Users / Projects / Logs +- Top header: reuse theme toggle + user menu from existing Layout +- Route guard: redirect non-admin users to `/dashboard` +- Separate from existing `Layout.tsx`, parallel structure + +### Dashboard page +| Card | Content | +|------|---------| +| Registered Users | Total + today's new | +| Projects | Total + today's new | +| MCP Calls | Total + today's calls | +| Active Users (7d) | Users with activity in past 7 days | +| Avg Response Time | Mean durationMs of MCP calls | +| Success Rate | Percentage of successful calls | + +Below cards: 7-day call trend chart + recent calls table. + +### User Management +- Table: name, email, role, projects count, created date, status (active/disabled) +- Search by name/email +- Actions: view detail, toggle disable +- Detail page: user info + list of their projects + +### Project Management +- Table: name, owner, endpoints count, modules count, created date +- Search by name +- Actions: view detail, delete (with confirmation) +- Detail page: project info, modules, endpoints summary + +### Call Logs +- Table: time, project name, tool name, duration, success, client IP +- Filters: project, tool name, date range, success/failure +- Pagination + +## Auth Flow +- JWT payload adds `role` field +- Frontend stores role in auth context +- Admin nav entry only visible to admin users +- Non-admin accessing `/admin/*` → redirect to `/dashboard` + +## Tech Stack (frontend) +- Same React 19 + React Router 7 + Tailwind CSS v4 +- Reuse existing custom components (Badge, Modal, ConfirmDialog, etc.) +- Charts: lightweight solution (CSS-based or small chart lib) +- No new component library diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 6ba356b..57efcc0 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -40,7 +40,9 @@ app.post('/mcp/:projectId', mcpAuth, async (req, res) => { } }; - const server = createMcpServer(projectId); + const forwarded = req.headers['x-forwarded-for'] as string | undefined; + const clientIp = forwarded?.split(',')[0].trim() || req.socket.remoteAddress || ''; + const server = createMcpServer(projectId, clientIp); await server.connect(transport); await transport.handleRequest(req, res, req.body); }); diff --git a/packages/mcp/src/lib/call-logger.ts b/packages/mcp/src/lib/call-logger.ts new file mode 100644 index 0000000..ee1ebc7 --- /dev/null +++ b/packages/mcp/src/lib/call-logger.ts @@ -0,0 +1,45 @@ +import { prisma } from '@agent-fox/shared'; + +type CallContext = { + projectId: string; + toolName: string; + requestParams: Record; + clientIp: string; +}; + +export async function logMcpCall(ctx: CallContext, fn: () => Promise): Promise { + const start = Date.now(); + let success = true; + let result: any; + + try { + result = await fn(); + if (result?.isError) success = false; + return result; + } catch (err) { + success = false; + throw err; + } finally { + const durationMs = Date.now() - start; + const responseText = result ? JSON.stringify(result) : ''; + const responseSize = Buffer.byteLength(responseText, 'utf-8'); + // Rough token estimate: ~4 chars per token + const estimatedTokens = Math.ceil(responseText.length / 4); + + // Fire-and-forget: don't block the response + prisma.mcpCallLog.create({ + data: { + projectId: ctx.projectId, + toolName: ctx.toolName, + durationMs, + success, + requestParams: ctx.requestParams as any, + responseSize, + clientIp: ctx.clientIp, + estimatedTokens, + }, + }).catch((err) => { + console.error('Failed to log MCP call:', err); + }); + } +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b927835..170eb35 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -5,39 +5,43 @@ import { listModules } from './tools/list-modules.js'; import { listEndpoints } from './tools/list-endpoints.js'; import { getEndpointDetail } from './tools/get-endpoint-detail.js'; import { searchEndpoints } from './tools/search-endpoints.js'; +import { logMcpCall } from './lib/call-logger.js'; -export function createMcpServer(projectId: string): McpServer { +export function createMcpServer(projectId: string, clientIp: string = ''): McpServer { const server = new McpServer({ name: 'agent-fox', version: '0.1.0', }); + const ctx = (toolName: string, requestParams: Record = {}) => + ({ projectId, toolName, requestParams, clientIp }); + server.tool( 'get_project_overview', 'Get an overview of this API project including its name, version, base URL, and a summary of available modules with endpoint counts. Call this first to understand what the API offers.', {}, - async () => getProjectOverview(projectId), + async () => logMcpCall(ctx('get_project_overview'), () => getProjectOverview(projectId)), ); server.tool( 'list_modules', 'List all API modules/groups with their descriptions. Each module contains related endpoints. Use this when you need module descriptions to decide which module to explore.', {}, - async () => listModules(projectId), + async () => logMcpCall(ctx('list_modules'), () => listModules(projectId)), ); server.tool( 'list_endpoints', 'List all endpoints in a specific module. Returns method, path, and summary for each endpoint. Use get_endpoint_detail to get full information about a specific endpoint.', { moduleId: z.string().describe('The module ID to list endpoints for. Get module IDs from get_project_overview or list_modules.') }, - async ({ moduleId }) => listEndpoints(projectId, moduleId), + async ({ moduleId }) => logMcpCall(ctx('list_endpoints', { moduleId }), () => listEndpoints(projectId, moduleId)), ); server.tool( 'get_endpoint_detail', 'Get complete details for a specific endpoint including parameters, request body schema, response schemas. Use this when you need to understand exactly how to call an endpoint.', { endpointId: z.string().describe('The endpoint ID. Get endpoint IDs from list_endpoints or search_endpoints.') }, - async ({ endpointId }) => getEndpointDetail(projectId, endpointId), + async ({ endpointId }) => logMcpCall(ctx('get_endpoint_detail', { endpointId }), () => getEndpointDetail(projectId, endpointId)), ); server.tool( @@ -47,7 +51,7 @@ export function createMcpServer(projectId: string): McpServer { keyword: z.string().describe('Search keyword to match against endpoint path, summary, description, and operationId.'), moduleId: z.string().optional().describe('Optional module ID to limit search scope. Omit to search all modules.'), }, - async ({ keyword, moduleId }) => searchEndpoints(projectId, keyword, moduleId), + async ({ keyword, moduleId }) => logMcpCall(ctx('search_endpoints', { keyword, moduleId }), () => searchEndpoints(projectId, keyword, moduleId)), ); return server; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index bdc5784..e8e1a79 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -7,6 +7,7 @@ import importRouter from './routes/import.js'; import moduleRouter from './routes/modules.js'; import endpointRouter from './routes/endpoints.js'; import fetchSpecRouter from './routes/fetch-spec.js'; +import adminRouter from './routes/admin.js'; const app = express(); app.use(cors()); @@ -24,6 +25,7 @@ app.use('/api/projects', projectRouter); app.use('/api/projects', importRouter); app.use('/api/projects', moduleRouter); app.use('/api/projects', endpointRouter); +app.use('/api/admin', adminRouter); const port = process.env.SERVER_PORT || 3000; app.listen(port, () => { diff --git a/packages/server/src/lib/jwt.ts b/packages/server/src/lib/jwt.ts index d9b1d40..a675f5c 100644 --- a/packages/server/src/lib/jwt.ts +++ b/packages/server/src/lib/jwt.ts @@ -1,4 +1,5 @@ import jwt from 'jsonwebtoken'; +import type { Role } from '@agent-fox/shared'; const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret'; const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret'; @@ -8,6 +9,7 @@ const REFRESH_EXPIRY = '7d'; export type TokenPayload = { userId: string; email: string; + role: Role; }; export function generateAccessToken(payload: TokenPayload): string { diff --git a/packages/server/src/middleware/admin.ts b/packages/server/src/middleware/admin.ts new file mode 100644 index 0000000..f5c2d8a --- /dev/null +++ b/packages/server/src/middleware/admin.ts @@ -0,0 +1,9 @@ +import type { Request, Response, NextFunction } from 'express'; + +export function requireAdmin(req: Request, res: Response, next: NextFunction): void { + if (!req.user || req.user.role !== 'ADMIN') { + res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: 'Admin access required' } }); + return; + } + next(); +} diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts new file mode 100644 index 0000000..61c78ca --- /dev/null +++ b/packages/server/src/routes/admin.ts @@ -0,0 +1,305 @@ +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { prisma } from '@agent-fox/shared'; +import { requireAuth } from '../middleware/auth.js'; +import { requireAdmin } from '../middleware/admin.js'; + +const router: RouterType = Router(); + +router.use(requireAuth, requireAdmin); + +function parsePagination(query: Record, defaultLimit = 20) { + return { + page: Math.max(1, parseInt(query.page as string) || 1), + limit: Math.min(100, Math.max(1, parseInt(query.limit as string) || defaultLimit)), + }; +} + +// ─── Dashboard Stats ──────────────────────────────────────────────── + +router.get('/stats', async (_req, res) => { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const sevenDaysAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000); + + const [ + totalUsers, + todayUsers, + totalProjects, + todayProjects, + totalCalls, + todayCalls, + callStats, + successCount, + activeProjectUsers, + ] = await Promise.all([ + prisma.user.count(), + prisma.user.count({ where: { createdAt: { gte: todayStart } } }), + prisma.project.count(), + prisma.project.count({ where: { createdAt: { gte: todayStart } } }), + prisma.mcpCallLog.count(), + prisma.mcpCallLog.count({ where: { calledAt: { gte: todayStart } } }), + prisma.mcpCallLog.aggregate({ + _avg: { durationMs: true }, + _count: { id: true }, + where: { calledAt: { gte: sevenDaysAgo } }, + }), + prisma.mcpCallLog.count({ + where: { calledAt: { gte: sevenDaysAgo }, success: true }, + }), + prisma.mcpCallLog.groupBy({ + by: ['projectId'], + where: { calledAt: { gte: sevenDaysAgo } }, + }), + ]); + + // Resolve unique user IDs from active projects + const activeProjectIds = activeProjectUsers.map(g => g.projectId); + let activeUserCount = 0; + if (activeProjectIds.length > 0) { + activeUserCount = await prisma.project.groupBy({ + by: ['userId'], + where: { id: { in: activeProjectIds } }, + }).then(r => r.length); + } + + const recentTotal = callStats._count.id; + const successRate = recentTotal > 0 ? Math.round((successCount / recentTotal) * 100) : 100; + + res.json({ + success: true, + data: { + totalUsers, + todayUsers, + totalProjects, + todayProjects, + totalCalls, + todayCalls, + avgResponseTime: Math.round(callStats._avg.durationMs ?? 0), + successRate, + activeUsers: activeUserCount, + }, + }); +}); + +// ─── Trends (7d / 30d) ───────────────────────────────────────────── + +router.get('/stats/trends', async (req, res) => { + const days = req.query.days === '30' ? 30 : 7; + const since = new Date(); + since.setDate(since.getDate() - days); + since.setHours(0, 0, 0, 0); + + const rows = await prisma.$queryRaw< + { date: string; total: bigint; success_count: bigint; avg_duration: number }[] + >` + SELECT + TO_CHAR("calledAt", 'YYYY-MM-DD') AS date, + COUNT(*)::bigint AS total, + SUM(CASE WHEN success THEN 1 ELSE 0 END)::bigint AS success_count, + COALESCE(AVG("durationMs"), 0)::int AS avg_duration + FROM "McpCallLog" + WHERE "calledAt" >= ${since} + GROUP BY TO_CHAR("calledAt", 'YYYY-MM-DD') + ORDER BY date + `; + + // Build full date range with zeros for missing days + const dataMap = new Map(rows.map(r => [r.date, r])); + const trends = []; + for (let i = 0; i < days; i++) { + const d = new Date(since); + d.setDate(d.getDate() + i); + const key = d.toISOString().slice(0, 10); + const row = dataMap.get(key); + const total = row ? Number(row.total) : 0; + const successCnt = row ? Number(row.success_count) : 0; + trends.push({ + date: key, + calls: total, + successRate: total > 0 ? Math.round((successCnt / total) * 100) : 100, + avgDuration: row ? row.avg_duration : 0, + }); + } + + res.json({ success: true, data: trends }); +}); + +// ─── User Management ──────────────────────────────────────────────── + +router.get('/users', async (req, res) => { + const { page, limit } = parsePagination(req.query); + const search = (req.query.search as string) || ''; + const sortBy = (req.query.sortBy as string) || 'createdAt'; + const order = req.query.order === 'asc' ? 'asc' as const : 'desc' as const; + + const where = search + ? { OR: [{ name: { contains: search, mode: 'insensitive' as const } }, { email: { contains: search, mode: 'insensitive' as const } }] } + : {}; + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + select: { + id: true, email: true, name: true, role: true, disabled: true, createdAt: true, avatarUrl: true, + _count: { select: { projects: true } }, + }, + orderBy: { [sortBy]: order }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.user.count({ where }), + ]); + + res.json({ success: true, data: { users, total, page, limit } }); +}); + +router.get('/users/:id', async (req, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.params.id }, + select: { + id: true, email: true, name: true, role: true, disabled: true, createdAt: true, avatarUrl: true, + oauthAccounts: { select: { provider: true, createdAt: true } }, + projects: { + select: { id: true, name: true, description: true, createdAt: true, _count: { select: { endpoints: true, modules: true } } }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!user) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } }); + return; + } + + res.json({ success: true, data: user }); +}); + +const disableSchema = z.object({ disabled: z.boolean() }); + +router.patch('/users/:id/disable', async (req, res) => { + const parsed = disableSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Invalid request body' } }); + return; + } + + if (req.params.id === req.user!.userId) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Cannot disable your own account' } }); + return; + } + + const user = await prisma.user.update({ + where: { id: req.params.id }, + data: { disabled: parsed.data.disabled }, + select: { id: true, disabled: true }, + }); + + res.json({ success: true, data: user }); +}); + +// ─── Project Management ───────────────────────────────────────────── + +router.get('/projects', async (req, res) => { + const { page, limit } = parsePagination(req.query); + const search = (req.query.search as string) || ''; + + const where = search + ? { name: { contains: search, mode: 'insensitive' as const } } + : {}; + + const [projects, total] = await Promise.all([ + prisma.project.findMany({ + where, + select: { + id: true, name: true, description: true, openApiVersion: true, createdAt: true, + user: { select: { id: true, name: true, email: true } }, + _count: { select: { endpoints: true, modules: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.project.count({ where }), + ]); + + res.json({ success: true, data: { projects, total, page, limit } }); +}); + +router.get('/projects/:id', async (req, res) => { + const project = await prisma.project.findUnique({ + where: { id: req.params.id }, + select: { + id: true, name: true, description: true, baseUrl: true, openApiVersion: true, createdAt: true, updatedAt: true, + user: { select: { id: true, name: true, email: true } }, + modules: { select: { id: true, name: true, description: true, source: true, _count: { select: { endpoints: true } } }, orderBy: { sortOrder: 'asc' } }, + _count: { select: { endpoints: true, modules: true, mcpCallLogs: true } }, + }, + }); + + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + + res.json({ success: true, data: project }); +}); + +router.delete('/projects/:id', async (req, res) => { + await prisma.project.delete({ where: { id: req.params.id } }); + res.json({ success: true, data: { message: 'Project deleted' } }); +}); + +// ─── Call Logs ────────────────────────────────────────────────────── + +router.get('/call-logs', async (req, res) => { + const { page, limit } = parsePagination(req.query, 30); + const projectId = req.query.projectId as string | undefined; + const toolName = req.query.toolName as string | undefined; + const success = req.query.success as string | undefined; + const dateStart = req.query.dateStart as string | undefined; + const dateEnd = req.query.dateEnd as string | undefined; + + const where: any = {}; + if (projectId) where.projectId = projectId; + if (toolName) where.toolName = toolName; + if (success === 'true') where.success = true; + if (success === 'false') where.success = false; + if (dateStart || dateEnd) { + where.calledAt = {}; + if (dateStart) where.calledAt.gte = new Date(dateStart); + if (dateEnd) where.calledAt.lte = new Date(dateEnd); + } + + const [logs, total] = await Promise.all([ + prisma.mcpCallLog.findMany({ + where, + select: { + id: true, toolName: true, calledAt: true, durationMs: true, success: true, + responseSize: true, clientIp: true, estimatedTokens: true, + project: { select: { id: true, name: true } }, + }, + orderBy: { calledAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.mcpCallLog.count({ where }), + ]); + + res.json({ success: true, data: { logs, total, page, limit } }); +}); + +router.get('/call-logs/recent', async (_req, res) => { + const logs = await prisma.mcpCallLog.findMany({ + select: { + id: true, toolName: true, calledAt: true, durationMs: true, success: true, + project: { select: { name: true } }, + }, + orderBy: { calledAt: 'desc' }, + take: 10, + }); + + res.json({ success: true, data: logs }); +}); + +export default router; diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index f3af704..36a4fb1 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -40,8 +40,8 @@ router.post('/register', async (req, res) => { data: { email, passwordHash, name }, }); - const tokens = generateTokenPair({ userId: user.id, email: user.email }); - res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } }); + const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role }); + res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name, role: user.role }, ...tokens } }); }); router.post('/login', async (req, res) => { @@ -59,14 +59,19 @@ router.post('/login', async (req, res) => { return; } + if (user.disabled) { + res.status(403).json({ success: false, error: { code: 'DISABLED', message: 'Account has been disabled' } }); + return; + } + const valid = await verifyPassword(password, user.passwordHash); if (!valid) { res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }); return; } - const tokens = generateTokenPair({ userId: user.id, email: user.email }); - res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } }); + const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role }); + res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name, role: user.role }, ...tokens } }); }); router.post('/refresh', async (req, res) => { @@ -83,7 +88,11 @@ router.post('/refresh', async (req, res) => { res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } }); return; } - const tokens = generateTokenPair({ userId: user.id, email: user.email }); + if (user.disabled) { + res.status(403).json({ success: false, error: { code: 'DISABLED', message: 'Account has been disabled' } }); + return; + } + const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role }); res.json({ success: true, data: tokens }); } catch { res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } }); @@ -170,7 +179,7 @@ router.put('/profile', requireAuth, async (req, res) => { router.get('/me', requireAuth, async (req, res) => { const user = await prisma.user.findUnique({ where: { id: req.user!.userId }, - select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true }, + select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true, role: true }, }); if (!user) { res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } }); diff --git a/packages/server/src/routes/oauth.ts b/packages/server/src/routes/oauth.ts index 767dca6..ac93598 100644 --- a/packages/server/src/routes/oauth.ts +++ b/packages/server/src/routes/oauth.ts @@ -72,7 +72,11 @@ async function handleOAuthCallback( } const user = await findOrCreateUser(provider, providerUser); - const tokens = generateTokenPair({ userId: user.id, email: user.email }); + if (user.disabled) { + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Account has been disabled')}`); + return; + } + const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role }); const redirectParam = stateResult.redirect ? `&redirect=${encodeURIComponent(stateResult.redirect)}` : ''; res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 1e3e830..0670d20 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,6 +1,6 @@ -import type { User, Project, Module, Endpoint, ModuleSource } from '@prisma/client'; +import type { User, Project, Module, Endpoint, ModuleSource, Role } from '@prisma/client'; -export type { User, Project, Module, Endpoint, ModuleSource }; +export type { User, Project, Module, Endpoint, ModuleSource, Role }; export type ApiResponse = { success: boolean; diff --git a/packages/web/nginx.conf b/packages/web/nginx.conf index 66104fa..5763509 100644 --- a/packages/web/nginx.conf +++ b/packages/web/nginx.conf @@ -7,12 +7,14 @@ server { proxy_pass http://server:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /mcp/ { proxy_pass http://mcp:3001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index dfc71ee..c6532fc 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -9,6 +9,14 @@ import Layout from './pages/Layout'; import Projects from './pages/Projects'; import ProjectDetail from './pages/ProjectDetail'; import LandingPage from './pages/landing/LandingPage'; +import AdminLayout from './pages/admin/AdminLayout'; +import AdminDashboard from './pages/admin/Dashboard'; +import AdminUsers from './pages/admin/Users'; +import AdminUserDetail from './pages/admin/UserDetail'; +import AdminProjects from './pages/admin/Projects'; +import AdminProjectDetail from './pages/admin/ProjectDetail'; +import AdminCallLogs from './pages/admin/CallLogs'; + const queryClient = new QueryClient(); export default function App() { @@ -26,6 +34,14 @@ export default function App() { } /> } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 67af34c..ecf9d87 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -302,6 +302,13 @@ body { &::placeholder { color: var(--text-muted); } &:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-muted); } } + select.input-base { + @apply pr-10 appearance-none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.25rem 1.25rem; + } .card { @apply rounded-xl transition-all duration-200; background: var(--bg-elevated); diff --git a/packages/web/src/lib/auth.tsx b/packages/web/src/lib/auth.tsx index e06649b..ec07b1e 100644 --- a/packages/web/src/lib/auth.tsx +++ b/packages/web/src/lib/auth.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; import { getAccessToken, clearTokens, setTokens, apiFetch } from './api'; -type User = { id: string; email: string; name: string; hasPassword?: boolean }; +type User = { id: string; email: string; name: string; hasPassword?: boolean; role?: string }; type AuthContextType = { user: User | null; diff --git a/packages/web/src/pages/Layout.tsx b/packages/web/src/pages/Layout.tsx index 44167d1..88df5e1 100644 --- a/packages/web/src/pages/Layout.tsx +++ b/packages/web/src/pages/Layout.tsx @@ -17,7 +17,7 @@ type ProjectSummary = { _count: { endpoints: number; modules: number }; }; -function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string }; logout: () => void; onOpenSettings: () => void }) { +function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string; role?: string }; logout: () => void; onOpenSettings: () => void }) { const [open, setOpen] = useState(false); const [confirmLogout, setConfirmLogout] = useState(false); const ref = useRef(null); @@ -67,6 +67,19 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; {/* Actions */}
+ {user.role === 'ADMIN' && ( + setOpen(false)} + className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-accent hover:bg-accent-muted transition-colors mx-1" + style={{ width: 'calc(100% - 8px)' }} + > + + + + Admin 后台 + + )} +

Admin

+
+
+ + 返回主站 + + +
+ + + {/* Page content */} +
+ +
+ + + ); +} + +function SidebarContent({ + initials, user, logout, onNavClick, +}: { + initials: string; + user: { name: string; email: string }; + logout: () => void; + onNavClick?: () => void; +}) { + return ( + <> + {/* Brand */} +
+ +
+ + + +
+ Agent Fox + Admin + +
+ + {/* Nav */} + + + {/* User section at bottom */} +
+
+
+ {initials} +
+
+
{user.name}
+
{user.email}
+
+
+ +
+ + ); +} diff --git a/packages/web/src/pages/admin/CallLogs.tsx b/packages/web/src/pages/admin/CallLogs.tsx new file mode 100644 index 0000000..60fdb90 --- /dev/null +++ b/packages/web/src/pages/admin/CallLogs.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '../../lib/api'; + +type CallLogItem = { + id: string; + toolName: string; + calledAt: string; + durationMs: number; + success: boolean; + responseSize: number; + clientIp: string; + estimatedTokens: number | null; + project: { id: string; name: string }; +}; + +type CallLogsResponse = { + logs: CallLogItem[]; + total: number; + page: number; + limit: number; +}; + +const TOOL_NAMES = [ + 'get_project_overview', + 'list_modules', + 'list_endpoints', + 'get_endpoint_detail', + 'search_endpoints', +]; + +export default function CallLogs() { + const [page, setPage] = useState(1); + const [toolName, setToolName] = useState(''); + const [successFilter, setSuccessFilter] = useState(''); + const limit = 30; + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'call-logs', page, toolName, successFilter], + queryFn: () => { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (toolName) params.set('toolName', toolName); + if (successFilter) params.set('success', successFilter); + return apiFetch(`/admin/call-logs?${params}`); + }, + }); + + const totalPages = data ? Math.ceil(data.total / limit) : 0; + + return ( +
+ {/* Header */} +
+

调用日志

+

共 {data?.total ?? 0} 条记录

+
+ + {/* Filters */} +
+ + + {(toolName || successFilter) && ( + + )} +
+ + {/* Table */} +
+
+ + + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( + + {Array.from({ length: 8 }).map((_, j) => ( + + ))} + + )) + ) : data?.logs.length === 0 ? ( + + + + ) : ( + data?.logs.map((log) => ( + + + + + + + + + + + )) + )} + +
时间项目工具耗时响应大小Token客户端 IP状态
暂无调用日志
+ {new Date(log.calledAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })} + {log.project.name} + {log.toolName} + + 1000 ? 'text-warning' : 'text-text-secondary'}`}> + {log.durationMs}ms + + + {formatBytes(log.responseSize)} + + {log.estimatedTokens != null ? log.estimatedTokens.toLocaleString() : '—'} + {log.clientIp || '—'} + + + {log.success ? '成功' : '失败'} + +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ 第 {page} / {totalPages} 页 +
+ + +
+
+ )} +
+ ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} diff --git a/packages/web/src/pages/admin/Dashboard.tsx b/packages/web/src/pages/admin/Dashboard.tsx new file mode 100644 index 0000000..bc92f10 --- /dev/null +++ b/packages/web/src/pages/admin/Dashboard.tsx @@ -0,0 +1,243 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '../../lib/api'; + +type Stats = { + totalUsers: number; + todayUsers: number; + totalProjects: number; + todayProjects: number; + totalCalls: number; + todayCalls: number; + avgResponseTime: number; + successRate: number; + activeUsers: number; +}; + +type TrendPoint = { + date: string; + calls: number; + successRate: number; + avgDuration: number; +}; + +type RecentCall = { + id: string; + toolName: string; + calledAt: string; + durationMs: number; + success: boolean; + project: { name: string }; +}; + +export default function Dashboard() { + const { data: stats, isLoading: statsLoading } = useQuery({ + queryKey: ['admin', 'stats'], + queryFn: () => apiFetch('/admin/stats'), + refetchInterval: 30000, + }); + + const { data: trends } = useQuery({ + queryKey: ['admin', 'trends'], + queryFn: () => apiFetch('/admin/stats/trends'), + refetchInterval: 60000, + }); + + const { data: recentCalls } = useQuery({ + queryKey: ['admin', 'recent-calls'], + queryFn: () => apiFetch('/admin/call-logs/recent'), + refetchInterval: 15000, + }); + + if (statsLoading) { + return ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + const statCards = [ + { + label: '注册用户', + value: stats?.totalUsers ?? 0, + sub: `今日 +${stats?.todayUsers ?? 0}`, + icon: ( + + + + ), + color: 'text-blue-500', + bg: 'bg-blue-500/10', + }, + { + label: '项目数', + value: stats?.totalProjects ?? 0, + sub: `今日 +${stats?.todayProjects ?? 0}`, + icon: ( + + + + ), + color: 'text-amber-500', + bg: 'bg-amber-500/10', + }, + { + label: 'MCP 调用', + value: stats?.totalCalls ?? 0, + sub: `今日 ${stats?.todayCalls ?? 0} 次`, + icon: ( + + + + ), + color: 'text-emerald-500', + bg: 'bg-emerald-500/10', + }, + { + label: '活跃用户 (7天)', + value: stats?.activeUsers ?? 0, + sub: '有 MCP 调用', + icon: ( + + + + + ), + color: 'text-violet-500', + bg: 'bg-violet-500/10', + }, + { + label: '平均响应时间', + value: `${stats?.avgResponseTime ?? 0}ms`, + sub: '近 7 天', + icon: ( + + + + ), + color: 'text-cyan-500', + bg: 'bg-cyan-500/10', + }, + { + label: '调用成功率', + value: `${stats?.successRate ?? 100}%`, + sub: '近 7 天', + icon: ( + + + + ), + color: (stats?.successRate ?? 100) >= 95 ? 'text-emerald-500' : 'text-amber-500', + bg: (stats?.successRate ?? 100) >= 95 ? 'bg-emerald-500/10' : 'bg-amber-500/10', + }, + ]; + + const maxCalls = Math.max(...(trends?.map(t => t.calls) ?? [1]), 1); + + return ( +
+ {/* Stats Grid */} +
+ {statCards.map((card) => ( +
+
+ {card.icon} +
+
+ {typeof card.value === 'number' ? card.value.toLocaleString() : card.value} +
+
+ {card.label} + {card.sub} +
+
+ ))} +
+ + {/* Trend Chart + Recent Calls */} +
+ {/* Trend Chart */} +
+

7 天调用趋势

+ {trends && trends.length > 0 ? ( +
+ {trends.map((point) => { + const height = maxCalls > 0 ? (point.calls / maxCalls) * 100 : 0; + return ( +
+ {/* Tooltip */} +
+
+
{point.calls} 次调用
+
成功率 {point.successRate}%
+
均耗时 {point.avgDuration}ms
+
+
+ {/* Bar */} +
+
+
+ {point.date.slice(5)} +
+ ); + })} +
+ ) : ( +
+ 暂无调用数据 +
+ )} +
+ + {/* Recent Calls */} +
+

最近调用

+ {recentCalls && recentCalls.length > 0 ? ( +
+ {recentCalls.map((call) => ( +
+
+
+
+ {call.toolName} + · {call.durationMs}ms +
+
{call.project.name}
+
+ + {formatTimeAgo(call.calledAt)} + +
+ ))} +
+ ) : ( +
+ 暂无调用记录 +
+ )} +
+
+
+ ); +} + +function formatTimeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return '刚刚'; + if (mins < 60) return `${mins}分钟前`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}小时前`; + return `${Math.floor(hours / 24)}天前`; +} diff --git a/packages/web/src/pages/admin/ProjectDetail.tsx b/packages/web/src/pages/admin/ProjectDetail.tsx new file mode 100644 index 0000000..ccbb8f2 --- /dev/null +++ b/packages/web/src/pages/admin/ProjectDetail.tsx @@ -0,0 +1,152 @@ +import { useParams, Link } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiFetch } from '../../lib/api'; +import ConfirmDialog from '../../components/ConfirmDialog'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +type ProjectDetailData = { + id: string; + name: string; + description: string | null; + baseUrl: string | null; + openApiVersion: string; + createdAt: string; + updatedAt: string; + user: { id: string; name: string; email: string }; + modules: { + id: string; + name: string; + description: string | null; + source: string; + _count: { endpoints: number }; + }[]; + _count: { endpoints: number; modules: number; mcpCallLogs: number }; +}; + +export default function AdminProjectDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [confirmDelete, setConfirmDelete] = useState(false); + + const { data: project, isLoading } = useQuery({ + queryKey: ['admin', 'project', id], + queryFn: () => apiFetch(`/admin/projects/${id}`), + }); + + const deleteProject = useMutation({ + mutationFn: () => apiFetch(`/admin/projects/${id}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'projects'] }); + navigate('/admin/projects'); + }, + }); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (!project) { + return
项目不存在
; + } + + return ( +
+ {/* Breadcrumb */} +
+ 项目管理 + / + {project.name} +
+ + {/* Project Info */} +
+
+
+

{project.name}

+ {project.description &&

{project.description}

} +
+ +
+ +
+ + + {project.user.name} + + + + + +
+ +
+ + + +
+
+ + {/* Modules */} +
+
+

模块列表 ({project.modules.length})

+
+ {project.modules.length === 0 ? ( +
暂无模块
+ ) : ( +
+ {project.modules.map((mod) => ( +
+
+
+ {mod.name} + {mod.source} +
+ {mod.description &&
{mod.description}
} +
+ {mod._count.endpoints} 端点 +
+ ))} +
+ )} +
+ + deleteProject.mutate()} + onCancel={() => setConfirmDelete(false)} + /> +
+ ); +} + +function InfoItem({ label, value, mono, children }: { label: string; value?: string; mono?: boolean; children?: React.ReactNode }) { + return ( +
+
{label}
+ {children ||
{value}
} +
+ ); +} + +function StatBadge({ label, value }: { label: string; value: number }) { + return ( +
+
{value.toLocaleString()}
+
{label}
+
+ ); +} diff --git a/packages/web/src/pages/admin/Projects.tsx b/packages/web/src/pages/admin/Projects.tsx new file mode 100644 index 0000000..27df885 --- /dev/null +++ b/packages/web/src/pages/admin/Projects.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { apiFetch } from '../../lib/api'; + +type ProjectItem = { + id: string; + name: string; + description: string | null; + openApiVersion: string; + createdAt: string; + user: { id: string; name: string; email: string }; + _count: { endpoints: number; modules: number }; +}; + +type ProjectsResponse = { + projects: ProjectItem[]; + total: number; + page: number; + limit: number; +}; + +export default function AdminProjects() { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const limit = 20; + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'projects', page, search], + queryFn: () => { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (search) params.set('search', search); + return apiFetch(`/admin/projects?${params}`); + }, + }); + + const totalPages = data ? Math.ceil(data.total / limit) : 0; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setSearch(searchInput); + setPage(1); + }; + + return ( +
+ {/* Header */} +
+

项目管理

+

共 {data?.total ?? 0} 个项目

+
+ + {/* Search */} +
+ setSearchInput(e.target.value)} + placeholder="搜索项目名称..." + className="input-base max-w-xs" + /> + + {search && ( + + )} +
+ + {/* Table */} +
+
+ + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((_, j) => ( + + ))} + + )) + ) : data?.projects.length === 0 ? ( + + + + ) : ( + data?.projects.map((project) => ( + + + + + + + + + + )) + )} + +
项目所有者版本模块端点创建时间操作
无匹配项目
+
{project.name}
+ {project.description && ( +
{project.description}
+ )} +
+ + {project.user.name} + + + {project.openApiVersion} + {project._count.modules}{project._count.endpoints}{new Date(project.createdAt).toLocaleDateString('zh-CN')} + + 查看 + +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ 第 {page} / {totalPages} 页 +
+ + +
+
+ )} +
+ ); +} diff --git a/packages/web/src/pages/admin/UserDetail.tsx b/packages/web/src/pages/admin/UserDetail.tsx new file mode 100644 index 0000000..6c19cb7 --- /dev/null +++ b/packages/web/src/pages/admin/UserDetail.tsx @@ -0,0 +1,175 @@ +import { useParams, Link } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiFetch } from '../../lib/api'; +import { useAuth } from '../../lib/auth'; +import ConfirmDialog from '../../components/ConfirmDialog'; +import { useState } from 'react'; + +type UserDetailData = { + id: string; + email: string; + name: string; + role: string; + disabled: boolean; + createdAt: string; + avatarUrl: string | null; + oauthAccounts: { provider: string; createdAt: string }[]; + projects: { + id: string; + name: string; + description: string | null; + createdAt: string; + _count: { endpoints: number; modules: number }; + }[]; +}; + +export default function UserDetail() { + const { id } = useParams<{ id: string }>(); + const { user: currentUser } = useAuth(); + const queryClient = useQueryClient(); + const [confirmDisable, setConfirmDisable] = useState(false); + + const { data: user, isLoading } = useQuery({ + queryKey: ['admin', 'user', id], + queryFn: () => apiFetch(`/admin/users/${id}`), + }); + + const toggleDisable = useMutation({ + mutationFn: () => apiFetch(`/admin/users/${id}/disable`, { + method: 'PATCH', + body: JSON.stringify({ disabled: !user?.disabled }), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'user', id] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); + setConfirmDisable(false); + }, + }); + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + if (!user) { + return ( +
+ 用户不存在 +
+ ); + } + + const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2); + const isSelf = currentUser?.id === user.id; + + return ( +
+ {/* Breadcrumb */} +
+ 用户管理 + / + {user.name} +
+ + {/* User Info Card */} +
+
+
+
+ {initials} +
+
+

{user.name}

+
{user.email}
+
+ + {user.role} + + + + {user.disabled ? '已禁用' : '正常'} + +
+
+
+ + {!isSelf && ( + + )} +
+ +
+ + + a.provider).join(', ') || '无'} /> + +
+
+ + {/* Projects */} +
+
+

项目列表 ({user.projects.length})

+
+ {user.projects.length === 0 ? ( +
暂无项目
+ ) : ( +
+ {user.projects.map((project) => ( + +
+
{project.name}
+ {project.description &&
{project.description}
} +
+
+ {project._count.modules} 模块 + {project._count.endpoints} 端点 + {new Date(project.createdAt).toLocaleDateString('zh-CN')} +
+ + ))} +
+ )} +
+ + {/* Confirm Dialog */} + toggleDisable.mutate()} + onCancel={() => setConfirmDisable(false)} + /> +
+ ); +} + +function InfoItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/packages/web/src/pages/admin/Users.tsx b/packages/web/src/pages/admin/Users.tsx new file mode 100644 index 0000000..a8e0120 --- /dev/null +++ b/packages/web/src/pages/admin/Users.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { apiFetch } from '../../lib/api'; + +type UserItem = { + id: string; + email: string; + name: string; + role: string; + disabled: boolean; + createdAt: string; + avatarUrl: string | null; + _count: { projects: number }; +}; + +type UsersResponse = { + users: UserItem[]; + total: number; + page: number; + limit: number; +}; + +export default function Users() { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const limit = 20; + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'users', page, search], + queryFn: () => { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (search) params.set('search', search); + return apiFetch(`/admin/users?${params}`); + }, + }); + + const totalPages = data ? Math.ceil(data.total / limit) : 0; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setSearch(searchInput); + setPage(1); + }; + + return ( +
+ {/* Header */} +
+
+

用户管理

+

共 {data?.total ?? 0} 个用户

+
+
+ + {/* Search */} +
+ setSearchInput(e.target.value)} + placeholder="搜索用户名或邮箱..." + className="input-base max-w-xs" + /> + + {search && ( + + )} +
+ + {/* Table */} +
+
+ + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + )) + ) : data?.users.length === 0 ? ( + + + + ) : ( + data?.users.map((user) => ( + + + + + + + + + )) + )} + +
用户角色项目数注册时间状态操作
无匹配用户
+
+
+ {user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)} +
+
+
{user.name}
+
{user.email}
+
+
+
+ + {user.role} + + {user._count.projects}{new Date(user.createdAt).toLocaleDateString('zh-CN')} + + + {user.disabled ? '已禁用' : '正常'} + + + + 查看 + +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ 第 {page} / {totalPages} 页 +
+ + +
+
+ )} +
+ ); +} diff --git a/prisma/migrations/20260404120000_add_admin_role_and_call_logs/migration.sql b/prisma/migrations/20260404120000_add_admin_role_and_call_logs/migration.sql new file mode 100644 index 0000000..5994de8 --- /dev/null +++ b/prisma/migrations/20260404120000_add_admin_role_and_call_logs/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER'; +ALTER TABLE "User" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "McpCallLog" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "toolName" TEXT NOT NULL, + "calledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "durationMs" INTEGER NOT NULL, + "success" BOOLEAN NOT NULL, + "requestParams" JSONB NOT NULL DEFAULT '{}', + "responseSize" INTEGER NOT NULL DEFAULT 0, + "clientIp" TEXT NOT NULL DEFAULT '', + "estimatedTokens" INTEGER, + + CONSTRAINT "McpCallLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "McpCallLog_projectId_idx" ON "McpCallLog"("projectId"); + +-- CreateIndex +CREATE INDEX "McpCallLog_calledAt_idx" ON "McpCallLog"("calledAt"); + +-- CreateIndex +CREATE INDEX "McpCallLog_toolName_idx" ON "McpCallLog"("toolName"); + +-- AddForeignKey +ALTER TABLE "McpCallLog" ADD CONSTRAINT "McpCallLog_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 699a601..f1dcb10 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,12 +7,19 @@ datasource db { url = env("DATABASE_URL") } +enum Role { + USER + ADMIN +} + model User { id String @id @default(uuid()) email String @unique passwordHash String? name String avatarUrl String? + role Role @default(USER) + disabled Boolean @default(false) apiKeyHash String? apiKeyEncrypted String? apiKeyPrefix String? @@ -33,8 +40,26 @@ model OAuthAccount { @@unique([provider, providerAccountId]) } +model McpCallLog { + id String @id @default(uuid()) + projectId String + toolName String + calledAt DateTime @default(now()) + durationMs Int + success Boolean + requestParams Json @default("{}") + responseSize Int @default(0) + clientIp String @default("") + estimatedTokens Int? + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId]) + @@index([calledAt]) + @@index([toolName]) +} + model Project { - id String @id @default(uuid()) + id String @id @default(uuid()) userId String name String description String? @@ -43,9 +68,10 @@ model Project { openApiVersion String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) modules Module[] endpoints Endpoint[] + mcpCallLogs McpCallLog[] } enum ModuleSource {