From cd895c10cbb83b13b3506043fe82e7668ccddeeb Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sun, 28 Dec 2025 17:19:46 +1000 Subject: [PATCH] feat: add historical stats tracking with daily snapshots - Add StatsSnapshot model to store daily registry metrics - Create /api/sync/stats-snapshot endpoint for daily cron captures - Add GET endpoint to retrieve historical snapshots (up to 365 days) - Update stats page with Historical Trends section showing: - Tools & packages growth over time - Health status trends - Daily executions history - NPM downloads trends - Add cron job running at midnight UTC daily - Fix PostgreSQL GROUP BY issue in quality distribution query --- .../src/app/api/sync/stats-snapshot/route.ts | 322 ++++++++++++++++++ apps/web/src/app/stats/page.tsx | 91 ++++- packages/db/prisma/schema.prisma | 58 ++++ vercel.json | 4 + 4 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/api/sync/stats-snapshot/route.ts diff --git a/apps/web/src/app/api/sync/stats-snapshot/route.ts b/apps/web/src/app/api/sync/stats-snapshot/route.ts new file mode 100644 index 0000000..4324107 --- /dev/null +++ b/apps/web/src/app/api/sync/stats-snapshot/route.ts @@ -0,0 +1,322 @@ +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +/** + * POST /api/sync/stats-snapshot + * Captures a daily snapshot of registry statistics for historical tracking. + * Should be run once per day via cron. + */ +export async function POST(request: NextRequest) { + const startTime = Date.now(); + + // Verify cron secret + const authHeader = request.headers.get('authorization'); + const cronSecret = process.env.CRON_SECRET; + + if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + // Get today's date at midnight UTC + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + // Check if we already have a snapshot for today + const existingSnapshot = await prisma.statsSnapshot.findUnique({ + where: { date: today }, + }); + + if (existingSnapshot) { + return NextResponse.json({ + success: true, + message: 'Snapshot already exists for today', + data: { date: today.toISOString(), id: existingSnapshot.id }, + }); + } + + // Time boundaries for daily counts + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + // Gather all statistics in parallel + const [ + // Tool and package counts + totalTools, + totalPackages, + officialTools, + officialPackages, + toolsWithSchema, + + // Health status counts + importHealthy, + importBroken, + importUnknown, + executionHealthy, + executionBroken, + executionUnknown, + + // Package data for aggregations + packages, + + // Daily execution stats + dailyExecutions, + dailySuccessful, + dailyFailed, + avgExecutionTime, + + // Daily token usage + dailyTokens, + + // Daily health checks + dailyHealthChecks, + + // Quality distribution + qualityDistribution, + ] = await Promise.all([ + // Tool counts + prisma.tool.count(), + prisma.package.count(), + prisma.tool.count({ where: { package: { isOfficial: true } } }), + prisma.package.count({ where: { isOfficial: true } }), + prisma.tool.count({ where: { schemaSource: 'extracted' } }), + + // Health status + prisma.tool.count({ where: { importHealth: 'HEALTHY' } }), + prisma.tool.count({ where: { importHealth: 'BROKEN' } }), + prisma.tool.count({ where: { importHealth: 'UNKNOWN' } }), + prisma.tool.count({ where: { executionHealth: 'HEALTHY' } }), + prisma.tool.count({ where: { executionHealth: 'BROKEN' } }), + prisma.tool.count({ where: { executionHealth: 'UNKNOWN' } }), + + // Package data + prisma.package.findMany({ + select: { + tier: true, + category: true, + npmDownloadsLastMonth: true, + githubStars: true, + _count: { select: { tools: true } }, + }, + }), + + // Daily execution stats + prisma.simulation.count({ + where: { createdAt: { gte: yesterday, lt: today } }, + }), + prisma.simulation.count({ + where: { createdAt: { gte: yesterday, lt: today }, status: 'success' }, + }), + prisma.simulation.count({ + where: { + createdAt: { gte: yesterday, lt: today }, + status: { in: ['error', 'timeout'] }, + }, + }), + prisma.simulation.aggregate({ + where: { + createdAt: { gte: yesterday, lt: today }, + status: 'success', + executionTimeMs: { not: null }, + }, + _avg: { executionTimeMs: true }, + }), + + // Daily token usage + prisma.tokenUsage.aggregate({ + where: { createdAt: { gte: yesterday, lt: today } }, + _sum: { + inputTokens: true, + outputTokens: true, + totalTokens: true, + estimatedCost: true, + }, + }), + + // Daily health checks + prisma.healthCheck.count({ + where: { createdAt: { gte: yesterday, lt: today } }, + }), + + // Quality score distribution + 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 + 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 + `, + ]); + + // Aggregate package data + let tiersMinimal = 0; + let tiersRich = 0; + let totalNpmDownloads = 0; + let totalGithubStars = 0; + const categories: Record = {}; + + for (const pkg of packages) { + if (pkg.tier === 'minimal') tiersMinimal++; + if (pkg.tier === 'rich') tiersRich++; + totalNpmDownloads += pkg.npmDownloadsLastMonth || 0; + totalGithubStars += pkg.githubStars || 0; + + if (pkg.category) { + categories[pkg.category] = (categories[pkg.category] || 0) + pkg._count.tools; + } + } + + // Format quality distribution + const qualityDist: Record = {}; + for (const row of qualityDistribution) { + qualityDist[row.bucket] = Number(row.count); + } + + // Create the snapshot + const snapshot = await prisma.statsSnapshot.create({ + data: { + date: today, + + // Registry overview + totalTools, + totalPackages, + officialTools, + officialPackages, + toolsWithSchema, + + // Downloads & stars + totalNpmDownloads, + totalGithubStars, + + // Health status + importHealthy, + importBroken, + importUnknown, + executionHealthy, + executionBroken, + executionUnknown, + + // Quality distribution + qualityDistribution: qualityDist, + + // Tiers + tiersMinimal, + tiersRich, + + // Daily execution stats + executionsTotal: dailyExecutions, + executionsSuccessful: dailySuccessful, + executionsFailed: dailyFailed, + executionsAvgTimeMs: avgExecutionTime._avg.executionTimeMs + ? Math.round(avgExecutionTime._avg.executionTimeMs) + : null, + + // Daily token usage + tokensInput: BigInt(dailyTokens._sum.inputTokens || 0), + tokensOutput: BigInt(dailyTokens._sum.outputTokens || 0), + tokensTotal: BigInt(dailyTokens._sum.totalTokens || 0), + tokensCostUsd: dailyTokens._sum.estimatedCost, + + // Daily health checks + healthChecksRun: dailyHealthChecks, + + // Categories + categories, + }, + }); + + const processingTime = Date.now() - startTime; + + return NextResponse.json({ + success: true, + data: { + id: snapshot.id, + date: snapshot.date, + totalTools: snapshot.totalTools, + totalPackages: snapshot.totalPackages, + executionsTotal: snapshot.executionsTotal, + processingTimeMs: processingTime, + }, + }); + } catch (error) { + console.error('Error creating stats snapshot:', error); + + return NextResponse.json( + { + success: false, + error: { + code: 'SNAPSHOT_ERROR', + message: 'Failed to create stats snapshot', + details: error instanceof Error ? error.message : 'Unknown error', + }, + }, + { status: 500 } + ); + } +} + +// GET endpoint to retrieve recent snapshots +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const days = Math.min(parseInt(searchParams.get('days') || '30', 10), 365); + + try { + const since = new Date(); + since.setDate(since.getDate() - days); + + const snapshots = await prisma.statsSnapshot.findMany({ + where: { date: { gte: since } }, + orderBy: { date: 'asc' }, + }); + + // Convert BigInt to number for JSON serialization + const serializedSnapshots = snapshots.map((s) => ({ + ...s, + tokensInput: Number(s.tokensInput), + tokensOutput: Number(s.tokensOutput), + tokensTotal: Number(s.tokensTotal), + })); + + return NextResponse.json({ + success: true, + data: { + snapshots: serializedSnapshots, + count: snapshots.length, + days, + }, + }); + } catch (error) { + console.error('Error fetching stats snapshots:', error); + + return NextResponse.json( + { + success: false, + error: { + code: 'FETCH_ERROR', + message: 'Failed to fetch stats snapshots', + details: error instanceof Error ? error.message : 'Unknown error', + }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/stats/page.tsx b/apps/web/src/app/stats/page.tsx index 6dd419b..9821c34 100644 --- a/apps/web/src/app/stats/page.tsx +++ b/apps/web/src/app/stats/page.tsx @@ -99,6 +99,23 @@ interface ExecutionsData { }; } +interface StatsSnapshot { + id: string; + date: string; + totalTools: number; + totalPackages: number; + totalNpmDownloads: number; + totalGithubStars: number; + importHealthy: number; + importBroken: number; + executionHealthy: number; + executionBroken: number; + executionsTotal: number; + executionsSuccessful: number; + executionsFailed: number; + tokensTotal: number; +} + const QUALITY_COLORS: Record = { excellent: '#22c55e', high: '#84cc16', @@ -122,15 +139,17 @@ const TIER_COLORS = { export default function StatsPage() { const [stats, setStats] = useState(null); const [executions, setExecutions] = useState(null); + const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function fetchData() { try { - const [statsRes, execRes] = await Promise.all([ + const [statsRes, execRes, historyRes] = await Promise.all([ fetch('/api/stats'), fetch('/api/stats/executions'), + fetch('/api/sync/stats-snapshot?days=90'), ]); if (!statsRes.ok || !execRes.ok) { @@ -139,6 +158,7 @@ export default function StatsPage() { const statsJson = await statsRes.json(); const execJson = await execRes.json(); + const historyJson = await historyRes.json(); if (statsJson.success) { setStats(statsJson.data); @@ -146,6 +166,9 @@ export default function StatsPage() { if (execJson.success) { setExecutions(execJson.data); } + if (historyJson.success && historyJson.data?.snapshots) { + setHistory(historyJson.data.snapshots); + } } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { @@ -293,6 +316,72 @@ export default function StatsPage() { + {/* Historical Trends */} + {history.length > 0 && ( +
+

Historical Trends

+
+ + ({ + date: s.date, + value: s.totalTools, + secondaryValue: s.totalPackages, + }))} + width={500} + height={250} + showSecondary + labels={{ primary: 'Tools', secondary: 'Packages' }} + color="#2563eb" + secondaryColor="#22c55e" + /> + + + ({ + date: s.date, + value: s.importHealthy + s.executionHealthy, + secondaryValue: s.importBroken + s.executionBroken, + }))} + width={500} + height={250} + showSecondary + labels={{ primary: 'Healthy', secondary: 'Broken' }} + color="#22c55e" + secondaryColor="#ef4444" + /> + + + ({ + date: s.date, + value: s.executionsSuccessful, + secondaryValue: s.executionsFailed, + }))} + width={500} + height={250} + showSecondary + labels={{ primary: 'Successful', secondary: 'Failed' }} + color="#22c55e" + secondaryColor="#ef4444" + /> + + + ({ + date: s.date, + value: s.totalNpmDownloads, + }))} + width={500} + height={250} + color="#f97316" + showArea + /> + +
+
+ )} + {/* Health & Quality Charts */}

Health & Quality

diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6e9ab7d..9882ea1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -254,3 +254,61 @@ enum HealthCheckType { EXECUTION // Only execution check FULL // Both import and execution } + +/// StatsSnapshot - daily snapshot of registry statistics for historical tracking +model StatsSnapshot { + id String @id @default(cuid()) + + // Snapshot date (one per day) + date DateTime @unique @db.Date + + // Registry Overview + totalTools Int @default(0) @map("total_tools") + totalPackages Int @default(0) @map("total_packages") + officialTools Int @default(0) @map("official_tools") + officialPackages Int @default(0) @map("official_packages") + toolsWithSchema Int @default(0) @map("tools_with_schema") + + // Downloads & Stars + totalNpmDownloads Int @default(0) @map("total_npm_downloads") + totalGithubStars Int @default(0) @map("total_github_stars") + + // Health Status Counts + importHealthy Int @default(0) @map("import_healthy") + importBroken Int @default(0) @map("import_broken") + importUnknown Int @default(0) @map("import_unknown") + executionHealthy Int @default(0) @map("execution_healthy") + executionBroken Int @default(0) @map("execution_broken") + executionUnknown Int @default(0) @map("execution_unknown") + + // Quality Distribution (stored as JSON for flexibility) + qualityDistribution Json? @map("quality_distribution") @db.JsonB + + // Package Tiers + tiersMinimal Int @default(0) @map("tiers_minimal") + tiersRich Int @default(0) @map("tiers_rich") + + // Execution Stats (daily) + executionsTotal Int @default(0) @map("executions_total") + executionsSuccessful Int @default(0) @map("executions_successful") + executionsFailed Int @default(0) @map("executions_failed") + executionsAvgTimeMs Int? @map("executions_avg_time_ms") + + // Token Usage (daily) + tokensInput BigInt @default(0) @map("tokens_input") + tokensOutput BigInt @default(0) @map("tokens_output") + tokensTotal BigInt @default(0) @map("tokens_total") + tokensCostUsd Decimal? @map("tokens_cost_usd") @db.Decimal(10, 4) + + // Health Checks (daily) + healthChecksRun Int @default(0) @map("health_checks_run") + + // Category Breakdown (stored as JSON) + categories Json? @db.JsonB + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + + @@index([date]) + @@map("stats_snapshots") +} diff --git a/vercel.json b/vercel.json index ced84b4..8b8ddf3 100644 --- a/vercel.json +++ b/vercel.json @@ -48,6 +48,10 @@ { "path": "/api/sync/metrics", "schedule": "0 * * * *" + }, + { + "path": "/api/sync/stats-snapshot", + "schedule": "0 0 * * *" } ] }