From b4f6eb46b863097817f2c66e2f9d5f609f9041a8 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sun, 28 Dec 2025 17:08:19 +1000 Subject: [PATCH] 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 --- apps/web/package.json | 8 +- .../web/src/app/api/stats/executions/route.ts | 442 +++++++++++++++ apps/web/src/app/api/stats/health/route.ts | 330 ++++++++++++ apps/web/src/app/api/stats/route.ts | 377 +++++++++++-- apps/web/src/app/api/stats/sync/route.ts | 266 +++++++++ apps/web/src/app/api/stats/tools/route.ts | 238 +++++++++ apps/web/src/app/stats/layout.tsx | 16 + apps/web/src/app/stats/page.tsx | 504 ++++++++++++++++++ apps/web/src/components/AppHeader.tsx | 5 + apps/web/src/components/MobileMenu.tsx | 1 + .../src/components/stats/AnimatedCounter.tsx | 74 +++ apps/web/src/components/stats/AreaChart.tsx | 330 ++++++++++++ apps/web/src/components/stats/BarChart.tsx | 238 +++++++++ apps/web/src/components/stats/DonutChart.tsx | 160 ++++++ .../lib/ai-agent/tool-executor-agent.test.ts | 279 ++++++++++ apps/web/src/test/setup.ts | 20 + apps/web/vitest.config.ts | 17 + pnpm-lock.yaml | 20 +- 18 files changed, 3285 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/app/api/stats/executions/route.ts create mode 100644 apps/web/src/app/api/stats/health/route.ts create mode 100644 apps/web/src/app/api/stats/sync/route.ts create mode 100644 apps/web/src/app/api/stats/tools/route.ts create mode 100644 apps/web/src/app/stats/layout.tsx create mode 100644 apps/web/src/app/stats/page.tsx create mode 100644 apps/web/src/components/stats/AnimatedCounter.tsx create mode 100644 apps/web/src/components/stats/AreaChart.tsx create mode 100644 apps/web/src/components/stats/BarChart.tsx create mode 100644 apps/web/src/components/stats/DonutChart.tsx create mode 100644 apps/web/src/lib/ai-agent/tool-executor-agent.test.ts create mode 100644 apps/web/src/test/setup.ts create mode 100644 apps/web/vitest.config.ts diff --git a/apps/web/package.json b/apps/web/package.json index 0c38e36..c500f71 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } } diff --git a/apps/web/src/app/api/stats/executions/route.ts b/apps/web/src/app/api/stats/executions/route.ts new file mode 100644 index 0000000..cd2e0a4 --- /dev/null +++ b/apps/web/src/app/api/stats/executions/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/apps/web/src/app/api/stats/health/route.ts b/apps/web/src/app/api/stats/health/route.ts new file mode 100644 index 0000000..3143e16 --- /dev/null +++ b/apps/web/src/app/api/stats/health/route.ts @@ -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 } + ); + } +} diff --git a/apps/web/src/app/api/stats/route.ts b/apps/web/src/app/api/stats/route.ts index 79aa27e..8c7204b 100644 --- a/apps/web/src/app/api/stats/route.ts +++ b/apps/web/src/app/api/stats/route.ts @@ -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 = {}; + 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 = {}; + 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 = {}; + for (const checkpoint of syncCheckpoints) { + syncStatus[checkpoint.source] = { + lastRun: checkpoint.updatedAt, + ...(checkpoint.checkpoint as Record), + }; + } + + // 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)?.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 } ); diff --git a/apps/web/src/app/api/stats/sync/route.ts b/apps/web/src/app/api/stats/sync/route.ts new file mode 100644 index 0000000..15c6f98 --- /dev/null +++ b/apps/web/src/app/api/stats/sync/route.ts @@ -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 = {}; + for (const checkpoint of checkpoints) { + const data = checkpoint.checkpoint as Record; + 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)?.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)?.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 } + ); + } +} diff --git a/apps/web/src/app/api/stats/tools/route.ts b/apps/web/src/app/api/stats/tools/route.ts new file mode 100644 index 0000000..1110aa2 --- /dev/null +++ b/apps/web/src/app/api/stats/tools/route.ts @@ -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 = {}; + 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[]; + 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 = 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 } + ); + } +} diff --git a/apps/web/src/app/stats/layout.tsx b/apps/web/src/app/stats/layout.tsx new file mode 100644 index 0000000..a0d298b --- /dev/null +++ b/apps/web/src/app/stats/layout.tsx @@ -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; +} diff --git a/apps/web/src/app/stats/page.tsx b/apps/web/src/app/stats/page.tsx new file mode 100644 index 0000000..6dd419b --- /dev/null +++ b/apps/web/src/app/stats/page.tsx @@ -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; + }; + categories: Record; + 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; + 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 = { + 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(null); + const [executions, setExecutions] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading statistics...

+
+
+ ); + } + + if (error || !stats) { + return ( +
+
+

Failed to load statistics

+

{error || 'Unknown error'}

+
+
+ ); + } + + // 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 ( +
+ {/* Header */} +
+
+

Registry Statistics

+

+ Real-time metrics and analytics for the TPMJS tool registry +

+
+
+ +
+ {/* Overview Cards */} +
+

Overview

+
+ + + + +
+
+ + {/* Recent Activity */} +
+

Recent Activity

+
+ + + + +
+
+ + {/* Health & Quality Charts */} +
+

Health & Quality

+
+ + + + + + + + sum + d.value, 0)} + centerLabel="Scored" + /> + + + + +
+
+ + {/* Execution Stats */} +
+

Execution Statistics

+
+ + + + + +
+ + {dailyTrendData.length > 0 && ( + + + + )} +
+ + {/* Token Usage */} +
+

Token Usage

+
+ + + + +
+
+ + {/* Categories */} +
+

Top Categories

+ + + +
+ + {/* Sync Status */} +
+

Sync Operations

+
+
+ {stats.sync.recentOperations.slice(0, 5).map((op, i) => ( +
+
+ + {op.source} + + {new Date(op.timestamp).toLocaleString()} + +
+
+ +{op.processed} processed + {op.skipped > 0 && ( + {op.skipped} skipped + )} + {op.errors > 0 && {op.errors} errors} + {op.durationMs && ( + {op.durationMs}ms + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 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 ( +
+
+ {icon && {icon}} + {label} +
+
+ +
+
+ ); +} + +// Chart Card Wrapper +function ChartCard({ title, children }: { title?: string; children: React.ReactNode }) { + return ( +
+ {title &&

{title}

} +
{children}
+
+ ); +} diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index c6e6e44..914506c 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -65,6 +65,11 @@ export function AppHeader(): React.ReactElement { FAQ + + +