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:
Ajax Davis 2025-12-28 17:08:19 +10:00
parent 9855425be4
commit c189bd366a
18 changed files with 3285 additions and 40 deletions

View file

@ -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"
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View file

@ -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 }
);

View 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 }
);
}
}

View 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 }
);
}
}

View 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;
}

View 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>
);
}

View file

@ -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

View file

@ -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' },
];

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
);
});

View 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
View 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
View file

@ -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