Compare commits
2 Commits
f3fbd3876a
...
6fe04f4893
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe04f4893 | |||
| d45cc45815 |
16
CLAUDE.md
16
CLAUDE.md
@@ -38,7 +38,8 @@ pnpm monorepo with 4 packages sharing TypeScript config (`tsconfig.base.json`):
|
|||||||
### Data Flow
|
### Data Flow
|
||||||
|
|
||||||
1. User imports OpenAPI doc (JSON/YAML/URL) via web UI
|
1. User imports OpenAPI doc (JSON/YAML/URL) via web UI
|
||||||
2. Server validates with `@apidevtools/swagger-parser`, dereferences all `$ref`s
|
2. For URL imports, frontend fetches the content (supports localhost/intranet), falling back to server proxy `/api/fetch-spec` for CORS-blocked URLs. Server receives parsed spec objects only.
|
||||||
|
3. Server validates with `@apidevtools/swagger-parser`, dereferences all `$ref`s
|
||||||
3. Parses into Module (from tags or path prefixes) and Endpoint records in PostgreSQL
|
3. Parses into Module (from tags or path prefixes) and Endpoint records in PostgreSQL
|
||||||
4. User gets a project ID + API key
|
4. User gets a project ID + API key
|
||||||
5. LLM connects to MCP service at `/mcp/:projectId` with API key
|
5. LLM connects to MCP service at `/mcp/:projectId` with API key
|
||||||
@@ -58,9 +59,20 @@ The 5 tools are designed for minimal token usage per call (~200-2000 tokens each
|
|||||||
|
|
||||||
- **API responses**: All endpoints return `{ success: boolean, data?: T, error?: { code, message } }`
|
- **API responses**: All endpoints return `{ success: boolean, data?: T, error?: { code, message } }`
|
||||||
- **Auth**: User auth uses JWT dual-token (15min access + 7d refresh). MCP auth uses project API keys (`afk_` prefix, bcrypt hashed).
|
- **Auth**: User auth uses JWT dual-token (15min access + 7d refresh). MCP auth uses project API keys (`afk_` prefix, bcrypt hashed).
|
||||||
|
- **OAuth**: Google/GitHub via server-side flow. Redirect URL passed through OAuth state store. `validateState()` returns `{ valid, redirect }`. Apple login not yet implemented.
|
||||||
- **MCP SDK imports**: Use `@modelcontextprotocol/sdk/server/mcp.js` (not `@modelcontextprotocol/server`). Tool registration uses `server.tool(name, description, zodShape, handler)`.
|
- **MCP SDK imports**: Use `@modelcontextprotocol/sdk/server/mcp.js` (not `@modelcontextprotocol/server`). Tool registration uses `server.tool(name, description, zodShape, handler)`.
|
||||||
- **Swagger 2.0 + OpenAPI 3.x**: Parser handles both. For Swagger 2, body params are converted to requestBody format.
|
- **Swagger 2.0 + OpenAPI 3.x**: Parser handles both. For Swagger 2, body params are converted to requestBody format.
|
||||||
- **Docker dev mode**: Server/MCP use `deps` build stage + volume mounts for hot reload. Web uses Vite `build` stage. Shared must be built inside container before server/mcp start.
|
- **Docker dev mode**: Server/MCP use `build` stage + volume mounts for hot reload. Shared must be built inside container before server/mcp start.
|
||||||
|
- **Docker production**: Dockerfiles use `npm install` + global `tsc` (not pnpm — symlinks break on overlay2). `workspace:*` refs are replaced with `file:` refs via `sed`.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
- **Production**: Docker Compose on `ubuntu@43.130.35.66:/opt/1panel/apps/agentfox`. Deploy via `/deploy` skill.
|
||||||
|
- **Port mapping**: web=8088 (80 is OpenResty), server=3000, mcp=3001. PostgreSQL is internal only (no host port).
|
||||||
|
- **`.env`**: Local `.env` is the single source of truth, synced to server on deploy. `.env.example` must stay aligned.
|
||||||
|
- **Nginx**: Web container's nginx proxies `/api/` → server, `/mcp/` → mcp. External Nginx only needs to proxy to port 8088.
|
||||||
|
- **Migrations**: `scripts/migrate-and-start.sh` auto-runs `prisma migrate deploy` before server starts. Always create migrations for schema changes (`prisma migrate dev --name <name> --create-only`), never rely on `db push` alone.
|
||||||
|
- **Domain**: `www.agentfoxapp.com` — not hardcoded anywhere, configured via `OAUTH_CALLBACK_BASE_URL` and `FRONTEND_URL` env vars.
|
||||||
|
|
||||||
### Database (Prisma schema at `prisma/schema.prisma`)
|
### Database (Prisma schema at `prisma/schema.prisma`)
|
||||||
|
|
||||||
|
|||||||
108
docs/superpowers/specs/2026-04-04-admin-dashboard-design.md
Normal file
108
docs/superpowers/specs/2026-04-04-admin-dashboard-design.md
Normal file
@@ -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
|
||||||
@@ -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 server.connect(transport);
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
});
|
});
|
||||||
|
|||||||
45
packages/mcp/src/lib/call-logger.ts
Normal file
45
packages/mcp/src/lib/call-logger.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
|
||||||
|
type CallContext = {
|
||||||
|
projectId: string;
|
||||||
|
toolName: string;
|
||||||
|
requestParams: Record<string, unknown>;
|
||||||
|
clientIp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function logMcpCall(ctx: CallContext, fn: () => Promise<any>): Promise<any> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,39 +5,43 @@ import { listModules } from './tools/list-modules.js';
|
|||||||
import { listEndpoints } from './tools/list-endpoints.js';
|
import { listEndpoints } from './tools/list-endpoints.js';
|
||||||
import { getEndpointDetail } from './tools/get-endpoint-detail.js';
|
import { getEndpointDetail } from './tools/get-endpoint-detail.js';
|
||||||
import { searchEndpoints } from './tools/search-endpoints.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({
|
const server = new McpServer({
|
||||||
name: 'agent-fox',
|
name: 'agent-fox',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ctx = (toolName: string, requestParams: Record<string, unknown> = {}) =>
|
||||||
|
({ projectId, toolName, requestParams, clientIp });
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'get_project_overview',
|
'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.',
|
'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(
|
server.tool(
|
||||||
'list_modules',
|
'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.',
|
'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(
|
server.tool(
|
||||||
'list_endpoints',
|
'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.',
|
'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.') },
|
{ 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(
|
server.tool(
|
||||||
'get_endpoint_detail',
|
'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.',
|
'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.') },
|
{ 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(
|
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.'),
|
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.'),
|
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;
|
return server;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import importRouter from './routes/import.js';
|
|||||||
import moduleRouter from './routes/modules.js';
|
import moduleRouter from './routes/modules.js';
|
||||||
import endpointRouter from './routes/endpoints.js';
|
import endpointRouter from './routes/endpoints.js';
|
||||||
import fetchSpecRouter from './routes/fetch-spec.js';
|
import fetchSpecRouter from './routes/fetch-spec.js';
|
||||||
|
import adminRouter from './routes/admin.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -24,6 +25,7 @@ app.use('/api/projects', projectRouter);
|
|||||||
app.use('/api/projects', importRouter);
|
app.use('/api/projects', importRouter);
|
||||||
app.use('/api/projects', moduleRouter);
|
app.use('/api/projects', moduleRouter);
|
||||||
app.use('/api/projects', endpointRouter);
|
app.use('/api/projects', endpointRouter);
|
||||||
|
app.use('/api/admin', adminRouter);
|
||||||
|
|
||||||
const port = process.env.SERVER_PORT || 3000;
|
const port = process.env.SERVER_PORT || 3000;
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { Role } from '@agent-fox/shared';
|
||||||
|
|
||||||
const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret';
|
const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||||
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
|
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
|
||||||
@@ -8,6 +9,7 @@ const REFRESH_EXPIRY = '7d';
|
|||||||
export type TokenPayload = {
|
export type TokenPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
role: Role;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateAccessToken(payload: TokenPayload): string {
|
export function generateAccessToken(payload: TokenPayload): string {
|
||||||
|
|||||||
9
packages/server/src/middleware/admin.ts
Normal file
9
packages/server/src/middleware/admin.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
305
packages/server/src/routes/admin.ts
Normal file
305
packages/server/src/routes/admin.ts
Normal file
@@ -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<string, any>, 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;
|
||||||
@@ -40,8 +40,8 @@ router.post('/register', async (req, res) => {
|
|||||||
data: { email, passwordHash, name },
|
data: { email, passwordHash, name },
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
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 }, ...tokens } });
|
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) => {
|
router.post('/login', async (req, res) => {
|
||||||
@@ -59,14 +59,19 @@ router.post('/login', async (req, res) => {
|
|||||||
return;
|
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);
|
const valid = await verifyPassword(password, user.passwordHash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
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 }, ...tokens } });
|
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) => {
|
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' } });
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } });
|
||||||
return;
|
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 });
|
res.json({ success: true, data: tokens });
|
||||||
} catch {
|
} catch {
|
||||||
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } });
|
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) => {
|
router.get('/me', requireAuth, async (req, res) => {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: req.user!.userId },
|
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) {
|
if (!user) {
|
||||||
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ async function handleOAuthCallback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await findOrCreateUser(provider, providerUser);
|
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)}` : '';
|
const redirectParam = stateResult.redirect ? `&redirect=${encodeURIComponent(stateResult.redirect)}` : '';
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`);
|
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`);
|
||||||
|
|||||||
@@ -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<T = unknown> = {
|
export type ApiResponse<T = unknown> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ server {
|
|||||||
proxy_pass http://server:3000;
|
proxy_pass http://server:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /mcp/ {
|
location /mcp/ {
|
||||||
proxy_pass http://mcp:3001;
|
proxy_pass http://mcp:3001;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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_http_version 1.1;
|
||||||
proxy_set_header Connection '';
|
proxy_set_header Connection '';
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import Layout from './pages/Layout';
|
|||||||
import Projects from './pages/Projects';
|
import Projects from './pages/Projects';
|
||||||
import ProjectDetail from './pages/ProjectDetail';
|
import ProjectDetail from './pages/ProjectDetail';
|
||||||
import LandingPage from './pages/landing/LandingPage';
|
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();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -26,6 +34,14 @@ export default function App() {
|
|||||||
<Route index element={<Projects />} />
|
<Route index element={<Projects />} />
|
||||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
|
<Route index element={<AdminDashboard />} />
|
||||||
|
<Route path="users" element={<AdminUsers />} />
|
||||||
|
<Route path="users/:id" element={<AdminUserDetail />} />
|
||||||
|
<Route path="projects" element={<AdminProjects />} />
|
||||||
|
<Route path="projects/:id" element={<AdminProjectDetail />} />
|
||||||
|
<Route path="logs" element={<AdminCallLogs />} />
|
||||||
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -302,6 +302,13 @@ body {
|
|||||||
&::placeholder { color: var(--text-muted); }
|
&::placeholder { color: var(--text-muted); }
|
||||||
&:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-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 {
|
.card {
|
||||||
@apply rounded-xl transition-all duration-200;
|
@apply rounded-xl transition-all duration-200;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||||
import { getAccessToken, clearTokens, setTokens, apiFetch } from './api';
|
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 = {
|
type AuthContextType = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type ProjectSummary = {
|
|||||||
_count: { endpoints: number; modules: number };
|
_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 [open, setOpen] = useState(false);
|
||||||
const [confirmLogout, setConfirmLogout] = useState(false);
|
const [confirmLogout, setConfirmLogout] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -67,6 +67,19 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
|
{user.role === 'ADMIN' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
onClick={() => 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)' }}
|
||||||
|
>
|
||||||
|
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||||
|
</svg>
|
||||||
|
Admin 后台
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setOpen(false); onOpenSettings(); }}
|
onClick={() => { setOpen(false); onOpenSettings(); }}
|
||||||
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors mx-1"
|
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors mx-1"
|
||||||
|
|||||||
187
packages/web/src/pages/admin/AdminLayout.tsx
Normal file
187
packages/web/src/pages/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { Navigate, Outlet, NavLink, Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import ThemeToggle from '../../components/ThemeToggle';
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
to: '/admin',
|
||||||
|
label: '仪表盘',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
end: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/users',
|
||||||
|
label: '用户管理',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/projects',
|
||||||
|
label: '项目管理',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/logs',
|
||||||
|
label: '调用日志',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const { user, loading, logout } = useAuth();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mobileOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (sidebarRef.current && !sidebarRef.current.contains(e.target as Node)) setMobileOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [mobileOpen]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-bg-secondary">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (user.role !== 'ADMIN') return <Navigate to="/dashboard" replace />;
|
||||||
|
|
||||||
|
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-secondary flex">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<aside className="hidden lg:flex flex-col w-[220px] border-r border-border-default bg-bg-sidebar shrink-0 fixed inset-y-0 left-0 z-30">
|
||||||
|
<SidebarContent initials={initials} user={user} logout={logout} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div className="fixed inset-0 z-40 lg:hidden">
|
||||||
|
<div className="absolute inset-0 bg-overlay" />
|
||||||
|
<aside ref={sidebarRef} className="absolute inset-y-0 left-0 w-[260px] bg-bg-sidebar border-r border-border-default animate-slide-up flex flex-col">
|
||||||
|
<SidebarContent initials={initials} user={user} logout={logout} onNavClick={() => setMobileOpen(false)} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main area */}
|
||||||
|
<div className="flex-1 lg:ml-[220px] flex flex-col min-h-screen">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="h-14 border-b border-border-default bg-bg-primary flex items-center justify-between px-4 lg:px-6 shrink-0 sticky top-0 z-20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button className="lg:hidden p-1.5 rounded-lg hover:bg-bg-tertiary transition-colors" onClick={() => setMobileOpen(true)}>
|
||||||
|
<svg className="w-5 h-5 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 className="text-sm font-semibold text-text-primary">Admin</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link to="/dashboard" className="text-xs text-text-muted hover:text-text-secondary transition-colors px-2 py-1 rounded-md hover:bg-bg-tertiary">
|
||||||
|
返回主站
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 lg:p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({
|
||||||
|
initials, user, logout, onNavClick,
|
||||||
|
}: {
|
||||||
|
initials: string;
|
||||||
|
user: { name: string; email: string };
|
||||||
|
logout: () => void;
|
||||||
|
onNavClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="h-14 flex items-center px-4 border-b border-border-muted shrink-0">
|
||||||
|
<Link to="/admin" className="flex items-center gap-2.5" onClick={onNavClick}>
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-heading text-[15px] font-bold text-text-primary tracking-tight">Agent Fox</span>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-accent bg-accent-muted px-1.5 py-0.5 rounded">Admin</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 px-2.5 py-3 space-y-0.5 overflow-y-auto">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
onClick={onNavClick}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-muted text-accent'
|
||||||
|
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User section at bottom */}
|
||||||
|
<div className="border-t border-border-muted p-3 shrink-0">
|
||||||
|
<div className="flex items-center gap-2.5 mb-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide shrink-0">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[12px] font-medium text-text-primary truncate">{user.name}</div>
|
||||||
|
<div className="text-[10px] text-text-muted truncate">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-[12px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||||
|
</svg>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
packages/web/src/pages/admin/CallLogs.tsx
Normal file
172
packages/web/src/pages/admin/CallLogs.tsx
Normal file
@@ -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<CallLogsResponse>(`/admin/call-logs?${params}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / limit) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-text-primary font-heading">调用日志</h2>
|
||||||
|
<p className="text-[12px] text-text-muted mt-0.5">共 {data?.total ?? 0} 条记录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<select
|
||||||
|
value={toolName}
|
||||||
|
onChange={(e) => { setToolName(e.target.value); setPage(1); }}
|
||||||
|
className="input-base w-auto text-[13px]"
|
||||||
|
>
|
||||||
|
<option value="">全部工具</option>
|
||||||
|
{TOOL_NAMES.map(name => (
|
||||||
|
<option key={name} value={name}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={successFilter}
|
||||||
|
onChange={(e) => { setSuccessFilter(e.target.value); setPage(1); }}
|
||||||
|
className="input-base w-auto text-[13px]"
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="true">成功</option>
|
||||||
|
<option value="false">失败</option>
|
||||||
|
</select>
|
||||||
|
{(toolName || successFilter) && (
|
||||||
|
<button
|
||||||
|
className="btn-ghost text-[13px] px-3"
|
||||||
|
onClick={() => { setToolName(''); setSuccessFilter(''); setPage(1); }}
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-default bg-bg-secondary">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">时间</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">项目</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">工具</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">耗时</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">响应大小</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">Token</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">客户端 IP</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-border-muted">
|
||||||
|
{Array.from({ length: 8 }).map((_, j) => (
|
||||||
|
<td key={j} className="px-4 py-3"><div className="skeleton h-4 w-16" /></td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : data?.logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-12 text-center text-text-muted">暂无调用日志</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data?.logs.map((log) => (
|
||||||
|
<tr key={log.id} className="border-b border-border-muted hover:bg-bg-secondary/50 transition-colors">
|
||||||
|
<td className="px-4 py-3 text-text-muted whitespace-nowrap">
|
||||||
|
{new Date(log.calledAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary max-w-[150px] truncate">{log.project.name}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-mono text-[11px] text-accent">{log.toolName}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`font-mono text-[12px] ${log.durationMs > 1000 ? 'text-warning' : 'text-text-secondary'}`}>
|
||||||
|
{log.durationMs}ms
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted font-mono text-[12px]">
|
||||||
|
{formatBytes(log.responseSize)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted font-mono text-[12px]">
|
||||||
|
{log.estimatedTokens != null ? log.estimatedTokens.toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted font-mono text-[11px]">{log.clientIp || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[11px] font-medium ${log.success ? 'text-success' : 'text-danger'}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${log.success ? 'bg-success' : 'bg-danger'}`} />
|
||||||
|
{log.success ? '成功' : '失败'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[12px] text-text-muted">第 {page} / {totalPages} 页</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button className="btn-outline text-[12px] px-2.5 py-1.5" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||||
|
<button className="btn-outline text-[12px] px-2.5 py-1.5" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
243
packages/web/src/pages/admin/Dashboard.tsx
Normal file
243
packages/web/src/pages/admin/Dashboard.tsx
Normal file
@@ -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<Stats>('/admin/stats'),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: trends } = useQuery({
|
||||||
|
queryKey: ['admin', 'trends'],
|
||||||
|
queryFn: () => apiFetch<TrendPoint[]>('/admin/stats/trends'),
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: recentCalls } = useQuery({
|
||||||
|
queryKey: ['admin', 'recent-calls'],
|
||||||
|
queryFn: () => apiFetch<RecentCall[]>('/admin/call-logs/recent'),
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="skeleton h-[108px] rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-[280px] rounded-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: '注册用户',
|
||||||
|
value: stats?.totalUsers ?? 0,
|
||||||
|
sub: `今日 +${stats?.todayUsers ?? 0}`,
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'text-blue-500',
|
||||||
|
bg: 'bg-blue-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '项目数',
|
||||||
|
value: stats?.totalProjects ?? 0,
|
||||||
|
sub: `今日 +${stats?.todayProjects ?? 0}`,
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'text-amber-500',
|
||||||
|
bg: 'bg-amber-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'MCP 调用',
|
||||||
|
value: stats?.totalCalls ?? 0,
|
||||||
|
sub: `今日 ${stats?.todayCalls ?? 0} 次`,
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'text-emerald-500',
|
||||||
|
bg: 'bg-emerald-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '活跃用户 (7天)',
|
||||||
|
value: stats?.activeUsers ?? 0,
|
||||||
|
sub: '有 MCP 调用',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||||
|
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'text-violet-500',
|
||||||
|
bg: 'bg-violet-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '平均响应时间',
|
||||||
|
value: `${stats?.avgResponseTime ?? 0}ms`,
|
||||||
|
sub: '近 7 天',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'text-cyan-500',
|
||||||
|
bg: 'bg-cyan-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '调用成功率',
|
||||||
|
value: `${stats?.successRate ?? 100}%`,
|
||||||
|
sub: '近 7 天',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<div key={card.label} className="card p-4">
|
||||||
|
<div className={`w-9 h-9 rounded-lg ${card.bg} ${card.color} flex items-center justify-center mb-3`}>
|
||||||
|
{card.icon}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-text-primary font-heading tracking-tight">
|
||||||
|
{typeof card.value === 'number' ? card.value.toLocaleString() : card.value}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className="text-[12px] text-text-muted">{card.label}</span>
|
||||||
|
<span className="text-[11px] text-text-muted">{card.sub}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Chart + Recent Calls */}
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-5 gap-4">
|
||||||
|
{/* Trend Chart */}
|
||||||
|
<div className="xl:col-span-3 card p-5">
|
||||||
|
<h3 className="section-title mb-4">7 天调用趋势</h3>
|
||||||
|
{trends && trends.length > 0 ? (
|
||||||
|
<div className="flex items-end gap-1.5 h-[180px]">
|
||||||
|
{trends.map((point) => {
|
||||||
|
const height = maxCalls > 0 ? (point.calls / maxCalls) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={point.date} className="flex-1 flex flex-col items-center gap-1 group relative">
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute bottom-full mb-2 hidden group-hover:block z-10">
|
||||||
|
<div className="bg-bg-elevated border border-border-default rounded-lg shadow-lg px-3 py-2 text-[11px] whitespace-nowrap">
|
||||||
|
<div className="font-medium text-text-primary">{point.calls} 次调用</div>
|
||||||
|
<div className="text-text-muted">成功率 {point.successRate}%</div>
|
||||||
|
<div className="text-text-muted">均耗时 {point.avgDuration}ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Bar */}
|
||||||
|
<div className="w-full flex-1 flex items-end">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t-md bg-accent/70 hover:bg-accent transition-colors cursor-default"
|
||||||
|
style={{
|
||||||
|
height: `${Math.max(height, 2)}%`,
|
||||||
|
transition: 'height 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-text-muted">{point.date.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[180px] flex items-center justify-center text-[13px] text-text-muted">
|
||||||
|
暂无调用数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Calls */}
|
||||||
|
<div className="xl:col-span-2 card p-5">
|
||||||
|
<h3 className="section-title mb-4">最近调用</h3>
|
||||||
|
{recentCalls && recentCalls.length > 0 ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{recentCalls.map((call) => (
|
||||||
|
<div key={call.id} className="flex items-center gap-3 text-[12px]">
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${call.success ? 'bg-success' : 'bg-danger'}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-mono text-text-primary font-medium">{call.toolName}</span>
|
||||||
|
<span className="text-text-muted">· {call.durationMs}ms</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-text-muted truncate">{call.project.name}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-text-muted shrink-0">
|
||||||
|
{formatTimeAgo(call.calledAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[180px] flex items-center justify-center text-[13px] text-text-muted">
|
||||||
|
暂无调用记录
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}天前`;
|
||||||
|
}
|
||||||
152
packages/web/src/pages/admin/ProjectDetail.tsx
Normal file
152
packages/web/src/pages/admin/ProjectDetail.tsx
Normal file
@@ -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<ProjectDetailData>(`/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 (
|
||||||
|
<div className="space-y-4 animate-fade-in">
|
||||||
|
<div className="skeleton h-6 w-32" />
|
||||||
|
<div className="skeleton h-[200px] rounded-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return <div className="text-center py-20 text-text-muted">项目不存在</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 animate-fade-in max-w-3xl">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-1.5 text-[12px] text-text-muted">
|
||||||
|
<Link to="/admin/projects" className="hover:text-text-secondary transition-colors">项目管理</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-text-secondary">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Info */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-text-primary font-heading">{project.name}</h2>
|
||||||
|
{project.description && <p className="text-[13px] text-text-muted mt-1">{project.description}</p>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setConfirmDelete(true)} className="btn-danger text-[12px] px-3 py-1.5">
|
||||||
|
删除项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 pt-5 border-t border-border-muted">
|
||||||
|
<InfoItem label="所有者">
|
||||||
|
<Link to={`/admin/users/${project.user.id}`} className="text-accent hover:text-accent-hover transition-colors">
|
||||||
|
{project.user.name}
|
||||||
|
</Link>
|
||||||
|
</InfoItem>
|
||||||
|
<InfoItem label="OpenAPI 版本" value={project.openApiVersion} mono />
|
||||||
|
<InfoItem label="Base URL" value={project.baseUrl || '—'} mono />
|
||||||
|
<InfoItem label="创建时间" value={new Date(project.createdAt).toLocaleDateString('zh-CN')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-border-muted">
|
||||||
|
<StatBadge label="模块" value={project._count.modules} />
|
||||||
|
<StatBadge label="端点" value={project._count.endpoints} />
|
||||||
|
<StatBadge label="MCP 调用" value={project._count.mcpCallLogs} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modules */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="px-5 py-3 border-b border-border-muted">
|
||||||
|
<h3 className="section-title">模块列表 ({project.modules.length})</h3>
|
||||||
|
</div>
|
||||||
|
{project.modules.length === 0 ? (
|
||||||
|
<div className="px-5 py-10 text-center text-[13px] text-text-muted">暂无模块</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border-muted">
|
||||||
|
{project.modules.map((mod) => (
|
||||||
|
<div key={mod.id} className="flex items-center justify-between px-5 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-text-primary">{mod.name}</span>
|
||||||
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-bg-tertiary text-text-muted">{mod.source}</span>
|
||||||
|
</div>
|
||||||
|
{mod.description && <div className="text-[12px] text-text-muted truncate mt-0.5">{mod.description}</div>}
|
||||||
|
</div>
|
||||||
|
<span className="text-[12px] text-text-muted shrink-0 ml-4">{mod._count.endpoints} 端点</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
title="删除项目"
|
||||||
|
description={`确定要删除项目 "${project.name}" 吗?此操作不可恢复,项目下的所有模块、端点和调用日志都将被删除。`}
|
||||||
|
variant="danger"
|
||||||
|
confirmText="删除"
|
||||||
|
onConfirm={() => deleteProject.mutate()}
|
||||||
|
onCancel={() => setConfirmDelete(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value, mono, children }: { label: string; value?: string; mono?: boolean; children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-text-muted font-medium mb-0.5">{label}</div>
|
||||||
|
{children || <div className={`text-[13px] text-text-primary ${mono ? 'font-mono text-[12px]' : ''} truncate`}>{value}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatBadge({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xl font-bold text-text-primary font-heading">{value.toLocaleString()}</div>
|
||||||
|
<div className="text-[11px] text-text-muted mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
packages/web/src/pages/admin/Projects.tsx
Normal file
144
packages/web/src/pages/admin/Projects.tsx
Normal file
@@ -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<ProjectsResponse>(`/admin/projects?${params}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / limit) : 0;
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-text-primary font-heading">项目管理</h2>
|
||||||
|
<p className="text-[12px] text-text-muted mt-0.5">共 {data?.total ?? 0} 个项目</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="搜索项目名称..."
|
||||||
|
className="input-base max-w-xs"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary text-[13px] px-3">搜索</button>
|
||||||
|
{search && (
|
||||||
|
<button type="button" className="btn-ghost text-[13px] px-3" onClick={() => { setSearch(''); setSearchInput(''); setPage(1); }}>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-default bg-bg-secondary">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">项目</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">所有者</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">版本</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">模块</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">端点</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">创建时间</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-text-muted">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-border-muted">
|
||||||
|
{Array.from({ length: 7 }).map((_, j) => (
|
||||||
|
<td key={j} className="px-4 py-3"><div className="skeleton h-4 w-20" /></td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : data?.projects.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-12 text-center text-text-muted">无匹配项目</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data?.projects.map((project) => (
|
||||||
|
<tr key={project.id} className="border-b border-border-muted hover:bg-bg-secondary/50 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-text-primary">{project.name}</div>
|
||||||
|
{project.description && (
|
||||||
|
<div className="text-[11px] text-text-muted truncate max-w-[200px]">{project.description}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link to={`/admin/users/${project.user.id}`} className="text-text-secondary hover:text-accent transition-colors">
|
||||||
|
{project.user.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-mono text-[11px] text-text-muted">{project.openApiVersion}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{project._count.modules}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{project._count.endpoints}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{new Date(project.createdAt).toLocaleDateString('zh-CN')}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Link to={`/admin/projects/${project.id}`} className="text-accent hover:text-accent-hover text-[12px] font-medium transition-colors">
|
||||||
|
查看
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[12px] text-text-muted">第 {page} / {totalPages} 页</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button className="btn-outline text-[12px] px-2.5 py-1.5" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||||
|
<button className="btn-outline text-[12px] px-2.5 py-1.5" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
packages/web/src/pages/admin/UserDetail.tsx
Normal file
175
packages/web/src/pages/admin/UserDetail.tsx
Normal file
@@ -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<UserDetailData>(`/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 (
|
||||||
|
<div className="space-y-4 animate-fade-in">
|
||||||
|
<div className="skeleton h-6 w-32" />
|
||||||
|
<div className="skeleton h-[200px] rounded-xl" />
|
||||||
|
<div className="skeleton h-[200px] rounded-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-20 text-text-muted">
|
||||||
|
用户不存在
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||||
|
const isSelf = currentUser?.id === user.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 animate-fade-in max-w-3xl">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-1.5 text-[12px] text-text-muted">
|
||||||
|
<Link to="/admin/users" className="hover:text-text-secondary transition-colors">用户管理</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-text-secondary">{user.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info Card */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-lg font-bold">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-text-primary font-heading">{user.name}</h2>
|
||||||
|
<div className="text-[13px] text-text-muted mt-0.5">{user.email}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide ${
|
||||||
|
user.role === 'ADMIN' ? 'bg-accent-muted text-accent' : 'bg-bg-tertiary text-text-muted'
|
||||||
|
}`}>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[11px] font-medium ${user.disabled ? 'text-danger' : 'text-success'}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${user.disabled ? 'bg-danger' : 'bg-success'}`} />
|
||||||
|
{user.disabled ? '已禁用' : '正常'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSelf && (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDisable(true)}
|
||||||
|
className={user.disabled ? 'btn-primary text-[12px] px-3 py-1.5' : 'btn-danger text-[12px] px-3 py-1.5'}
|
||||||
|
>
|
||||||
|
{user.disabled ? '启用账号' : '禁用账号'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 pt-5 border-t border-border-muted">
|
||||||
|
<InfoItem label="注册时间" value={new Date(user.createdAt).toLocaleDateString('zh-CN')} />
|
||||||
|
<InfoItem label="项目数" value={String(user.projects.length)} />
|
||||||
|
<InfoItem label="OAuth 账号" value={user.oauthAccounts.map(a => a.provider).join(', ') || '无'} />
|
||||||
|
<InfoItem label="ID" value={user.id.slice(0, 8) + '...'} mono />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="px-5 py-3 border-b border-border-muted">
|
||||||
|
<h3 className="section-title">项目列表 ({user.projects.length})</h3>
|
||||||
|
</div>
|
||||||
|
{user.projects.length === 0 ? (
|
||||||
|
<div className="px-5 py-10 text-center text-[13px] text-text-muted">暂无项目</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border-muted">
|
||||||
|
{user.projects.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
to={`/admin/projects/${project.id}`}
|
||||||
|
className="flex items-center justify-between px-5 py-3 hover:bg-bg-secondary/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[13px] font-medium text-text-primary">{project.name}</div>
|
||||||
|
{project.description && <div className="text-[12px] text-text-muted truncate mt-0.5">{project.description}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-[11px] text-text-muted shrink-0 ml-4">
|
||||||
|
<span>{project._count.modules} 模块</span>
|
||||||
|
<span>{project._count.endpoints} 端点</span>
|
||||||
|
<span>{new Date(project.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Dialog */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDisable}
|
||||||
|
title={user.disabled ? '启用账号' : '禁用账号'}
|
||||||
|
description={user.disabled
|
||||||
|
? `确定要启用用户 "${user.name}" 的账号吗?启用后该用户可以正常登录和使用系统。`
|
||||||
|
: `确定要禁用用户 "${user.name}" 的账号吗?禁用后该用户将无法登录。`
|
||||||
|
}
|
||||||
|
variant={user.disabled ? 'warning' : 'danger'}
|
||||||
|
confirmText={user.disabled ? '启用' : '禁用'}
|
||||||
|
onConfirm={() => toggleDisable.mutate()}
|
||||||
|
onCancel={() => setConfirmDisable(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-text-muted font-medium mb-0.5">{label}</div>
|
||||||
|
<div className={`text-[13px] text-text-primary ${mono ? 'font-mono' : ''}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
packages/web/src/pages/admin/Users.tsx
Normal file
166
packages/web/src/pages/admin/Users.tsx
Normal file
@@ -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<UsersResponse>(`/admin/users?${params}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / limit) : 0;
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-text-primary font-heading">用户管理</h2>
|
||||||
|
<p className="text-[12px] text-text-muted mt-0.5">共 {data?.total ?? 0} 个用户</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="搜索用户名或邮箱..."
|
||||||
|
className="input-base max-w-xs"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary text-[13px] px-3">搜索</button>
|
||||||
|
{search && (
|
||||||
|
<button type="button" className="btn-ghost text-[13px] px-3" onClick={() => { setSearch(''); setSearchInput(''); setPage(1); }}>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-default bg-bg-secondary">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">用户</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">角色</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">项目数</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">注册时间</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-text-muted">状态</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-text-muted">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-border-muted">
|
||||||
|
<td className="px-4 py-3"><div className="skeleton h-4 w-40" /></td>
|
||||||
|
<td className="px-4 py-3"><div className="skeleton h-4 w-16" /></td>
|
||||||
|
<td className="px-4 py-3"><div className="skeleton h-4 w-8" /></td>
|
||||||
|
<td className="px-4 py-3"><div className="skeleton h-4 w-24" /></td>
|
||||||
|
<td className="px-4 py-3"><div className="skeleton h-4 w-16" /></td>
|
||||||
|
<td className="px-4 py-3"><div className="skeleton h-4 w-12 ml-auto" /></td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : data?.users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-12 text-center text-text-muted">无匹配用户</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data?.users.map((user) => (
|
||||||
|
<tr key={user.id} className="border-b border-border-muted hover:bg-bg-secondary/50 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[9px] font-bold shrink-0">
|
||||||
|
{user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-text-primary truncate">{user.name}</div>
|
||||||
|
<div className="text-[11px] text-text-muted truncate">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide ${
|
||||||
|
user.role === 'ADMIN' ? 'bg-accent-muted text-accent' : 'bg-bg-tertiary text-text-muted'
|
||||||
|
}`}>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{user._count.projects}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{new Date(user.createdAt).toLocaleDateString('zh-CN')}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[11px] font-medium ${user.disabled ? 'text-danger' : 'text-success'}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${user.disabled ? 'bg-danger' : 'bg-success'}`} />
|
||||||
|
{user.disabled ? '已禁用' : '正常'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Link to={`/admin/users/${user.id}`} className="text-accent hover:text-accent-hover text-[12px] font-medium transition-colors">
|
||||||
|
查看
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[12px] text-text-muted">第 {page} / {totalPages} 页</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
className="btn-outline text-[12px] px-2.5 py-1.5"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(p => p - 1)}
|
||||||
|
>上一页</button>
|
||||||
|
<button
|
||||||
|
className="btn-outline text-[12px] px-2.5 py-1.5"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
>下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -7,12 +7,19 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
USER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
name String
|
name String
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
role Role @default(USER)
|
||||||
|
disabled Boolean @default(false)
|
||||||
apiKeyHash String?
|
apiKeyHash String?
|
||||||
apiKeyEncrypted String?
|
apiKeyEncrypted String?
|
||||||
apiKeyPrefix String?
|
apiKeyPrefix String?
|
||||||
@@ -33,8 +40,26 @@ model OAuthAccount {
|
|||||||
@@unique([provider, providerAccountId])
|
@@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 {
|
model Project {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
@@ -43,9 +68,10 @@ model Project {
|
|||||||
openApiVersion String
|
openApiVersion String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
modules Module[]
|
modules Module[]
|
||||||
endpoints Endpoint[]
|
endpoints Endpoint[]
|
||||||
|
mcpCallLogs McpCallLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ModuleSource {
|
enum ModuleSource {
|
||||||
|
|||||||
Reference in New Issue
Block a user