feat: add comprehensive stats dashboard with D3 charts
- Add /stats page with animated D3 visualizations - Create reusable chart components: AnimatedCounter, DonutChart, BarChart, AreaChart - Expand stats API endpoints: /api/stats, /api/stats/health, /api/stats/executions, /api/stats/sync, /api/stats/tools - Add Stats link to desktop and mobile navigation - Add AI SDK v6 integration tests with vitest Dashboard displays: - Registry overview metrics with count-up animations - Health distribution donut charts (import/execution) - Quality score distribution - Package tier breakdown - Execution trends area chart with success/error series - Token usage statistics - Top categories bar chart - Recent sync operations status
This commit is contained in:
parent
9855425be4
commit
c189bd366a
18 changed files with 3285 additions and 40 deletions
|
|
@ -8,7 +8,9 @@
|
|||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf .next .turbo"
|
||||
"clean": "rm -rf .next .turbo",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "3.0.1",
|
||||
|
|
@ -46,10 +48,12 @@
|
|||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.4",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
442
apps/web/src/app/api/stats/executions/route.ts
Normal file
442
apps/web/src/app/api/stats/executions/route.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { checkRateLimit } from '~/lib/rate-limit';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* GET /api/stats/executions
|
||||
* Detailed execution (simulation) statistics and analytics
|
||||
*
|
||||
* Returns:
|
||||
* - Execution counts and success rates
|
||||
* - Performance timing metrics
|
||||
* - Token usage analytics
|
||||
* - Most executed tools
|
||||
* - Execution trends over time
|
||||
* - Error analysis
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitResponse = checkRateLimit(request);
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const last1h = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const last30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [
|
||||
// Total counts
|
||||
totalExecutions,
|
||||
successCount,
|
||||
errorCount,
|
||||
timeoutCount,
|
||||
pendingCount,
|
||||
runningCount,
|
||||
|
||||
// Time-based counts
|
||||
execLast1h,
|
||||
execLast24h,
|
||||
execLast7d,
|
||||
execLast30d,
|
||||
|
||||
// Timing statistics
|
||||
timingStats,
|
||||
|
||||
// Token usage aggregates
|
||||
tokenStats,
|
||||
|
||||
// Most executed tools
|
||||
mostExecutedTools,
|
||||
|
||||
// Recent executions
|
||||
recentExecutions,
|
||||
|
||||
// Execution by status (last 24h)
|
||||
statusBreakdown24h,
|
||||
|
||||
// Hourly trends (last 24h)
|
||||
hourlyTrends,
|
||||
|
||||
// Daily trends (last 7 days)
|
||||
dailyTrends,
|
||||
|
||||
// Top errors
|
||||
topErrors,
|
||||
|
||||
// Model usage breakdown
|
||||
modelUsage,
|
||||
] = await Promise.all([
|
||||
// Total counts
|
||||
prisma.simulation.count(),
|
||||
prisma.simulation.count({ where: { status: 'success' } }),
|
||||
prisma.simulation.count({ where: { status: 'error' } }),
|
||||
prisma.simulation.count({ where: { status: 'timeout' } }),
|
||||
prisma.simulation.count({ where: { status: 'pending' } }),
|
||||
prisma.simulation.count({ where: { status: 'running' } }),
|
||||
|
||||
// Time-based counts
|
||||
prisma.simulation.count({ where: { createdAt: { gte: last1h } } }),
|
||||
prisma.simulation.count({ where: { createdAt: { gte: last24h } } }),
|
||||
prisma.simulation.count({ where: { createdAt: { gte: last7d } } }),
|
||||
prisma.simulation.count({ where: { createdAt: { gte: last30d } } }),
|
||||
|
||||
// Timing stats for successful executions
|
||||
prisma.simulation.aggregate({
|
||||
where: {
|
||||
status: 'success',
|
||||
executionTimeMs: { not: null },
|
||||
},
|
||||
_avg: { executionTimeMs: true, agentSteps: true },
|
||||
_min: { executionTimeMs: true },
|
||||
_max: { executionTimeMs: true },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// Token usage stats
|
||||
prisma.tokenUsage.aggregate({
|
||||
_sum: {
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
totalTokens: true,
|
||||
estimatedCost: true,
|
||||
},
|
||||
_avg: {
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
totalTokens: true,
|
||||
estimatedCost: true,
|
||||
},
|
||||
_min: { totalTokens: true },
|
||||
_max: { totalTokens: true },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// Most executed tools (top 20)
|
||||
prisma.simulation.groupBy({
|
||||
by: ['toolId'],
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } },
|
||||
take: 20,
|
||||
}),
|
||||
|
||||
// Recent executions (last 20)
|
||||
prisma.simulation.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
executionTimeMs: true,
|
||||
agentSteps: true,
|
||||
model: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
tool: {
|
||||
select: {
|
||||
name: true,
|
||||
package: { select: { npmPackageName: true } },
|
||||
},
|
||||
},
|
||||
tokenUsage: {
|
||||
select: {
|
||||
totalTokens: true,
|
||||
estimatedCost: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Status breakdown last 24h
|
||||
prisma.simulation.groupBy({
|
||||
by: ['status'],
|
||||
where: { createdAt: { gte: last24h } },
|
||||
_count: { id: true },
|
||||
}),
|
||||
|
||||
// Hourly trends (last 24h)
|
||||
prisma.$queryRaw<
|
||||
{
|
||||
hour: Date;
|
||||
total: bigint;
|
||||
success: bigint;
|
||||
error: bigint;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
DATE_TRUNC('hour', created_at) as hour,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success,
|
||||
SUM(CASE WHEN status IN ('error', 'timeout') THEN 1 ELSE 0 END) as error
|
||||
FROM simulations
|
||||
WHERE created_at >= ${last24h}
|
||||
GROUP BY DATE_TRUNC('hour', created_at)
|
||||
ORDER BY hour DESC
|
||||
`,
|
||||
|
||||
// Daily trends (last 7 days)
|
||||
prisma.$queryRaw<
|
||||
{
|
||||
date: Date;
|
||||
total: bigint;
|
||||
success: bigint;
|
||||
error: bigint;
|
||||
avg_time_ms: number | null;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success,
|
||||
SUM(CASE WHEN status IN ('error', 'timeout') THEN 1 ELSE 0 END) as error,
|
||||
AVG(CASE WHEN status = 'success' THEN execution_time_ms END) as avg_time_ms
|
||||
FROM simulations
|
||||
WHERE created_at >= ${last7d}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
`,
|
||||
|
||||
// Top errors (most common error messages)
|
||||
prisma.$queryRaw<{ error: string; count: bigint }[]>`
|
||||
SELECT
|
||||
SUBSTRING(error, 1, 200) as error,
|
||||
COUNT(*) as count
|
||||
FROM simulations
|
||||
WHERE status IN ('error', 'timeout')
|
||||
AND error IS NOT NULL
|
||||
AND created_at >= ${last7d}
|
||||
GROUP BY SUBSTRING(error, 1, 200)
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
|
||||
// Model usage breakdown
|
||||
prisma.simulation.groupBy({
|
||||
by: ['model'],
|
||||
where: { model: { not: null } },
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Get tool details for most executed
|
||||
const toolIds = mostExecutedTools.map((t) => t.toolId);
|
||||
const toolDetails = await prisma.tool.findMany({
|
||||
where: { id: { in: toolIds } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
package: { select: { npmPackageName: true } },
|
||||
},
|
||||
});
|
||||
const toolMap = new Map(toolDetails.map((t) => [t.id, t]));
|
||||
|
||||
// Format most executed tools
|
||||
const formattedMostExecuted = mostExecutedTools.map((item) => {
|
||||
const tool = toolMap.get(item.toolId);
|
||||
return {
|
||||
toolId: item.toolId,
|
||||
packageName: tool?.package.npmPackageName ?? 'unknown',
|
||||
toolName: tool?.name ?? 'unknown',
|
||||
executionCount: item._count.id,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate success rate
|
||||
const completedCount = successCount + errorCount + timeoutCount;
|
||||
const successRate =
|
||||
completedCount > 0 ? ((successCount / completedCount) * 100).toFixed(2) : '0.00';
|
||||
|
||||
// Format status breakdown
|
||||
const statusBreakdownMap: Record<string, number> = {};
|
||||
for (const item of statusBreakdown24h) {
|
||||
statusBreakdownMap[item.status] = item._count.id;
|
||||
}
|
||||
|
||||
// Format hourly trends
|
||||
const formattedHourlyTrends = hourlyTrends.map((h) => ({
|
||||
hour: h.hour,
|
||||
total: Number(h.total),
|
||||
success: Number(h.success),
|
||||
error: Number(h.error),
|
||||
successRate:
|
||||
Number(h.total) > 0 ? ((Number(h.success) / Number(h.total)) * 100).toFixed(2) : '0.00',
|
||||
}));
|
||||
|
||||
// Format daily trends
|
||||
const formattedDailyTrends = dailyTrends.map((d) => ({
|
||||
date: d.date,
|
||||
total: Number(d.total),
|
||||
success: Number(d.success),
|
||||
error: Number(d.error),
|
||||
avgTimeMs: d.avg_time_ms ? Math.round(d.avg_time_ms) : null,
|
||||
successRate:
|
||||
Number(d.total) > 0 ? ((Number(d.success) / Number(d.total)) * 100).toFixed(2) : '0.00',
|
||||
}));
|
||||
|
||||
// Format recent executions
|
||||
const formattedRecentExecutions = recentExecutions.map((exec) => ({
|
||||
id: exec.id,
|
||||
packageName: exec.tool.package.npmPackageName,
|
||||
toolName: exec.tool.name,
|
||||
status: exec.status,
|
||||
executionTimeMs: exec.executionTimeMs,
|
||||
agentSteps: exec.agentSteps,
|
||||
model: exec.model,
|
||||
tokens: exec.tokenUsage?.totalTokens ?? null,
|
||||
costUsd: exec.tokenUsage?.estimatedCost
|
||||
? Number(exec.tokenUsage.estimatedCost).toFixed(6)
|
||||
: null,
|
||||
createdAt: exec.createdAt,
|
||||
completedAt: exec.completedAt,
|
||||
}));
|
||||
|
||||
// Format top errors
|
||||
const formattedErrors = topErrors.map((e) => ({
|
||||
error: e.error,
|
||||
count: Number(e.count),
|
||||
}));
|
||||
|
||||
// Format model usage
|
||||
const formattedModelUsage = modelUsage.map((m) => ({
|
||||
model: m.model,
|
||||
count: m._count.id,
|
||||
}));
|
||||
|
||||
const processingTime = Date.now() - startTime;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
timestamp: now.toISOString(),
|
||||
processingTimeMs: processingTime,
|
||||
},
|
||||
data: {
|
||||
// Overview
|
||||
overview: {
|
||||
totalExecutions,
|
||||
successRate: `${successRate}%`,
|
||||
byStatus: {
|
||||
success: successCount,
|
||||
error: errorCount,
|
||||
timeout: timeoutCount,
|
||||
pending: pendingCount,
|
||||
running: runningCount,
|
||||
},
|
||||
},
|
||||
|
||||
// Activity
|
||||
activity: {
|
||||
last1h: execLast1h,
|
||||
last24h: execLast24h,
|
||||
last7d: execLast7d,
|
||||
last30d: execLast30d,
|
||||
statusBreakdown24h: statusBreakdownMap,
|
||||
},
|
||||
|
||||
// Performance
|
||||
performance: {
|
||||
timing: {
|
||||
avgMs: timingStats._avg.executionTimeMs
|
||||
? Math.round(timingStats._avg.executionTimeMs)
|
||||
: null,
|
||||
minMs: timingStats._min.executionTimeMs,
|
||||
maxMs: timingStats._max.executionTimeMs,
|
||||
sampleSize: timingStats._count,
|
||||
},
|
||||
avgAgentSteps: timingStats._avg.agentSteps
|
||||
? Number(timingStats._avg.agentSteps.toFixed(2))
|
||||
: null,
|
||||
},
|
||||
|
||||
// Token usage
|
||||
tokens: {
|
||||
totalRecorded: tokenStats._count,
|
||||
totals: {
|
||||
inputTokens: tokenStats._sum.inputTokens || 0,
|
||||
outputTokens: tokenStats._sum.outputTokens || 0,
|
||||
totalTokens: tokenStats._sum.totalTokens || 0,
|
||||
estimatedCostUsd: tokenStats._sum.estimatedCost
|
||||
? Number(tokenStats._sum.estimatedCost).toFixed(4)
|
||||
: '0.0000',
|
||||
},
|
||||
averages: {
|
||||
inputTokens: tokenStats._avg.inputTokens
|
||||
? Math.round(tokenStats._avg.inputTokens)
|
||||
: null,
|
||||
outputTokens: tokenStats._avg.outputTokens
|
||||
? Math.round(tokenStats._avg.outputTokens)
|
||||
: null,
|
||||
totalTokens: tokenStats._avg.totalTokens
|
||||
? Math.round(tokenStats._avg.totalTokens)
|
||||
: null,
|
||||
costUsd: tokenStats._avg.estimatedCost
|
||||
? Number(tokenStats._avg.estimatedCost).toFixed(6)
|
||||
: null,
|
||||
},
|
||||
range: {
|
||||
minTokens: tokenStats._min.totalTokens,
|
||||
maxTokens: tokenStats._max.totalTokens,
|
||||
},
|
||||
},
|
||||
|
||||
// Model usage
|
||||
modelUsage: formattedModelUsage,
|
||||
|
||||
// Trends
|
||||
trends: {
|
||||
hourly: formattedHourlyTrends,
|
||||
daily: formattedDailyTrends,
|
||||
},
|
||||
|
||||
// Top tools
|
||||
topTools: formattedMostExecuted,
|
||||
|
||||
// Recent executions
|
||||
recentExecutions: formattedRecentExecutions,
|
||||
|
||||
// Error analysis
|
||||
errors: {
|
||||
totalErrors: errorCount + timeoutCount,
|
||||
topErrors: formattedErrors,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
|
||||
'X-Processing-Time': `${processingTime}ms`,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching execution stats:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'EXECUTION_STATS_ERROR',
|
||||
message: 'Failed to fetch execution statistics',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
330
apps/web/src/app/api/stats/health/route.ts
Normal file
330
apps/web/src/app/api/stats/health/route.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { checkRateLimit } from '~/lib/rate-limit';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* GET /api/stats/health
|
||||
* Detailed health check statistics and analytics
|
||||
*
|
||||
* Returns:
|
||||
* - Current health status distribution
|
||||
* - Health check history and trends
|
||||
* - Broken tools with error details
|
||||
* - Health check timing statistics
|
||||
* - Health check coverage metrics
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitResponse = checkRateLimit(request);
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [
|
||||
// Current status distribution
|
||||
totalTools,
|
||||
importHealthy,
|
||||
importBroken,
|
||||
importUnknown,
|
||||
executionHealthy,
|
||||
executionBroken,
|
||||
executionUnknown,
|
||||
|
||||
// Tools never checked
|
||||
neverChecked,
|
||||
|
||||
// Health check history
|
||||
checksLast24h,
|
||||
checksLast7d,
|
||||
totalChecks,
|
||||
|
||||
// Check type breakdown
|
||||
importChecks,
|
||||
executionChecks,
|
||||
fullChecks,
|
||||
|
||||
// Recent health check results
|
||||
recentChecks,
|
||||
|
||||
// Broken tools with details
|
||||
brokenTools,
|
||||
|
||||
// Health check timing stats
|
||||
checkTimingStats,
|
||||
|
||||
// Daily health check trends (last 7 days)
|
||||
dailyTrends,
|
||||
] = await Promise.all([
|
||||
// Total tools
|
||||
prisma.tool.count(),
|
||||
|
||||
// Import health distribution
|
||||
prisma.tool.count({ where: { importHealth: 'HEALTHY' } }),
|
||||
prisma.tool.count({ where: { importHealth: 'BROKEN' } }),
|
||||
prisma.tool.count({ where: { importHealth: 'UNKNOWN' } }),
|
||||
|
||||
// Execution health distribution
|
||||
prisma.tool.count({ where: { executionHealth: 'HEALTHY' } }),
|
||||
prisma.tool.count({ where: { executionHealth: 'BROKEN' } }),
|
||||
prisma.tool.count({ where: { executionHealth: 'UNKNOWN' } }),
|
||||
|
||||
// Never checked tools
|
||||
prisma.tool.count({ where: { lastHealthCheck: null } }),
|
||||
|
||||
// Health check counts
|
||||
prisma.healthCheck.count({ where: { createdAt: { gte: last24h } } }),
|
||||
prisma.healthCheck.count({ where: { createdAt: { gte: last7d } } }),
|
||||
prisma.healthCheck.count(),
|
||||
|
||||
// Check types
|
||||
prisma.healthCheck.count({ where: { checkType: 'IMPORT' } }),
|
||||
prisma.healthCheck.count({ where: { checkType: 'EXECUTION' } }),
|
||||
prisma.healthCheck.count({ where: { checkType: 'FULL' } }),
|
||||
|
||||
// Recent checks (last 20)
|
||||
prisma.healthCheck.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
checkType: true,
|
||||
triggerSource: true,
|
||||
importStatus: true,
|
||||
executionStatus: true,
|
||||
overallStatus: true,
|
||||
importTimeMs: true,
|
||||
executionTimeMs: true,
|
||||
createdAt: true,
|
||||
tool: {
|
||||
select: {
|
||||
name: true,
|
||||
package: {
|
||||
select: { npmPackageName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Broken tools with error details
|
||||
prisma.tool.findMany({
|
||||
where: {
|
||||
OR: [{ importHealth: 'BROKEN' }, { executionHealth: 'BROKEN' }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
importHealth: true,
|
||||
executionHealth: true,
|
||||
healthCheckError: true,
|
||||
lastHealthCheck: true,
|
||||
package: {
|
||||
select: { npmPackageName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { lastHealthCheck: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
|
||||
// Timing statistics
|
||||
prisma.healthCheck.aggregate({
|
||||
_avg: {
|
||||
importTimeMs: true,
|
||||
executionTimeMs: true,
|
||||
},
|
||||
_min: {
|
||||
importTimeMs: true,
|
||||
executionTimeMs: true,
|
||||
},
|
||||
_max: {
|
||||
importTimeMs: true,
|
||||
executionTimeMs: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Daily trends (raw SQL for date grouping)
|
||||
prisma.$queryRaw<
|
||||
{
|
||||
date: Date;
|
||||
total: bigint;
|
||||
healthy: bigint;
|
||||
broken: bigint;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN overall_status = 'HEALTHY' THEN 1 ELSE 0 END) as healthy,
|
||||
SUM(CASE WHEN overall_status = 'BROKEN' THEN 1 ELSE 0 END) as broken
|
||||
FROM health_checks
|
||||
WHERE created_at >= ${last7d}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
`,
|
||||
]);
|
||||
|
||||
// Calculate coverage percentage
|
||||
const checkedTools = totalTools - neverChecked;
|
||||
const coveragePercent =
|
||||
totalTools > 0 ? ((checkedTools / totalTools) * 100).toFixed(2) : '0.00';
|
||||
|
||||
// Calculate health rates
|
||||
const importHealthRate =
|
||||
totalTools > 0 ? ((importHealthy / totalTools) * 100).toFixed(2) : '0.00';
|
||||
const executionHealthRate =
|
||||
totalTools > 0 ? ((executionHealthy / totalTools) * 100).toFixed(2) : '0.00';
|
||||
|
||||
// Format broken tools
|
||||
const formattedBrokenTools = brokenTools.map((tool) => ({
|
||||
id: tool.id,
|
||||
packageName: tool.package.npmPackageName,
|
||||
toolName: tool.name,
|
||||
importHealth: tool.importHealth,
|
||||
executionHealth: tool.executionHealth,
|
||||
error: tool.healthCheckError,
|
||||
lastChecked: tool.lastHealthCheck,
|
||||
}));
|
||||
|
||||
// Format recent checks
|
||||
const formattedRecentChecks = recentChecks.map((check) => ({
|
||||
id: check.id,
|
||||
packageName: check.tool.package.npmPackageName,
|
||||
toolName: check.tool.name,
|
||||
checkType: check.checkType,
|
||||
triggerSource: check.triggerSource,
|
||||
importStatus: check.importStatus,
|
||||
executionStatus: check.executionStatus,
|
||||
overallStatus: check.overallStatus,
|
||||
importTimeMs: check.importTimeMs,
|
||||
executionTimeMs: check.executionTimeMs,
|
||||
timestamp: check.createdAt,
|
||||
}));
|
||||
|
||||
// Format daily trends
|
||||
const formattedTrends = dailyTrends.map((day) => ({
|
||||
date: day.date,
|
||||
total: Number(day.total),
|
||||
healthy: Number(day.healthy),
|
||||
broken: Number(day.broken),
|
||||
healthRate:
|
||||
Number(day.total) > 0
|
||||
? ((Number(day.healthy) / Number(day.total)) * 100).toFixed(2)
|
||||
: '0.00',
|
||||
}));
|
||||
|
||||
const processingTime = Date.now() - startTime;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
timestamp: now.toISOString(),
|
||||
processingTimeMs: processingTime,
|
||||
},
|
||||
data: {
|
||||
// Current status
|
||||
currentStatus: {
|
||||
totalTools,
|
||||
import: {
|
||||
healthy: importHealthy,
|
||||
broken: importBroken,
|
||||
unknown: importUnknown,
|
||||
healthRate: `${importHealthRate}%`,
|
||||
},
|
||||
execution: {
|
||||
healthy: executionHealthy,
|
||||
broken: executionBroken,
|
||||
unknown: executionUnknown,
|
||||
healthRate: `${executionHealthRate}%`,
|
||||
},
|
||||
},
|
||||
|
||||
// Coverage metrics
|
||||
coverage: {
|
||||
checkedTools,
|
||||
neverChecked,
|
||||
coveragePercent: `${coveragePercent}%`,
|
||||
},
|
||||
|
||||
// Check history
|
||||
checkHistory: {
|
||||
totalChecks,
|
||||
last24h: checksLast24h,
|
||||
last7d: checksLast7d,
|
||||
byType: {
|
||||
import: importChecks,
|
||||
execution: executionChecks,
|
||||
full: fullChecks,
|
||||
},
|
||||
},
|
||||
|
||||
// Timing statistics
|
||||
timing: {
|
||||
import: {
|
||||
avgMs: checkTimingStats._avg.importTimeMs
|
||||
? Math.round(checkTimingStats._avg.importTimeMs)
|
||||
: null,
|
||||
minMs: checkTimingStats._min.importTimeMs,
|
||||
maxMs: checkTimingStats._max.importTimeMs,
|
||||
},
|
||||
execution: {
|
||||
avgMs: checkTimingStats._avg.executionTimeMs
|
||||
? Math.round(checkTimingStats._avg.executionTimeMs)
|
||||
: null,
|
||||
minMs: checkTimingStats._min.executionTimeMs,
|
||||
maxMs: checkTimingStats._max.executionTimeMs,
|
||||
},
|
||||
},
|
||||
|
||||
// Daily trends
|
||||
dailyTrends: formattedTrends,
|
||||
|
||||
// Recent checks
|
||||
recentChecks: formattedRecentChecks,
|
||||
|
||||
// Broken tools
|
||||
brokenTools: {
|
||||
count: brokenTools.length,
|
||||
tools: formattedBrokenTools,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
|
||||
'X-Processing-Time': `${processingTime}ms`,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching health stats:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'HEALTH_STATS_ERROR',
|
||||
message: 'Failed to fetch health statistics',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,19 +4,26 @@ import { checkRateLimit } from '~/lib/rate-limit';
|
|||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* GET /api/stats
|
||||
* Get aggregated statistics about tools in the registry
|
||||
* Comprehensive statistics about the TPMJS registry
|
||||
*
|
||||
* Returns:
|
||||
* - totalTools: Total number of tools
|
||||
* - officialTools: Number of official tools (with tpmjs-tool keyword)
|
||||
* - categories: Breakdown by category with counts
|
||||
* - recentTools: Count of tools added in last 7 days
|
||||
* - totalDownloads: Sum of all npm downloads
|
||||
* Returns complete developer-focused metrics including:
|
||||
* - Registry totals (tools, packages, downloads)
|
||||
* - Health status distribution
|
||||
* - Quality score distribution
|
||||
* - Category breakdown
|
||||
* - Tier breakdown (minimal vs rich)
|
||||
* - Recent activity
|
||||
* - Execution statistics
|
||||
* - Token usage statistics
|
||||
* - Sync operation status
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitResponse = checkRateLimit(request);
|
||||
if (rateLimitResponse) {
|
||||
|
|
@ -24,61 +31,356 @@ export async function GET(request: NextRequest) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Run all aggregations in parallel
|
||||
const [totalTools, officialTools, recentCount, packages] = await Promise.all([
|
||||
// Total tools count
|
||||
// Time boundaries
|
||||
const now = new Date();
|
||||
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const last30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Run all aggregations in parallel for performance
|
||||
const [
|
||||
// Tool counts
|
||||
totalTools,
|
||||
officialToolCount,
|
||||
toolsWithSchema,
|
||||
|
||||
// Health status counts
|
||||
healthyImportCount,
|
||||
brokenImportCount,
|
||||
unknownImportCount,
|
||||
healthyExecutionCount,
|
||||
brokenExecutionCount,
|
||||
unknownExecutionCount,
|
||||
|
||||
// Package data for aggregations
|
||||
packages,
|
||||
|
||||
// Recent activity
|
||||
toolsLast24h,
|
||||
toolsLast7d,
|
||||
toolsLast30d,
|
||||
packagesLast7d,
|
||||
|
||||
// Simulation statistics
|
||||
totalSimulations,
|
||||
successfulSimulations,
|
||||
failedSimulations,
|
||||
simulationsLast24h,
|
||||
simulationsLast7d,
|
||||
|
||||
// Execution time stats (successful simulations only)
|
||||
executionTimeStats,
|
||||
|
||||
// Token usage aggregates
|
||||
tokenUsageStats,
|
||||
|
||||
// Recent sync logs
|
||||
recentSyncLogs,
|
||||
|
||||
// Sync checkpoints
|
||||
syncCheckpoints,
|
||||
|
||||
// Health check history
|
||||
healthChecksLast24h,
|
||||
healthChecksLast7d,
|
||||
|
||||
// Quality score distribution
|
||||
qualityScoreDistribution,
|
||||
] = await Promise.all([
|
||||
// Total tools
|
||||
prisma.tool.count(),
|
||||
|
||||
// Official tools count (isOfficial is at package level)
|
||||
// Official tools
|
||||
prisma.tool.count({
|
||||
where: {
|
||||
package: { isOfficial: true },
|
||||
},
|
||||
where: { package: { isOfficial: true } },
|
||||
}),
|
||||
|
||||
// Recent tools (last 7 days)
|
||||
// Tools with extracted schema
|
||||
prisma.tool.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
where: { schemaSource: 'extracted' },
|
||||
}),
|
||||
|
||||
// Get all packages with their tool counts and download stats
|
||||
// Health status distribution - Import
|
||||
prisma.tool.count({ where: { importHealth: 'HEALTHY' } }),
|
||||
prisma.tool.count({ where: { importHealth: 'BROKEN' } }),
|
||||
prisma.tool.count({ where: { importHealth: 'UNKNOWN' } }),
|
||||
|
||||
// Health status distribution - Execution
|
||||
prisma.tool.count({ where: { executionHealth: 'HEALTHY' } }),
|
||||
prisma.tool.count({ where: { executionHealth: 'BROKEN' } }),
|
||||
prisma.tool.count({ where: { executionHealth: 'UNKNOWN' } }),
|
||||
|
||||
// Package data for category/tier/download aggregations
|
||||
prisma.package.findMany({
|
||||
select: {
|
||||
category: true,
|
||||
tier: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
_count: {
|
||||
select: { tools: true },
|
||||
},
|
||||
githubStars: true,
|
||||
isOfficial: true,
|
||||
_count: { select: { tools: true } },
|
||||
},
|
||||
}),
|
||||
|
||||
// Recent tools
|
||||
prisma.tool.count({ where: { createdAt: { gte: last24h } } }),
|
||||
prisma.tool.count({ where: { createdAt: { gte: last7d } } }),
|
||||
prisma.tool.count({ where: { createdAt: { gte: last30d } } }),
|
||||
prisma.package.count({ where: { createdAt: { gte: last7d } } }),
|
||||
|
||||
// Simulation counts
|
||||
prisma.simulation.count(),
|
||||
prisma.simulation.count({ where: { status: 'success' } }),
|
||||
prisma.simulation.count({ where: { status: { in: ['error', 'timeout'] } } }),
|
||||
prisma.simulation.count({ where: { createdAt: { gte: last24h } } }),
|
||||
prisma.simulation.count({ where: { createdAt: { gte: last7d } } }),
|
||||
|
||||
// Execution time statistics (only for successful simulations with timing data)
|
||||
prisma.simulation.aggregate({
|
||||
where: {
|
||||
status: 'success',
|
||||
executionTimeMs: { not: null },
|
||||
},
|
||||
_avg: { executionTimeMs: true },
|
||||
_min: { executionTimeMs: true },
|
||||
_max: { executionTimeMs: true },
|
||||
}),
|
||||
|
||||
// Token usage aggregates
|
||||
prisma.tokenUsage.aggregate({
|
||||
_sum: {
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
totalTokens: true,
|
||||
estimatedCost: true,
|
||||
},
|
||||
_avg: {
|
||||
totalTokens: true,
|
||||
estimatedCost: true,
|
||||
},
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// Recent sync logs (last 10)
|
||||
prisma.syncLog.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
select: {
|
||||
source: true,
|
||||
status: true,
|
||||
processed: true,
|
||||
skipped: true,
|
||||
errors: true,
|
||||
createdAt: true,
|
||||
metadata: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Sync checkpoints
|
||||
prisma.syncCheckpoint.findMany({
|
||||
select: {
|
||||
source: true,
|
||||
checkpoint: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Health checks last 24h
|
||||
prisma.healthCheck.count({ where: { createdAt: { gte: last24h } } }),
|
||||
prisma.healthCheck.count({ where: { createdAt: { gte: last7d } } }),
|
||||
|
||||
// Quality score distribution (buckets)
|
||||
prisma.$queryRaw<{ bucket: string; count: bigint }[]>`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN quality_score IS NULL THEN 'unscored'
|
||||
WHEN quality_score < 0.3 THEN 'low'
|
||||
WHEN quality_score < 0.5 THEN 'medium-low'
|
||||
WHEN quality_score < 0.7 THEN 'medium'
|
||||
WHEN quality_score < 0.9 THEN 'high'
|
||||
ELSE 'excellent'
|
||||
END as bucket,
|
||||
COUNT(*) as count
|
||||
FROM tools
|
||||
GROUP BY bucket
|
||||
ORDER BY
|
||||
CASE bucket
|
||||
WHEN 'unscored' THEN 0
|
||||
WHEN 'low' THEN 1
|
||||
WHEN 'medium-low' THEN 2
|
||||
WHEN 'medium' THEN 3
|
||||
WHEN 'high' THEN 4
|
||||
WHEN 'excellent' THEN 5
|
||||
END
|
||||
`,
|
||||
]);
|
||||
|
||||
// Calculate stats from packages
|
||||
// Aggregate package data
|
||||
const categories: Record<string, number> = {};
|
||||
const tiers = { minimal: 0, rich: 0 };
|
||||
let totalDownloads = 0;
|
||||
let totalGithubStars = 0;
|
||||
let totalPackages = 0;
|
||||
let officialPackages = 0;
|
||||
|
||||
for (const pkg of packages) {
|
||||
// Count tools by category
|
||||
totalPackages++;
|
||||
|
||||
// Category breakdown (by tool count)
|
||||
if (pkg.category) {
|
||||
categories[pkg.category] = (categories[pkg.category] || 0) + pkg._count.tools;
|
||||
}
|
||||
|
||||
// Sum downloads
|
||||
// Tier breakdown (by package count)
|
||||
if (pkg.tier === 'minimal') {
|
||||
tiers.minimal++;
|
||||
} else if (pkg.tier === 'rich') {
|
||||
tiers.rich++;
|
||||
}
|
||||
|
||||
// Download totals
|
||||
totalDownloads += pkg.npmDownloadsLastMonth || 0;
|
||||
totalGithubStars += pkg.githubStars || 0;
|
||||
|
||||
if (pkg.isOfficial) {
|
||||
officialPackages++;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
// Format quality score distribution
|
||||
const qualityDistribution: Record<string, number> = {};
|
||||
for (const row of qualityScoreDistribution) {
|
||||
qualityDistribution[row.bucket] = Number(row.count);
|
||||
}
|
||||
|
||||
// Calculate execution success rate
|
||||
const executionSuccessRate =
|
||||
totalSimulations > 0 ? ((successfulSimulations / totalSimulations) * 100).toFixed(2) : '0.00';
|
||||
|
||||
// Format sync checkpoint data
|
||||
const syncStatus: Record<string, unknown> = {};
|
||||
for (const checkpoint of syncCheckpoints) {
|
||||
syncStatus[checkpoint.source] = {
|
||||
lastRun: checkpoint.updatedAt,
|
||||
...(checkpoint.checkpoint as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
|
||||
// Build response
|
||||
const processingTime = Date.now() - startTime;
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
meta: {
|
||||
version: '2.0.0',
|
||||
timestamp: now.toISOString(),
|
||||
processingTimeMs: processingTime,
|
||||
},
|
||||
data: {
|
||||
totalTools,
|
||||
officialTools,
|
||||
// Overview
|
||||
overview: {
|
||||
totalTools,
|
||||
totalPackages,
|
||||
officialTools: officialToolCount,
|
||||
officialPackages,
|
||||
toolsWithExtractedSchema: toolsWithSchema,
|
||||
totalNpmDownloads: totalDownloads,
|
||||
totalGithubStars,
|
||||
},
|
||||
|
||||
// Health status
|
||||
health: {
|
||||
import: {
|
||||
healthy: healthyImportCount,
|
||||
broken: brokenImportCount,
|
||||
unknown: unknownImportCount,
|
||||
},
|
||||
execution: {
|
||||
healthy: healthyExecutionCount,
|
||||
broken: brokenExecutionCount,
|
||||
unknown: unknownExecutionCount,
|
||||
},
|
||||
healthChecksLast24h,
|
||||
healthChecksLast7d,
|
||||
},
|
||||
|
||||
// Quality distribution
|
||||
quality: {
|
||||
distribution: qualityDistribution,
|
||||
},
|
||||
|
||||
// Category breakdown
|
||||
categories,
|
||||
recentTools: recentCount,
|
||||
totalDownloads,
|
||||
|
||||
// Tier breakdown
|
||||
tiers,
|
||||
|
||||
// Recent activity
|
||||
recentActivity: {
|
||||
toolsAddedLast24h: toolsLast24h,
|
||||
toolsAddedLast7d: toolsLast7d,
|
||||
toolsAddedLast30d: toolsLast30d,
|
||||
packagesAddedLast7d: packagesLast7d,
|
||||
},
|
||||
|
||||
// Execution statistics
|
||||
executions: {
|
||||
total: totalSimulations,
|
||||
successful: successfulSimulations,
|
||||
failed: failedSimulations,
|
||||
successRate: `${executionSuccessRate}%`,
|
||||
last24h: simulationsLast24h,
|
||||
last7d: simulationsLast7d,
|
||||
timing: {
|
||||
avgMs: executionTimeStats._avg.executionTimeMs
|
||||
? Math.round(executionTimeStats._avg.executionTimeMs)
|
||||
: null,
|
||||
minMs: executionTimeStats._min.executionTimeMs,
|
||||
maxMs: executionTimeStats._max.executionTimeMs,
|
||||
},
|
||||
},
|
||||
|
||||
// Token usage
|
||||
tokens: {
|
||||
totalRecorded: tokenUsageStats._count,
|
||||
totals: {
|
||||
inputTokens: tokenUsageStats._sum.inputTokens || 0,
|
||||
outputTokens: tokenUsageStats._sum.outputTokens || 0,
|
||||
totalTokens: tokenUsageStats._sum.totalTokens || 0,
|
||||
estimatedCostUsd: tokenUsageStats._sum.estimatedCost
|
||||
? Number(tokenUsageStats._sum.estimatedCost).toFixed(4)
|
||||
: '0.0000',
|
||||
},
|
||||
averages: {
|
||||
tokensPerExecution: tokenUsageStats._avg.totalTokens
|
||||
? Math.round(tokenUsageStats._avg.totalTokens)
|
||||
: null,
|
||||
costPerExecutionUsd: tokenUsageStats._avg.estimatedCost
|
||||
? Number(tokenUsageStats._avg.estimatedCost).toFixed(6)
|
||||
: null,
|
||||
},
|
||||
},
|
||||
|
||||
// Sync status
|
||||
sync: {
|
||||
status: syncStatus,
|
||||
recentOperations: recentSyncLogs.map((log) => ({
|
||||
source: log.source,
|
||||
status: log.status,
|
||||
processed: log.processed,
|
||||
skipped: log.skipped,
|
||||
errors: log.errors,
|
||||
timestamp: log.createdAt,
|
||||
durationMs: (log.metadata as Record<string, unknown>)?.durationMs ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
|
||||
'X-Processing-Time': `${processingTime}ms`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -87,8 +389,15 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch stats',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
error: {
|
||||
code: 'STATS_ERROR',
|
||||
message: 'Failed to fetch registry statistics',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
meta: {
|
||||
version: '2.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
|
|
|||
266
apps/web/src/app/api/stats/sync/route.ts
Normal file
266
apps/web/src/app/api/stats/sync/route.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { checkRateLimit } from '~/lib/rate-limit';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* GET /api/stats/sync
|
||||
* Sync operation statistics and status
|
||||
*
|
||||
* Returns:
|
||||
* - Current checkpoint status for each sync source
|
||||
* - Recent sync operations with success/failure rates
|
||||
* - Sync timing statistics
|
||||
* - Error analysis for failed syncs
|
||||
* - NPM changes feed pending count
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitResponse = checkRateLimit(request);
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [
|
||||
// Checkpoints
|
||||
checkpoints,
|
||||
|
||||
// Recent logs by source
|
||||
changesFeedLogs,
|
||||
keywordLogs,
|
||||
metricsLogs,
|
||||
|
||||
// Total counts
|
||||
totalSyncs,
|
||||
successfulSyncs,
|
||||
failedSyncs,
|
||||
partialSyncs,
|
||||
|
||||
// Counts by time period
|
||||
syncsLast24h,
|
||||
syncsLast7d,
|
||||
|
||||
// Aggregate stats
|
||||
aggregateStats,
|
||||
|
||||
// Sync logs for error analysis
|
||||
errorLogs,
|
||||
] = await Promise.all([
|
||||
// Get all checkpoints
|
||||
prisma.syncCheckpoint.findMany(),
|
||||
|
||||
// Recent changes-feed logs
|
||||
prisma.syncLog.findMany({
|
||||
where: { source: 'changes-feed' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
|
||||
// Recent keyword search logs
|
||||
prisma.syncLog.findMany({
|
||||
where: { source: 'keyword-search' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
|
||||
// Recent metrics logs
|
||||
prisma.syncLog.findMany({
|
||||
where: { source: 'metrics' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
|
||||
// Total sync counts
|
||||
prisma.syncLog.count(),
|
||||
prisma.syncLog.count({ where: { status: 'success' } }),
|
||||
prisma.syncLog.count({ where: { status: 'error' } }),
|
||||
prisma.syncLog.count({ where: { status: 'partial' } }),
|
||||
|
||||
// Time-based counts
|
||||
prisma.syncLog.count({ where: { createdAt: { gte: last24h } } }),
|
||||
prisma.syncLog.count({ where: { createdAt: { gte: last7d } } }),
|
||||
|
||||
// Aggregate processing stats
|
||||
prisma.syncLog.aggregate({
|
||||
_sum: {
|
||||
processed: true,
|
||||
skipped: true,
|
||||
errors: true,
|
||||
},
|
||||
_avg: {
|
||||
processed: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Recent error logs for analysis
|
||||
prisma.syncLog.findMany({
|
||||
where: {
|
||||
OR: [{ status: 'error' }, { status: 'partial' }],
|
||||
createdAt: { gte: last7d },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
source: true,
|
||||
status: true,
|
||||
message: true,
|
||||
errors: true,
|
||||
createdAt: true,
|
||||
metadata: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Format checkpoints
|
||||
const checkpointStatus: Record<string, unknown> = {};
|
||||
for (const checkpoint of checkpoints) {
|
||||
const data = checkpoint.checkpoint as Record<string, unknown>;
|
||||
checkpointStatus[checkpoint.source] = {
|
||||
lastUpdated: checkpoint.updatedAt,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to format sync logs
|
||||
const formatSyncLogs = (logs: typeof changesFeedLogs) =>
|
||||
logs.map((log) => ({
|
||||
status: log.status,
|
||||
processed: log.processed,
|
||||
skipped: log.skipped,
|
||||
errors: log.errors,
|
||||
message: log.message,
|
||||
timestamp: log.createdAt,
|
||||
durationMs: (log.metadata as Record<string, unknown>)?.durationMs ?? null,
|
||||
metadata: log.metadata,
|
||||
}));
|
||||
|
||||
// Calculate success rate
|
||||
const completedSyncs = successfulSyncs + failedSyncs + partialSyncs;
|
||||
const successRate =
|
||||
completedSyncs > 0 ? ((successfulSyncs / completedSyncs) * 100).toFixed(2) : '0.00';
|
||||
|
||||
// Calculate average duration from recent logs
|
||||
const allRecentLogs = [...changesFeedLogs, ...keywordLogs, ...metricsLogs];
|
||||
const durations = allRecentLogs
|
||||
.map((log) => (log.metadata as Record<string, unknown>)?.durationMs)
|
||||
.filter((d): d is number => typeof d === 'number');
|
||||
const avgDuration =
|
||||
durations.length > 0
|
||||
? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
|
||||
: null;
|
||||
|
||||
// Format error logs
|
||||
const formattedErrors = errorLogs.map((log) => ({
|
||||
source: log.source,
|
||||
status: log.status,
|
||||
message: log.message,
|
||||
errorCount: log.errors,
|
||||
timestamp: log.createdAt,
|
||||
}));
|
||||
|
||||
// Sync frequency (operations per day last 7 days)
|
||||
const syncsPerDay = syncsLast7d > 0 ? (syncsLast7d / 7).toFixed(2) : '0.00';
|
||||
|
||||
const processingTime = Date.now() - startTime;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
timestamp: now.toISOString(),
|
||||
processingTimeMs: processingTime,
|
||||
},
|
||||
data: {
|
||||
// Current status
|
||||
checkpoints: checkpointStatus,
|
||||
|
||||
// Overview
|
||||
overview: {
|
||||
totalOperations: totalSyncs,
|
||||
successRate: `${successRate}%`,
|
||||
byStatus: {
|
||||
success: successfulSyncs,
|
||||
error: failedSyncs,
|
||||
partial: partialSyncs,
|
||||
},
|
||||
last24h: syncsLast24h,
|
||||
last7d: syncsLast7d,
|
||||
avgOperationsPerDay: syncsPerDay,
|
||||
},
|
||||
|
||||
// Processing statistics
|
||||
processing: {
|
||||
totalProcessed: aggregateStats._sum.processed || 0,
|
||||
totalSkipped: aggregateStats._sum.skipped || 0,
|
||||
totalErrors: aggregateStats._sum.errors || 0,
|
||||
avgProcessedPerOperation: aggregateStats._avg.processed
|
||||
? Math.round(aggregateStats._avg.processed)
|
||||
: null,
|
||||
},
|
||||
|
||||
// Timing
|
||||
timing: {
|
||||
avgDurationMs: avgDuration,
|
||||
},
|
||||
|
||||
// By source
|
||||
bySource: {
|
||||
'changes-feed': {
|
||||
recentOperations: formatSyncLogs(changesFeedLogs),
|
||||
lastRun: changesFeedLogs[0]?.createdAt ?? null,
|
||||
checkpoint: checkpointStatus['changes-feed'] ?? null,
|
||||
},
|
||||
'keyword-search': {
|
||||
recentOperations: formatSyncLogs(keywordLogs),
|
||||
lastRun: keywordLogs[0]?.createdAt ?? null,
|
||||
checkpoint: checkpointStatus['keyword-search'] ?? null,
|
||||
},
|
||||
metrics: {
|
||||
recentOperations: formatSyncLogs(metricsLogs),
|
||||
lastRun: metricsLogs[0]?.createdAt ?? null,
|
||||
checkpoint: checkpointStatus.metrics ?? null,
|
||||
},
|
||||
},
|
||||
|
||||
// Recent errors
|
||||
recentErrors: formattedErrors,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
|
||||
'X-Processing-Time': `${processingTime}ms`,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching sync stats:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'SYNC_STATS_ERROR',
|
||||
message: 'Failed to fetch sync statistics',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
238
apps/web/src/app/api/stats/tools/route.ts
Normal file
238
apps/web/src/app/api/stats/tools/route.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { checkRateLimit } from '~/lib/rate-limit';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* GET /api/stats/tools
|
||||
* Top tools by various metrics
|
||||
*
|
||||
* Query params:
|
||||
* - sortBy: 'quality' | 'downloads' | 'executions' | 'recent' (default: 'quality')
|
||||
* - limit: number (default: 20, max: 100)
|
||||
* - category: filter by category
|
||||
* - health: 'healthy' | 'broken' | 'unknown' - filter by health status
|
||||
*
|
||||
* Returns:
|
||||
* - Top tools by quality score
|
||||
* - Top tools by npm downloads
|
||||
* - Most executed tools
|
||||
* - Recently added tools
|
||||
* - Tool category distribution
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitResponse = checkRateLimit(request);
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sortBy = searchParams.get('sortBy') || 'quality';
|
||||
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '20', 10), 100);
|
||||
const category = searchParams.get('category');
|
||||
const healthFilter = searchParams.get('health');
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Build where clause
|
||||
const whereClause: Record<string, unknown> = {};
|
||||
if (category) {
|
||||
whereClause.package = { category };
|
||||
}
|
||||
if (healthFilter === 'healthy') {
|
||||
whereClause.importHealth = 'HEALTHY';
|
||||
whereClause.executionHealth = 'HEALTHY';
|
||||
} else if (healthFilter === 'broken') {
|
||||
whereClause.OR = [{ importHealth: 'BROKEN' }, { executionHealth: 'BROKEN' }];
|
||||
} else if (healthFilter === 'unknown') {
|
||||
whereClause.OR = [{ importHealth: 'UNKNOWN' }, { executionHealth: 'UNKNOWN' }];
|
||||
}
|
||||
|
||||
// Determine sort order
|
||||
let orderBy: Record<string, unknown>[];
|
||||
switch (sortBy) {
|
||||
case 'downloads':
|
||||
orderBy = [{ package: { npmDownloadsLastMonth: 'desc' } }, { qualityScore: 'desc' }];
|
||||
break;
|
||||
case 'recent':
|
||||
orderBy = [{ createdAt: 'desc' }];
|
||||
break;
|
||||
case 'quality':
|
||||
default:
|
||||
orderBy = [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }];
|
||||
break;
|
||||
}
|
||||
|
||||
// For execution sorting, we need a different query
|
||||
let tools;
|
||||
let executionCounts: Map<string, number> = new Map();
|
||||
|
||||
if (sortBy === 'executions') {
|
||||
// Get execution counts first
|
||||
const execGroups = await prisma.simulation.groupBy({
|
||||
by: ['toolId'],
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } },
|
||||
take: limit * 2, // Get more to account for filtering
|
||||
});
|
||||
|
||||
const toolIds = execGroups.map((g) => g.toolId);
|
||||
executionCounts = new Map(execGroups.map((g) => [g.toolId, g._count.id]));
|
||||
|
||||
tools = await prisma.tool.findMany({
|
||||
where: {
|
||||
id: { in: toolIds },
|
||||
...whereClause,
|
||||
},
|
||||
take: limit,
|
||||
include: {
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
npmVersion: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
githubStars: true,
|
||||
category: true,
|
||||
tier: true,
|
||||
isOfficial: true,
|
||||
npmHomepage: true,
|
||||
npmRepository: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sort by execution count
|
||||
tools.sort((a, b) => (executionCounts.get(b.id) || 0) - (executionCounts.get(a.id) || 0));
|
||||
} else {
|
||||
tools = await prisma.tool.findMany({
|
||||
where: whereClause,
|
||||
orderBy,
|
||||
take: limit,
|
||||
include: {
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
npmVersion: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
githubStars: true,
|
||||
category: true,
|
||||
tier: true,
|
||||
isOfficial: true,
|
||||
npmHomepage: true,
|
||||
npmRepository: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { simulations: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get execution counts for non-execution sorted queries
|
||||
if (sortBy !== 'executions') {
|
||||
const toolIds = tools.map((t) => t.id);
|
||||
const execGroups = await prisma.simulation.groupBy({
|
||||
by: ['toolId'],
|
||||
where: { toolId: { in: toolIds } },
|
||||
_count: { id: true },
|
||||
});
|
||||
executionCounts = new Map(execGroups.map((g) => [g.toolId, g._count.id]));
|
||||
}
|
||||
|
||||
// Get category distribution
|
||||
const categoryDistribution = await prisma.package.groupBy({
|
||||
by: ['category'],
|
||||
_count: true,
|
||||
orderBy: { _count: { category: 'desc' } },
|
||||
});
|
||||
|
||||
// Format tools response
|
||||
const formattedTools = tools.map((tool, index) => ({
|
||||
rank: index + 1,
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
qualityScore: tool.qualityScore ? Number(tool.qualityScore) : null,
|
||||
importHealth: tool.importHealth,
|
||||
executionHealth: tool.executionHealth,
|
||||
lastHealthCheck: tool.lastHealthCheck,
|
||||
hasExtractedSchema: tool.schemaSource === 'extracted',
|
||||
package: {
|
||||
name: tool.package.npmPackageName,
|
||||
version: tool.package.npmVersion,
|
||||
category: tool.package.category,
|
||||
tier: tool.package.tier,
|
||||
isOfficial: tool.package.isOfficial,
|
||||
npmDownloadsLastMonth: tool.package.npmDownloadsLastMonth,
|
||||
githubStars: tool.package.githubStars,
|
||||
homepage: tool.package.npmHomepage,
|
||||
repository: tool.package.npmRepository,
|
||||
},
|
||||
executionCount: executionCounts.get(tool.id) || 0,
|
||||
createdAt: tool.createdAt,
|
||||
}));
|
||||
|
||||
// Format category distribution
|
||||
const formattedCategories = categoryDistribution.map((cat) => ({
|
||||
category: cat.category,
|
||||
packageCount: cat._count,
|
||||
}));
|
||||
|
||||
const processingTime = Date.now() - startTime;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
timestamp: now.toISOString(),
|
||||
processingTimeMs: processingTime,
|
||||
},
|
||||
data: {
|
||||
query: {
|
||||
sortBy,
|
||||
limit,
|
||||
category: category || null,
|
||||
healthFilter: healthFilter || null,
|
||||
},
|
||||
resultCount: formattedTools.length,
|
||||
tools: formattedTools,
|
||||
categoryDistribution: formattedCategories,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=120, stale-while-revalidate=300',
|
||||
'X-Processing-Time': `${processingTime}ms`,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tool stats:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'TOOL_STATS_ERROR',
|
||||
message: 'Failed to fetch tool statistics',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
16
apps/web/src/app/stats/layout.tsx
Normal file
16
apps/web/src/app/stats/layout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Registry Statistics | TPMJS',
|
||||
description:
|
||||
'Real-time metrics and analytics for the TPMJS tool registry. View tool counts, health status, execution statistics, and more.',
|
||||
openGraph: {
|
||||
title: 'Registry Statistics | TPMJS',
|
||||
description:
|
||||
'Real-time metrics and analytics for the TPMJS tool registry. View tool counts, health status, execution statistics, and more.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function StatsLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
504
apps/web/src/app/stats/page.tsx
Normal file
504
apps/web/src/app/stats/page.tsx
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatedCounter } from '~/components/stats/AnimatedCounter';
|
||||
import { AreaChart } from '~/components/stats/AreaChart';
|
||||
import { BarChart } from '~/components/stats/BarChart';
|
||||
import { DonutChart } from '~/components/stats/DonutChart';
|
||||
|
||||
interface StatsData {
|
||||
overview: {
|
||||
totalTools: number;
|
||||
totalPackages: number;
|
||||
officialTools: number;
|
||||
officialPackages: number;
|
||||
toolsWithExtractedSchema: number;
|
||||
totalNpmDownloads: number;
|
||||
totalGithubStars: number;
|
||||
};
|
||||
health: {
|
||||
import: { healthy: number; broken: number; unknown: number };
|
||||
execution: { healthy: number; broken: number; unknown: number };
|
||||
healthChecksLast24h: number;
|
||||
healthChecksLast7d: number;
|
||||
};
|
||||
quality: {
|
||||
distribution: Record<string, number>;
|
||||
};
|
||||
categories: Record<string, number>;
|
||||
tiers: { minimal: number; rich: number };
|
||||
recentActivity: {
|
||||
toolsAddedLast24h: number;
|
||||
toolsAddedLast7d: number;
|
||||
toolsAddedLast30d: number;
|
||||
packagesAddedLast7d: number;
|
||||
};
|
||||
executions: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
successRate: string;
|
||||
last24h: number;
|
||||
last7d: number;
|
||||
timing: {
|
||||
avgMs: number | null;
|
||||
minMs: number | null;
|
||||
maxMs: number | null;
|
||||
};
|
||||
};
|
||||
tokens: {
|
||||
totalRecorded: number;
|
||||
totals: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
estimatedCostUsd: string;
|
||||
};
|
||||
averages: {
|
||||
tokensPerExecution: number | null;
|
||||
costPerExecutionUsd: string | null;
|
||||
};
|
||||
};
|
||||
sync: {
|
||||
status: Record<string, unknown>;
|
||||
recentOperations: Array<{
|
||||
source: string;
|
||||
status: string;
|
||||
processed: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
timestamp: string;
|
||||
durationMs: number | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutionsData {
|
||||
overview: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
timeout: number;
|
||||
successRate: string;
|
||||
};
|
||||
activity: {
|
||||
last24h: number;
|
||||
last7d: number;
|
||||
last30d: number;
|
||||
};
|
||||
timing: {
|
||||
avgMs: number | null;
|
||||
minMs: number | null;
|
||||
maxMs: number | null;
|
||||
p50Ms: number | null;
|
||||
p95Ms: number | null;
|
||||
};
|
||||
trends: {
|
||||
hourly: Array<{ hour: string; count: number; successCount: number; errorCount: number }>;
|
||||
daily: Array<{ date: string; count: number; successCount: number; errorCount: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
const QUALITY_COLORS: Record<string, string> = {
|
||||
excellent: '#22c55e',
|
||||
high: '#84cc16',
|
||||
medium: '#eab308',
|
||||
'medium-low': '#f97316',
|
||||
low: '#ef4444',
|
||||
unscored: '#6b7280',
|
||||
};
|
||||
|
||||
const HEALTH_COLORS = {
|
||||
healthy: '#22c55e',
|
||||
broken: '#ef4444',
|
||||
unknown: '#6b7280',
|
||||
};
|
||||
|
||||
const TIER_COLORS = {
|
||||
rich: '#8b5cf6',
|
||||
minimal: '#06b6d4',
|
||||
};
|
||||
|
||||
export default function StatsPage() {
|
||||
const [stats, setStats] = useState<StatsData | null>(null);
|
||||
const [executions, setExecutions] = useState<ExecutionsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [statsRes, execRes] = await Promise.all([
|
||||
fetch('/api/stats'),
|
||||
fetch('/api/stats/executions'),
|
||||
]);
|
||||
|
||||
if (!statsRes.ok || !execRes.ok) {
|
||||
throw new Error('Failed to fetch stats');
|
||||
}
|
||||
|
||||
const statsJson = await statsRes.json();
|
||||
const execJson = await execRes.json();
|
||||
|
||||
if (statsJson.success) {
|
||||
setStats(statsJson.data);
|
||||
}
|
||||
if (execJson.success) {
|
||||
setExecutions(execJson.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-foreground-secondary animate-pulse">Loading statistics...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-error mb-2">Failed to load statistics</h2>
|
||||
<p className="text-foreground-secondary">{error || 'Unknown error'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare chart data
|
||||
const qualityData = Object.entries(stats.quality.distribution)
|
||||
.filter(([bucket]) => bucket !== 'unscored')
|
||||
.map(([bucket, count]) => ({
|
||||
label: bucket.charAt(0).toUpperCase() + bucket.slice(1).replace('-', ' '),
|
||||
value: count,
|
||||
color: QUALITY_COLORS[bucket] || '#6b7280',
|
||||
}));
|
||||
|
||||
const importHealthData = [
|
||||
{ label: 'Healthy', value: stats.health.import.healthy, color: HEALTH_COLORS.healthy },
|
||||
{ label: 'Broken', value: stats.health.import.broken, color: HEALTH_COLORS.broken },
|
||||
{ label: 'Unknown', value: stats.health.import.unknown, color: HEALTH_COLORS.unknown },
|
||||
].filter((d) => d.value > 0);
|
||||
|
||||
const executionHealthData = [
|
||||
{ label: 'Healthy', value: stats.health.execution.healthy, color: HEALTH_COLORS.healthy },
|
||||
{ label: 'Broken', value: stats.health.execution.broken, color: HEALTH_COLORS.broken },
|
||||
{ label: 'Unknown', value: stats.health.execution.unknown, color: HEALTH_COLORS.unknown },
|
||||
].filter((d) => d.value > 0);
|
||||
|
||||
const tierData = [
|
||||
{ label: 'Rich', value: stats.tiers.rich, color: TIER_COLORS.rich },
|
||||
{ label: 'Minimal', value: stats.tiers.minimal, color: TIER_COLORS.minimal },
|
||||
].filter((d) => d.value > 0);
|
||||
|
||||
const categoryData = Object.entries(stats.categories)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 10)
|
||||
.map(([label, value]) => ({ label, value }));
|
||||
|
||||
// Prepare execution trend data
|
||||
const dailyTrendData =
|
||||
executions?.trends?.daily?.map((d) => ({
|
||||
date: d.date,
|
||||
value: d.successCount,
|
||||
secondaryValue: d.errorCount,
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="bg-surface-secondary border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">Registry Statistics</h1>
|
||||
<p className="mt-2 text-foreground-secondary">
|
||||
Real-time metrics and analytics for the TPMJS tool registry
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
{/* Overview Cards */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">Overview</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total Tools"
|
||||
value={stats.overview.totalTools}
|
||||
icon="🔧"
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Packages"
|
||||
value={stats.overview.totalPackages}
|
||||
icon="📦"
|
||||
color="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="NPM Downloads"
|
||||
value={stats.overview.totalNpmDownloads}
|
||||
icon="📥"
|
||||
color="text-warning"
|
||||
suffix="/mo"
|
||||
/>
|
||||
<StatCard
|
||||
label="GitHub Stars"
|
||||
value={stats.overview.totalGithubStars}
|
||||
icon="⭐"
|
||||
color="text-accent"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">Recent Activity</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Tools (24h)"
|
||||
value={stats.recentActivity.toolsAddedLast24h}
|
||||
prefix="+"
|
||||
color="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Tools (7d)"
|
||||
value={stats.recentActivity.toolsAddedLast7d}
|
||||
prefix="+"
|
||||
color="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Tools (30d)"
|
||||
value={stats.recentActivity.toolsAddedLast30d}
|
||||
prefix="+"
|
||||
color="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Packages (7d)"
|
||||
value={stats.recentActivity.packagesAddedLast7d}
|
||||
prefix="+"
|
||||
color="text-primary"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Health & Quality Charts */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">Health & Quality</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<ChartCard title="Import Health">
|
||||
<DonutChart
|
||||
data={importHealthData}
|
||||
size={180}
|
||||
centerValue={stats.health.import.healthy}
|
||||
centerLabel="Healthy"
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="Execution Health">
|
||||
<DonutChart
|
||||
data={executionHealthData}
|
||||
size={180}
|
||||
centerValue={stats.health.execution.healthy}
|
||||
centerLabel="Healthy"
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="Quality Distribution">
|
||||
<DonutChart
|
||||
data={qualityData}
|
||||
size={180}
|
||||
centerValue={qualityData.reduce((sum, d) => sum + d.value, 0)}
|
||||
centerLabel="Scored"
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="Package Tiers">
|
||||
<DonutChart
|
||||
data={tierData}
|
||||
size={180}
|
||||
centerValue={stats.tiers.rich}
|
||||
centerLabel="Rich"
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Execution Stats */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">Execution Statistics</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<StatCard
|
||||
label="Total Executions"
|
||||
value={stats.executions.total}
|
||||
icon="▶️"
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
label="Successful"
|
||||
value={stats.executions.successful}
|
||||
icon="✅"
|
||||
color="text-success"
|
||||
/>
|
||||
<StatCard label="Failed" value={stats.executions.failed} icon="❌" color="text-error" />
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={Number.parseFloat(stats.executions.successRate)}
|
||||
suffix="%"
|
||||
decimals={1}
|
||||
color="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Time"
|
||||
value={stats.executions.timing.avgMs || 0}
|
||||
suffix="ms"
|
||||
color="text-foreground-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dailyTrendData.length > 0 && (
|
||||
<ChartCard title="Daily Execution Trends (Last 30 Days)">
|
||||
<AreaChart
|
||||
data={dailyTrendData}
|
||||
width={800}
|
||||
height={300}
|
||||
showSecondary
|
||||
labels={{ primary: 'Successful', secondary: 'Errors' }}
|
||||
color="#22c55e"
|
||||
secondaryColor="#ef4444"
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Token Usage */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">Token Usage</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total Tokens"
|
||||
value={stats.tokens.totals.totalTokens}
|
||||
icon="🪙"
|
||||
color="text-accent"
|
||||
/>
|
||||
<StatCard
|
||||
label="Input Tokens"
|
||||
value={stats.tokens.totals.inputTokens}
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
label="Output Tokens"
|
||||
value={stats.tokens.totals.outputTokens}
|
||||
color="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Est. Cost"
|
||||
value={Number.parseFloat(stats.tokens.totals.estimatedCostUsd)}
|
||||
prefix="$"
|
||||
decimals={2}
|
||||
color="text-warning"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">Top Categories</h2>
|
||||
<ChartCard>
|
||||
<BarChart data={categoryData} width={600} height={400} horizontal showValues />
|
||||
</ChartCard>
|
||||
</section>
|
||||
|
||||
{/* Sync Status */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">Sync Operations</h2>
|
||||
<div className="bg-surface border border-border rounded-xl p-6">
|
||||
<div className="space-y-4">
|
||||
{stats.sync.recentOperations.slice(0, 5).map((op, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-3 bg-surface-secondary rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
op.status === 'success'
|
||||
? 'bg-success'
|
||||
: op.status === 'partial'
|
||||
? 'bg-warning'
|
||||
: 'bg-error'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium text-foreground capitalize">{op.source}</span>
|
||||
<span className="text-foreground-tertiary text-sm">
|
||||
{new Date(op.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-success">+{op.processed} processed</span>
|
||||
{op.skipped > 0 && (
|
||||
<span className="text-foreground-tertiary">{op.skipped} skipped</span>
|
||||
)}
|
||||
{op.errors > 0 && <span className="text-error">{op.errors} errors</span>}
|
||||
{op.durationMs && (
|
||||
<span className="text-foreground-tertiary">{op.durationMs}ms</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
decimals = 0,
|
||||
color = 'text-foreground',
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
decimals?: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-xl p-4 hover:border-border-hover transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{icon && <span className="text-lg">{icon}</span>}
|
||||
<span className="text-sm text-foreground-secondary">{label}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${color}`}>
|
||||
<AnimatedCounter value={value} prefix={prefix} suffix={suffix} decimals={decimals} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chart Card Wrapper
|
||||
function ChartCard({ title, children }: { title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-xl p-6">
|
||||
{title && <h4 className="text-sm font-semibold text-foreground mb-4">{title}</h4>}
|
||||
<div className="flex justify-center">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -65,6 +65,11 @@ export function AppHeader(): React.ReactElement {
|
|||
FAQ
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/stats">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Stats
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/changelog">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Changelog
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const navLinks = [
|
|||
{ href: '/spec', label: 'Spec' },
|
||||
{ href: '/sdk', label: 'SDK' },
|
||||
{ href: '/faq', label: 'FAQ' },
|
||||
{ href: '/stats', label: 'Stats' },
|
||||
{ href: '/changelog', label: 'Changelog' },
|
||||
];
|
||||
|
||||
|
|
|
|||
74
apps/web/src/components/stats/AnimatedCounter.tsx
Normal file
74
apps/web/src/components/stats/AnimatedCounter.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface AnimatedCounterProps {
|
||||
value: number;
|
||||
duration?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
decimals?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnimatedCounter({
|
||||
value,
|
||||
duration = 1500,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
decimals = 0,
|
||||
className = '',
|
||||
}: AnimatedCounterProps): React.ReactElement {
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const startValue = 0;
|
||||
const endValue = value;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTimeRef.current) {
|
||||
startTimeRef.current = timestamp;
|
||||
}
|
||||
|
||||
const elapsed = timestamp - startTimeRef.current;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function (ease-out-expo)
|
||||
const easeOutExpo = 1 - Math.pow(2, -10 * progress);
|
||||
const current = startValue + (endValue - startValue) * easeOutExpo;
|
||||
|
||||
setDisplayValue(current);
|
||||
|
||||
if (progress < 1) {
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, duration]);
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (decimals > 0) {
|
||||
return num.toFixed(decimals);
|
||||
}
|
||||
|
||||
// Format with commas
|
||||
return Math.round(num).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{prefix}
|
||||
{formatNumber(displayValue)}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
330
apps/web/src/components/stats/AreaChart.tsx
Normal file
330
apps/web/src/components/stats/AreaChart.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
'use client';
|
||||
|
||||
import * as d3 from 'd3';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface DataPoint {
|
||||
date: Date | string;
|
||||
value: number;
|
||||
secondaryValue?: number;
|
||||
}
|
||||
|
||||
interface AreaChartProps {
|
||||
data: DataPoint[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
title?: string;
|
||||
color?: string;
|
||||
secondaryColor?: string;
|
||||
showArea?: boolean;
|
||||
showSecondary?: boolean;
|
||||
labels?: { primary: string; secondary?: string };
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
function getThemeColors(element: HTMLElement) {
|
||||
const styles = getComputedStyle(element);
|
||||
return {
|
||||
background: styles.getPropertyValue('--color-background').trim() || '#ffffff',
|
||||
foreground: styles.getPropertyValue('--color-foreground').trim() || '#0a0a0a',
|
||||
foregroundSecondary:
|
||||
styles.getPropertyValue('--color-foreground-secondary').trim() || '#525252',
|
||||
foregroundTertiary: styles.getPropertyValue('--color-foreground-tertiary').trim() || '#737373',
|
||||
border: styles.getPropertyValue('--color-border').trim() || '#e5e5e5',
|
||||
primary: styles.getPropertyValue('--color-primary').trim() || '#2563eb',
|
||||
success: '#22c55e',
|
||||
error: '#ef4444',
|
||||
};
|
||||
}
|
||||
|
||||
export function AreaChart({
|
||||
data,
|
||||
width = 500,
|
||||
height = 250,
|
||||
title,
|
||||
color,
|
||||
secondaryColor,
|
||||
showArea = true,
|
||||
showSecondary = false,
|
||||
labels,
|
||||
dateFormat = '%b %d',
|
||||
}: AreaChartProps): React.ReactElement {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredPoint, setHoveredPoint] = useState<DataPoint | null>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current || data.length === 0) return;
|
||||
|
||||
const colors = getThemeColors(containerRef.current);
|
||||
const lineColor = color || colors.primary;
|
||||
const secondLine = secondaryColor || colors.error;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
|
||||
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||
const innerWidth = width - margin.left - margin.right;
|
||||
const innerHeight = height - margin.top - margin.bottom;
|
||||
|
||||
// Parse dates
|
||||
const parsedData = data.map((d) => ({
|
||||
...d,
|
||||
date: typeof d.date === 'string' ? new Date(d.date) : d.date,
|
||||
}));
|
||||
|
||||
const g = svg
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.append('g')
|
||||
.attr('transform', `translate(${margin.left}, ${margin.top})`);
|
||||
|
||||
// Scales
|
||||
const x = d3
|
||||
.scaleTime()
|
||||
.domain(d3.extent(parsedData, (d) => d.date) as [Date, Date])
|
||||
.range([0, innerWidth]);
|
||||
|
||||
const maxY = d3.max(parsedData, (d) => Math.max(d.value, d.secondaryValue || 0)) || 0;
|
||||
|
||||
const y = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxY * 1.1])
|
||||
.range([innerHeight, 0]);
|
||||
|
||||
// Grid lines
|
||||
g.selectAll('.grid-line')
|
||||
.data(y.ticks(5))
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('class', 'grid-line')
|
||||
.attr('x1', 0)
|
||||
.attr('x2', innerWidth)
|
||||
.attr('y1', (d) => y(d))
|
||||
.attr('y2', (d) => y(d))
|
||||
.attr('stroke', colors.border)
|
||||
.attr('stroke-dasharray', '3,3')
|
||||
.style('opacity', 0.5);
|
||||
|
||||
// Area generator
|
||||
const area = d3
|
||||
.area<DataPoint & { date: Date }>()
|
||||
.x((d) => x(d.date))
|
||||
.y0(innerHeight)
|
||||
.y1((d) => y(d.value))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
// Line generator
|
||||
const line = d3
|
||||
.line<DataPoint & { date: Date }>()
|
||||
.x((d) => x(d.date))
|
||||
.y((d) => y(d.value))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
const secondaryLine = d3
|
||||
.line<DataPoint & { date: Date }>()
|
||||
.x((d) => x(d.date))
|
||||
.y((d) => y(d.secondaryValue || 0))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
// Gradient
|
||||
const gradient = svg
|
||||
.append('defs')
|
||||
.append('linearGradient')
|
||||
.attr('id', 'area-gradient')
|
||||
.attr('x1', '0%')
|
||||
.attr('y1', '0%')
|
||||
.attr('x2', '0%')
|
||||
.attr('y2', '100%');
|
||||
|
||||
gradient
|
||||
.append('stop')
|
||||
.attr('offset', '0%')
|
||||
.attr('stop-color', lineColor)
|
||||
.attr('stop-opacity', 0.3);
|
||||
gradient
|
||||
.append('stop')
|
||||
.attr('offset', '100%')
|
||||
.attr('stop-color', lineColor)
|
||||
.attr('stop-opacity', 0);
|
||||
|
||||
// Draw area
|
||||
if (showArea) {
|
||||
const areaPath = g
|
||||
.append('path')
|
||||
.datum(parsedData)
|
||||
.attr('fill', 'url(#area-gradient)')
|
||||
.attr('d', area);
|
||||
|
||||
// Animate area
|
||||
const totalLength = areaPath.node()?.getTotalLength() || 0;
|
||||
areaPath
|
||||
.attr('stroke-dasharray', `${totalLength} ${totalLength}`)
|
||||
.attr('stroke-dashoffset', totalLength)
|
||||
.transition()
|
||||
.duration(1200)
|
||||
.ease(d3.easeQuadOut)
|
||||
.attr('stroke-dashoffset', 0);
|
||||
}
|
||||
|
||||
// Draw primary line
|
||||
const linePath = g
|
||||
.append('path')
|
||||
.datum(parsedData)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', lineColor)
|
||||
.attr('stroke-width', 2.5)
|
||||
.attr('d', line);
|
||||
|
||||
// Animate line
|
||||
const lineLength = linePath.node()?.getTotalLength() || 0;
|
||||
linePath
|
||||
.attr('stroke-dasharray', `${lineLength} ${lineLength}`)
|
||||
.attr('stroke-dashoffset', lineLength)
|
||||
.transition()
|
||||
.duration(1200)
|
||||
.ease(d3.easeQuadOut)
|
||||
.attr('stroke-dashoffset', 0);
|
||||
|
||||
// Draw secondary line
|
||||
if (showSecondary) {
|
||||
const secondPath = g
|
||||
.append('path')
|
||||
.datum(parsedData)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', secondLine)
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-dasharray', '5,3')
|
||||
.attr('d', secondaryLine);
|
||||
|
||||
const secondLength = secondPath.node()?.getTotalLength() || 0;
|
||||
secondPath
|
||||
.attr('stroke-dasharray', `${secondLength} ${secondLength}`)
|
||||
.attr('stroke-dashoffset', secondLength)
|
||||
.transition()
|
||||
.duration(1200)
|
||||
.delay(200)
|
||||
.ease(d3.easeQuadOut)
|
||||
.attr('stroke-dashoffset', 0);
|
||||
}
|
||||
|
||||
// Draw dots
|
||||
g.selectAll('.dot')
|
||||
.data(parsedData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('class', 'dot')
|
||||
.attr('cx', (d) => x(d.date))
|
||||
.attr('cy', (d) => y(d.value))
|
||||
.attr('r', 0)
|
||||
.attr('fill', lineColor)
|
||||
.attr('stroke', colors.background)
|
||||
.attr('stroke-width', 2)
|
||||
.style('cursor', 'pointer')
|
||||
.on('mouseenter', function (event, d) {
|
||||
setHoveredPoint(d);
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setMousePos({ x: event.clientX - rect.left, y: event.clientY - rect.top });
|
||||
}
|
||||
d3.select(this).transition().duration(150).attr('r', 6);
|
||||
})
|
||||
.on('mouseleave', function () {
|
||||
setHoveredPoint(null);
|
||||
d3.select(this).transition().duration(150).attr('r', 4);
|
||||
})
|
||||
.transition()
|
||||
.duration(400)
|
||||
.delay((_, i) => 1000 + i * 50)
|
||||
.attr('r', 4);
|
||||
|
||||
// X axis
|
||||
g.append('g')
|
||||
.attr('transform', `translate(0, ${innerHeight})`)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(Math.min(data.length, 7))
|
||||
.tickFormat((d) => d3.timeFormat(dateFormat)(d as Date))
|
||||
)
|
||||
.call((g) => g.select('.domain').attr('stroke', colors.border))
|
||||
.call((g) => g.selectAll('.tick line').attr('stroke', colors.border))
|
||||
.call((g) =>
|
||||
g
|
||||
.selectAll('.tick text')
|
||||
.attr('fill', colors.foregroundTertiary)
|
||||
.style('font-size', '0.7rem')
|
||||
);
|
||||
|
||||
// Y axis
|
||||
g.append('g')
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(5)
|
||||
.tickFormat((d) => d.toLocaleString())
|
||||
)
|
||||
.call((g) => g.select('.domain').remove())
|
||||
.call((g) => g.selectAll('.tick line').remove())
|
||||
.call((g) =>
|
||||
g
|
||||
.selectAll('.tick text')
|
||||
.attr('fill', colors.foregroundTertiary)
|
||||
.style('font-size', '0.7rem')
|
||||
);
|
||||
}, [data, width, height, color, secondaryColor, showArea, showSecondary, dateFormat]);
|
||||
|
||||
const themeColors = containerRef.current ? getThemeColors(containerRef.current) : null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{title && <h4 className="text-sm font-semibold text-foreground mb-3">{title}</h4>}
|
||||
{labels && (
|
||||
<div className="flex gap-4 mb-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-3 h-0.5 rounded"
|
||||
style={{ backgroundColor: color || themeColors?.primary }}
|
||||
/>
|
||||
<span className="text-foreground-secondary">{labels.primary}</span>
|
||||
</div>
|
||||
{showSecondary && labels.secondary && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-3 h-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: secondaryColor || themeColors?.error,
|
||||
borderStyle: 'dashed',
|
||||
}}
|
||||
/>
|
||||
<span className="text-foreground-secondary">{labels.secondary}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<svg ref={svgRef} />
|
||||
{hoveredPoint && (
|
||||
<div
|
||||
className="absolute px-3 py-2 bg-surface border border-border rounded-lg shadow-lg text-sm z-10 pointer-events-none"
|
||||
style={{
|
||||
left: Math.min(mousePos.x + 10, width - 150),
|
||||
top: mousePos.y - 60,
|
||||
}}
|
||||
>
|
||||
<div className="text-xs text-foreground-tertiary mb-1">
|
||||
{typeof hoveredPoint.date === 'string'
|
||||
? hoveredPoint.date
|
||||
: d3.timeFormat('%b %d, %Y')(hoveredPoint.date as Date)}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">{hoveredPoint.value.toLocaleString()}</div>
|
||||
{showSecondary && hoveredPoint.secondaryValue !== undefined && (
|
||||
<div className="text-error text-xs mt-0.5">
|
||||
{hoveredPoint.secondaryValue.toLocaleString()} errors
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
apps/web/src/components/stats/BarChart.tsx
Normal file
238
apps/web/src/components/stats/BarChart.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
'use client';
|
||||
|
||||
import * as d3 from 'd3';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface BarChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
title?: string;
|
||||
horizontal?: boolean;
|
||||
color?: string;
|
||||
showValues?: boolean;
|
||||
}
|
||||
|
||||
function getThemeColors(element: HTMLElement) {
|
||||
const styles = getComputedStyle(element);
|
||||
return {
|
||||
background: styles.getPropertyValue('--color-background').trim() || '#ffffff',
|
||||
foreground: styles.getPropertyValue('--color-foreground').trim() || '#0a0a0a',
|
||||
foregroundSecondary:
|
||||
styles.getPropertyValue('--color-foreground-secondary').trim() || '#525252',
|
||||
foregroundTertiary: styles.getPropertyValue('--color-foreground-tertiary').trim() || '#737373',
|
||||
border: styles.getPropertyValue('--color-border').trim() || '#e5e5e5',
|
||||
primary: styles.getPropertyValue('--color-primary').trim() || '#2563eb',
|
||||
};
|
||||
}
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
width = 400,
|
||||
height = 300,
|
||||
title,
|
||||
horizontal = false,
|
||||
color,
|
||||
showValues = true,
|
||||
}: BarChartProps): React.ReactElement {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredBar, setHoveredBar] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current || data.length === 0) return;
|
||||
|
||||
const colors = getThemeColors(containerRef.current);
|
||||
const barColor = color || colors.primary;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
|
||||
const margin = horizontal
|
||||
? { top: 20, right: 40, bottom: 20, left: 100 }
|
||||
: { top: 20, right: 20, bottom: 60, left: 50 };
|
||||
|
||||
const innerWidth = width - margin.left - margin.right;
|
||||
const innerHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.append('g')
|
||||
.attr('transform', `translate(${margin.left}, ${margin.top})`);
|
||||
|
||||
const maxValue = d3.max(data, (d) => d.value) || 0;
|
||||
|
||||
if (horizontal) {
|
||||
// Horizontal bar chart
|
||||
const y = d3
|
||||
.scaleBand()
|
||||
.domain(data.map((d) => d.label))
|
||||
.range([0, innerHeight])
|
||||
.padding(0.3);
|
||||
|
||||
const x = d3.scaleLinear().domain([0, maxValue]).range([0, innerWidth]);
|
||||
|
||||
// Bars
|
||||
g.selectAll('rect')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('rect')
|
||||
.attr('y', (d) => y(d.label) || 0)
|
||||
.attr('height', y.bandwidth())
|
||||
.attr('fill', barColor)
|
||||
.attr('rx', 4)
|
||||
.style('cursor', 'pointer')
|
||||
.style('opacity', 0.85)
|
||||
.on('mouseenter', function (_, d) {
|
||||
setHoveredBar(d.label);
|
||||
d3.select(this).transition().duration(150).style('opacity', 1);
|
||||
})
|
||||
.on('mouseleave', function () {
|
||||
setHoveredBar(null);
|
||||
d3.select(this).transition().duration(150).style('opacity', 0.85);
|
||||
})
|
||||
.attr('x', 0)
|
||||
.attr('width', 0)
|
||||
.transition()
|
||||
.duration(800)
|
||||
.delay((_, i) => i * 50)
|
||||
.ease(d3.easeElasticOut.amplitude(1).period(0.5))
|
||||
.attr('width', (d) => x(d.value));
|
||||
|
||||
// Labels
|
||||
g.selectAll('.label')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('class', 'label')
|
||||
.attr('x', -8)
|
||||
.attr('y', (d) => (y(d.label) || 0) + y.bandwidth() / 2)
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('fill', colors.foregroundSecondary)
|
||||
.style('font-size', '0.75rem')
|
||||
.text((d) => (d.label.length > 12 ? d.label.slice(0, 12) + '...' : d.label));
|
||||
|
||||
// Values
|
||||
if (showValues) {
|
||||
g.selectAll('.value')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('class', 'value')
|
||||
.attr('x', (d) => x(d.value) + 6)
|
||||
.attr('y', (d) => (y(d.label) || 0) + y.bandwidth() / 2)
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('fill', colors.foregroundTertiary)
|
||||
.style('font-size', '0.7rem')
|
||||
.style('opacity', 0)
|
||||
.text((d) => d.value.toLocaleString())
|
||||
.transition()
|
||||
.duration(400)
|
||||
.delay((_, i) => 600 + i * 50)
|
||||
.style('opacity', 1);
|
||||
}
|
||||
} else {
|
||||
// Vertical bar chart
|
||||
const x = d3
|
||||
.scaleBand()
|
||||
.domain(data.map((d) => d.label))
|
||||
.range([0, innerWidth])
|
||||
.padding(0.3);
|
||||
|
||||
const y = d3.scaleLinear().domain([0, maxValue]).range([innerHeight, 0]);
|
||||
|
||||
// Grid lines
|
||||
g.selectAll('.grid-line')
|
||||
.data(y.ticks(5))
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('class', 'grid-line')
|
||||
.attr('x1', 0)
|
||||
.attr('x2', innerWidth)
|
||||
.attr('y1', (d) => y(d))
|
||||
.attr('y2', (d) => y(d))
|
||||
.attr('stroke', colors.border)
|
||||
.attr('stroke-dasharray', '3,3')
|
||||
.style('opacity', 0.5);
|
||||
|
||||
// Bars
|
||||
g.selectAll('rect')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('rect')
|
||||
.attr('x', (d) => x(d.label) || 0)
|
||||
.attr('width', x.bandwidth())
|
||||
.attr('fill', barColor)
|
||||
.attr('rx', 4)
|
||||
.style('cursor', 'pointer')
|
||||
.style('opacity', 0.85)
|
||||
.on('mouseenter', function (_, d) {
|
||||
setHoveredBar(d.label);
|
||||
d3.select(this).transition().duration(150).style('opacity', 1);
|
||||
})
|
||||
.on('mouseleave', function () {
|
||||
setHoveredBar(null);
|
||||
d3.select(this).transition().duration(150).style('opacity', 0.85);
|
||||
})
|
||||
.attr('y', innerHeight)
|
||||
.attr('height', 0)
|
||||
.transition()
|
||||
.duration(800)
|
||||
.delay((_, i) => i * 50)
|
||||
.ease(d3.easeElasticOut.amplitude(1).period(0.5))
|
||||
.attr('y', (d) => y(d.value))
|
||||
.attr('height', (d) => innerHeight - y(d.value));
|
||||
|
||||
// X axis labels
|
||||
g.selectAll('.x-label')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('class', 'x-label')
|
||||
.attr('x', (d) => (x(d.label) || 0) + x.bandwidth() / 2)
|
||||
.attr('y', innerHeight + 20)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', colors.foregroundSecondary)
|
||||
.style('font-size', '0.7rem')
|
||||
.text((d) => (d.label.length > 8 ? d.label.slice(0, 8) + '...' : d.label))
|
||||
.attr(
|
||||
'transform',
|
||||
(d) => `rotate(-45, ${(x(d.label) || 0) + x.bandwidth() / 2}, ${innerHeight + 20})`
|
||||
);
|
||||
|
||||
// Y axis
|
||||
g.append('g')
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(5)
|
||||
.tickFormat((d) => d.toLocaleString())
|
||||
)
|
||||
.call((g) => g.select('.domain').remove())
|
||||
.call((g) => g.selectAll('.tick line').remove())
|
||||
.call((g) =>
|
||||
g
|
||||
.selectAll('.tick text')
|
||||
.attr('fill', colors.foregroundTertiary)
|
||||
.style('font-size', '0.7rem')
|
||||
);
|
||||
}
|
||||
}, [data, width, height, horizontal, color, showValues]);
|
||||
|
||||
const hoveredData = data.find((d) => d.label === hoveredBar);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{title && <h4 className="text-sm font-semibold text-foreground mb-3">{title}</h4>}
|
||||
<svg ref={svgRef} />
|
||||
{hoveredData && (
|
||||
<div className="absolute top-2 right-2 px-3 py-1.5 bg-surface border border-border rounded-lg shadow-lg text-sm z-10">
|
||||
<span className="font-medium text-foreground">{hoveredData.label}:</span>{' '}
|
||||
<span className="text-foreground-secondary">{hoveredData.value.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
apps/web/src/components/stats/DonutChart.tsx
Normal file
160
apps/web/src/components/stats/DonutChart.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
'use client';
|
||||
|
||||
import * as d3 from 'd3';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface DonutChartProps {
|
||||
data: { label: string; value: number; color: string }[];
|
||||
size?: number;
|
||||
innerRadius?: number;
|
||||
title?: string;
|
||||
centerValue?: string | number;
|
||||
centerLabel?: string;
|
||||
}
|
||||
|
||||
function getThemeColors(element: HTMLElement) {
|
||||
const styles = getComputedStyle(element);
|
||||
return {
|
||||
background: styles.getPropertyValue('--color-background').trim() || '#ffffff',
|
||||
foreground: styles.getPropertyValue('--color-foreground').trim() || '#0a0a0a',
|
||||
foregroundSecondary:
|
||||
styles.getPropertyValue('--color-foreground-secondary').trim() || '#525252',
|
||||
};
|
||||
}
|
||||
|
||||
export function DonutChart({
|
||||
data,
|
||||
size = 200,
|
||||
innerRadius = 0.6,
|
||||
title,
|
||||
centerValue,
|
||||
centerLabel,
|
||||
}: DonutChartProps): React.ReactElement {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredSlice, setHoveredSlice] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current || data.length === 0) return;
|
||||
|
||||
const colors = getThemeColors(containerRef.current);
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
|
||||
const radius = size / 2;
|
||||
const innerR = radius * innerRadius;
|
||||
|
||||
const g = svg
|
||||
.attr('width', size)
|
||||
.attr('height', size)
|
||||
.append('g')
|
||||
.attr('transform', `translate(${radius}, ${radius})`);
|
||||
|
||||
const pie = d3
|
||||
.pie<{ label: string; value: number; color: string }>()
|
||||
.value((d) => d.value)
|
||||
.sort(null)
|
||||
.padAngle(0.02);
|
||||
|
||||
const arc = d3
|
||||
.arc<d3.PieArcDatum<{ label: string; value: number; color: string }>>()
|
||||
.innerRadius(innerR)
|
||||
.outerRadius(radius - 10);
|
||||
|
||||
const hoverArc = d3
|
||||
.arc<d3.PieArcDatum<{ label: string; value: number; color: string }>>()
|
||||
.innerRadius(innerR)
|
||||
.outerRadius(radius - 5);
|
||||
|
||||
const arcs = pie(data);
|
||||
|
||||
// Draw slices with animation
|
||||
g.selectAll('path')
|
||||
.data(arcs)
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('fill', (d) => d.data.color)
|
||||
.attr('stroke', colors.background)
|
||||
.attr('stroke-width', 2)
|
||||
.style('cursor', 'pointer')
|
||||
.style('opacity', 0.9)
|
||||
.on('mouseenter', function (_, d) {
|
||||
setHoveredSlice(d.data.label);
|
||||
d3.select(this)
|
||||
.transition()
|
||||
.duration(200)
|
||||
.attr('d', hoverArc as unknown as string)
|
||||
.style('opacity', 1);
|
||||
})
|
||||
.on('mouseleave', function () {
|
||||
setHoveredSlice(null);
|
||||
d3.select(this)
|
||||
.transition()
|
||||
.duration(200)
|
||||
.attr('d', arc as unknown as string)
|
||||
.style('opacity', 0.9);
|
||||
})
|
||||
.transition()
|
||||
.duration(800)
|
||||
.attrTween('d', (d) => {
|
||||
const interpolate = d3.interpolate({ startAngle: 0, endAngle: 0 }, d);
|
||||
return (t) => arc(interpolate(t)) || '';
|
||||
});
|
||||
|
||||
// Center text
|
||||
if (centerValue !== undefined) {
|
||||
g.append('text')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('dy', centerLabel ? '-0.3em' : '0')
|
||||
.attr('fill', colors.foreground)
|
||||
.style('font-size', '1.5rem')
|
||||
.style('font-weight', 'bold')
|
||||
.text(String(centerValue));
|
||||
|
||||
if (centerLabel) {
|
||||
g.append('text')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('dy', '1.2em')
|
||||
.attr('fill', colors.foregroundSecondary)
|
||||
.style('font-size', '0.75rem')
|
||||
.text(centerLabel);
|
||||
}
|
||||
}
|
||||
}, [data, size, innerRadius, centerValue, centerLabel]);
|
||||
|
||||
const total = data.reduce((sum, d) => sum + d.value, 0);
|
||||
const hoveredData = data.find((d) => d.label === hoveredSlice);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col items-center">
|
||||
{title && <h4 className="text-sm font-semibold text-foreground mb-3">{title}</h4>}
|
||||
<div className="relative">
|
||||
<svg ref={svgRef} />
|
||||
{hoveredData && (
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 bg-surface border border-border rounded-lg shadow-lg text-sm whitespace-nowrap z-10">
|
||||
<span className="font-medium text-foreground">{hoveredData.label}:</span>{' '}
|
||||
<span className="text-foreground-secondary">
|
||||
{hoveredData.value.toLocaleString()} ({((hoveredData.value / total) * 100).toFixed(1)}
|
||||
%)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-4">
|
||||
{data.map((d) => (
|
||||
<div
|
||||
key={d.label}
|
||||
className={`flex items-center gap-1.5 text-xs transition-opacity ${
|
||||
hoveredSlice && hoveredSlice !== d.label ? 'opacity-50' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: d.color }} />
|
||||
<span className="text-foreground-secondary">{d.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
apps/web/src/lib/ai-agent/tool-executor-agent.test.ts
Normal file
279
apps/web/src/lib/ai-agent/tool-executor-agent.test.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* Tests for AI SDK v6 integration
|
||||
* Verifies the upgrade from beta to stable versions works correctly
|
||||
*/
|
||||
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { type ModelMessage, generateText } from 'ai';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateTokenBreakdown,
|
||||
createToolDefinition,
|
||||
tpmjsParamsToZodSchema,
|
||||
} from './tool-executor-agent';
|
||||
|
||||
describe('AI SDK v6 Integration', () => {
|
||||
describe('Type compatibility', () => {
|
||||
it('should use ModelMessage type correctly', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hello world',
|
||||
},
|
||||
];
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
const firstMessage = messages[0];
|
||||
expect(firstMessage).toBeDefined();
|
||||
expect(firstMessage?.role).toBe('user');
|
||||
expect(firstMessage?.content).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should create openai model instance', () => {
|
||||
// This will fail if OPENAI_API_KEY is not set, but that's expected
|
||||
// The important thing is that the import works
|
||||
const model = openai('gpt-4-turbo');
|
||||
expect(model).toBeDefined();
|
||||
expect(model.modelId).toBe('gpt-4-turbo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tpmjsParamsToZodSchema', () => {
|
||||
it('should convert string parameter to Zod string schema', () => {
|
||||
const params = [
|
||||
{
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The URL to fetch',
|
||||
},
|
||||
];
|
||||
|
||||
const schema = tpmjsParamsToZodSchema(params);
|
||||
expect(schema).toBeDefined();
|
||||
|
||||
// Test that valid data passes
|
||||
const result = schema.safeParse({ url: 'https://example.com' });
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Test that invalid data fails
|
||||
const invalidResult = schema.safeParse({ url: 123 });
|
||||
expect(invalidResult.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should convert number parameter to Zod number schema', () => {
|
||||
const params = [
|
||||
{
|
||||
name: 'count',
|
||||
type: 'number',
|
||||
required: true,
|
||||
description: 'Number of items',
|
||||
},
|
||||
];
|
||||
|
||||
const schema = tpmjsParamsToZodSchema(params);
|
||||
const result = schema.safeParse({ count: 42 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert boolean parameter to Zod boolean schema', () => {
|
||||
const params = [
|
||||
{
|
||||
name: 'enabled',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
description: 'Whether feature is enabled',
|
||||
},
|
||||
];
|
||||
|
||||
const schema = tpmjsParamsToZodSchema(params);
|
||||
const result = schema.safeParse({ enabled: true });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle optional parameters', () => {
|
||||
const params = [
|
||||
{
|
||||
name: 'optional',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Optional parameter',
|
||||
},
|
||||
];
|
||||
|
||||
const schema = tpmjsParamsToZodSchema(params);
|
||||
|
||||
// Should pass without the optional field
|
||||
const result = schema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle array types', () => {
|
||||
const params = [
|
||||
{
|
||||
name: 'items',
|
||||
type: 'string[]',
|
||||
required: true,
|
||||
description: 'List of items',
|
||||
},
|
||||
];
|
||||
|
||||
const schema = tpmjsParamsToZodSchema(params);
|
||||
const result = schema.safeParse({ items: ['a', 'b', 'c'] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle enum/union types', () => {
|
||||
const params = [
|
||||
{
|
||||
name: 'format',
|
||||
type: "'markdown' | 'mdx'",
|
||||
required: true,
|
||||
description: 'Output format',
|
||||
},
|
||||
];
|
||||
|
||||
const schema = tpmjsParamsToZodSchema(params);
|
||||
|
||||
const validResult = schema.safeParse({ format: 'markdown' });
|
||||
expect(validResult.success).toBe(true);
|
||||
|
||||
const invalidResult = schema.safeParse({ format: 'html' });
|
||||
expect(invalidResult.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createToolDefinition', () => {
|
||||
it('should create a valid AI SDK tool definition', () => {
|
||||
const mockTool = {
|
||||
id: 'test-id',
|
||||
name: 'helloWorld',
|
||||
description: 'Says hello to the world',
|
||||
parameters: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Name to greet',
|
||||
},
|
||||
],
|
||||
returns: { type: 'string', description: 'Greeting message' },
|
||||
packageId: 'pkg-id',
|
||||
package: {
|
||||
id: 'pkg-id',
|
||||
npmPackageName: '@tpmjs/hello',
|
||||
npmVersion: '1.0.0',
|
||||
description: 'Hello world tool',
|
||||
tpmjsVersion: '1.0.0',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tier: 'rich',
|
||||
npmDownloadsLastMonth: 100,
|
||||
qualityScore: 0.8,
|
||||
category: 'utility',
|
||||
discoveryMethod: 'keyword',
|
||||
manualToolsJson: null,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock data for testing
|
||||
const toolDef = createToolDefinition(mockTool as any);
|
||||
|
||||
expect(toolDef).toBeDefined();
|
||||
expect(toolDef.description).toBe('Says hello to the world');
|
||||
expect(toolDef.inputSchema).toBeDefined();
|
||||
expect(typeof toolDef.execute).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle tools with no parameters', () => {
|
||||
const mockTool = {
|
||||
id: 'test-id',
|
||||
name: 'noParams',
|
||||
description: 'Tool with no parameters',
|
||||
parameters: [],
|
||||
returns: { type: 'string', description: 'Result' },
|
||||
packageId: 'pkg-id',
|
||||
package: {
|
||||
id: 'pkg-id',
|
||||
npmPackageName: '@tpmjs/no-params',
|
||||
npmVersion: '1.0.0',
|
||||
description: 'No params tool',
|
||||
tpmjsVersion: '1.0.0',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tier: 'minimal',
|
||||
npmDownloadsLastMonth: 0,
|
||||
qualityScore: 0.4,
|
||||
category: 'utility',
|
||||
discoveryMethod: 'keyword',
|
||||
manualToolsJson: null,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock data for testing
|
||||
const toolDef = createToolDefinition(mockTool as any);
|
||||
|
||||
expect(toolDef).toBeDefined();
|
||||
expect(toolDef.inputSchema).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTokenBreakdown', () => {
|
||||
it('should calculate token breakdown correctly', () => {
|
||||
const breakdown = calculateTokenBreakdown(
|
||||
'What is the weather?', // userPrompt
|
||||
'Get weather for a location', // toolDescription
|
||||
[{ name: 'location', type: 'string', required: true, description: 'City name' }],
|
||||
{ type: 'object', description: 'Weather data' },
|
||||
'{"temperature": 72, "condition": "sunny"}' // output
|
||||
);
|
||||
|
||||
expect(breakdown).toBeDefined();
|
||||
expect(breakdown.inputTokens).toBeGreaterThan(0);
|
||||
expect(breakdown.toolDescTokens).toBeGreaterThan(0);
|
||||
expect(breakdown.schemaTokens).toBeGreaterThan(0);
|
||||
expect(breakdown.outputTokens).toBeGreaterThan(0);
|
||||
expect(breakdown.totalTokens).toBe(
|
||||
breakdown.inputTokens +
|
||||
breakdown.toolDescTokens +
|
||||
breakdown.schemaTokens +
|
||||
breakdown.outputTokens
|
||||
);
|
||||
expect(breakdown.estimatedCost).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI SDK v6 API Integration', () => {
|
||||
const hasApiKey = !!process.env.OPENAI_API_KEY;
|
||||
|
||||
beforeAll(() => {
|
||||
if (!hasApiKey) {
|
||||
console.log('⚠️ OPENAI_API_KEY not set - skipping API integration tests');
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!hasApiKey)(
|
||||
'should successfully call generateText with a simple prompt',
|
||||
async () => {
|
||||
const result = await generateText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Say "hello" and nothing else.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.text).toBeDefined();
|
||||
expect(result.text.toLowerCase()).toContain('hello');
|
||||
},
|
||||
30000
|
||||
);
|
||||
});
|
||||
20
apps/web/src/test/setup.ts
Normal file
20
apps/web/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Vitest test setup for @tpmjs/web
|
||||
* Loads environment variables and configures test environment
|
||||
*/
|
||||
|
||||
import { resolve } from 'path';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
// Load environment variables from .env.local or .env
|
||||
config({ path: resolve(__dirname, '../../.env.local') });
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
// Validate required environment variables for AI tests
|
||||
const requiredEnvVars = ['OPENAI_API_KEY'];
|
||||
|
||||
for (const envVar of requiredEnvVars) {
|
||||
if (!process.env[envVar]) {
|
||||
console.warn(`⚠️ Warning: ${envVar} not set. Some tests may be skipped.`);
|
||||
}
|
||||
}
|
||||
17
apps/web/vitest.config.ts
Normal file
17
apps/web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
testTimeout: 30000, // 30s for API calls
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
|
@ -134,10 +134,10 @@ importers:
|
|||
version: 10.4.22(postcss@8.5.6)
|
||||
eslint:
|
||||
specifier: ^9.39.1
|
||||
version: 9.39.1(jiti@1.21.7)
|
||||
version: 9.39.1(jiti@2.6.1)
|
||||
eslint-config-next:
|
||||
specifier: ^16.0.4
|
||||
version: 16.0.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
|
||||
version: 16.0.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
postcss:
|
||||
specifier: ^8.5.1
|
||||
version: 8.5.6
|
||||
|
|
@ -259,12 +259,15 @@ importers:
|
|||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.4.22(postcss@8.5.6)
|
||||
dotenv:
|
||||
specifier: ^17.2.3
|
||||
version: 17.2.3
|
||||
eslint:
|
||||
specifier: ^9.39.1
|
||||
version: 9.39.1(jiti@2.6.1)
|
||||
version: 9.39.1(jiti@1.21.7)
|
||||
eslint-config-next:
|
||||
specifier: ^16.0.4
|
||||
version: 16.0.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
version: 16.0.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
|
||||
postcss:
|
||||
specifier: ^8.5.1
|
||||
version: 8.5.6
|
||||
|
|
@ -274,6 +277,9 @@ importers:
|
|||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(@types/node@22.19.1)(happy-dom@15.11.7)(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))
|
||||
|
||||
packages/config:
|
||||
dependencies:
|
||||
|
|
@ -3589,6 +3595,10 @@ packages:
|
|||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@17.2.3:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -9243,6 +9253,8 @@ snapshots:
|
|||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue