feat: 完善 MCP 接入体验与协议层稳定性
接入体验:
- 客户端选择器覆盖 7 个客户端(Claude Desktop/Code、Cursor、Cline、
Windsurf、Codex、GitHub Copilot),自动生成对应配置
- API Key 一键含 key 复制(密码验证后写入剪贴板)
- 公开 /mcp/:projectId/info + Test connection 按钮
- 项目新增 Usage 标签(每日柱状图 + 工具调用分布)
- 导入完成后引导跳转到 MCP 配置
协议层稳定性:
- session 超时清理(5 分钟扫描 / 30 分钟 idle 关闭)
- 错误响应标准化为 { error: { code, message } }
- 调用日志捕获错误信息 + 4KB payload 截断
- McpCallLog 增加 errorMessage 字段(migration)
抽出可复用模块:PasswordRevealPrompt 组件、BarChart 组件、
trends helper(daysWindowStart + getDailyTrends)。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
61
packages/server/src/lib/trends.ts
Normal file
61
packages/server/src/lib/trends.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { prisma, Prisma } from '@agent-fox/shared';
|
||||
|
||||
export type TrendRow = {
|
||||
date: string;
|
||||
total: bigint;
|
||||
success_count: bigint;
|
||||
avg_duration: number;
|
||||
tokens: bigint;
|
||||
};
|
||||
|
||||
export function daysWindowStart(days: number): Date {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
since.setHours(0, 0, 0, 0);
|
||||
return since;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily aggregation of MCP call logs in `[since, now]`. Pass `projectId`
|
||||
* to scope; omit for site-wide. Always emits one row per day in range
|
||||
* (zeros for days with no calls), in chronological order.
|
||||
*/
|
||||
export async function getDailyTrends(opts: { since: Date; days: number; projectId?: string }) {
|
||||
const { since, days, projectId } = opts;
|
||||
const projectFilter = projectId
|
||||
? Prisma.sql`AND "projectId" = ${projectId}`
|
||||
: Prisma.empty;
|
||||
|
||||
const rows = await prisma.$queryRaw<TrendRow[]>`
|
||||
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,
|
||||
COALESCE(SUM("estimatedTokens"), 0)::bigint AS tokens
|
||||
FROM "McpCallLog"
|
||||
WHERE "calledAt" >= ${since} ${projectFilter}
|
||||
GROUP BY TO_CHAR("calledAt", 'YYYY-MM-DD')
|
||||
ORDER BY date
|
||||
`;
|
||||
|
||||
const map = new Map(rows.map((r) => [r.date, r]));
|
||||
const filled = [];
|
||||
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 = map.get(key);
|
||||
const total = row ? Number(row.total) : 0;
|
||||
const successCnt = row ? Number(row.success_count) : 0;
|
||||
filled.push({
|
||||
date: key,
|
||||
total,
|
||||
success: successCnt,
|
||||
errors: total - successCnt,
|
||||
avgDuration: row ? row.avg_duration : 0,
|
||||
tokens: row ? Number(row.tokens) : 0,
|
||||
});
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { prisma } from '@agent-fox/shared';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { requireAdmin } from '../middleware/admin.js';
|
||||
import { daysWindowStart, getDailyTrends } from '../lib/trends.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
@@ -86,42 +87,14 @@ router.get('/stats', async (_req, res) => {
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const since = daysWindowStart(days);
|
||||
const filled = await getDailyTrends({ since, days });
|
||||
const trends = filled.map((d) => ({
|
||||
date: d.date,
|
||||
calls: d.total,
|
||||
successRate: d.total > 0 ? Math.round((d.success / d.total) * 100) : 100,
|
||||
avgDuration: d.avgDuration,
|
||||
}));
|
||||
res.json({ success: true, data: trends });
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { prisma } from '@agent-fox/shared';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { parseOpenApiDocument } from '../services/openapi-parser.js';
|
||||
import { daysWindowStart, getDailyTrends } from '../lib/trends.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -121,6 +122,51 @@ router.put('/:id', async (req, res) => {
|
||||
res.json({ success: true, data: updated });
|
||||
});
|
||||
|
||||
router.get('/:id/usage', async (req, res) => {
|
||||
const days = req.query.days === '30' ? 30 : 7;
|
||||
|
||||
const owned = await prisma.project.findFirst({
|
||||
where: { id: req.params.id, userId: req.user!.userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) {
|
||||
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const since = daysWindowStart(days);
|
||||
const [trends, toolRows] = await Promise.all([
|
||||
getDailyTrends({ since, days, projectId: req.params.id }),
|
||||
prisma.mcpCallLog.groupBy({
|
||||
by: ['toolName'],
|
||||
where: { projectId: req.params.id, calledAt: { gte: since } },
|
||||
_count: { _all: true },
|
||||
_sum: { estimatedTokens: true },
|
||||
orderBy: { _count: { toolName: 'desc' } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const daily = trends.map((d) => ({ date: d.date, calls: d.total, errors: d.errors, tokens: d.tokens }));
|
||||
const totals = daily.reduce(
|
||||
(acc, d) => ({ calls: acc.calls + d.calls, errors: acc.errors + d.errors, tokens: acc.tokens + d.tokens }),
|
||||
{ calls: 0, errors: 0, tokens: 0 },
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
days,
|
||||
totals,
|
||||
daily,
|
||||
byTool: toolRows.map((r) => ({
|
||||
toolName: r.toolName,
|
||||
calls: r._count._all,
|
||||
tokens: r._sum.estimatedTokens ?? 0,
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const result = await prisma.project.deleteMany({
|
||||
where: { id: req.params.id, userId: req.user!.userId },
|
||||
|
||||
Reference in New Issue
Block a user