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
This commit is contained in:
Ajax Davis 2025-12-28 17:19:46 +10:00
parent 606cf1714c
commit 61c89824f9
4 changed files with 474 additions and 1 deletions

View file

@ -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<string, number> = {};
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<string, number> = {};
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 }
);
}
}

View file

@ -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<string, string> = {
excellent: '#22c55e',
high: '#84cc16',
@ -122,15 +139,17 @@ const TIER_COLORS = {
export default function StatsPage() {
const [stats, setStats] = useState<StatsData | null>(null);
const [executions, setExecutions] = useState<ExecutionsData | null>(null);
const [history, setHistory] = useState<StatsSnapshot[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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() {
</div>
</section>
{/* Historical Trends */}
{history.length > 0 && (
<section>
<h2 className="text-xl font-semibold text-foreground mb-4">Historical Trends</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ChartCard title="Tools & Packages Over Time">
<AreaChart
data={history.map((s) => ({
date: s.date,
value: s.totalTools,
secondaryValue: s.totalPackages,
}))}
width={500}
height={250}
showSecondary
labels={{ primary: 'Tools', secondary: 'Packages' }}
color="#2563eb"
secondaryColor="#22c55e"
/>
</ChartCard>
<ChartCard title="Health Status Over Time">
<AreaChart
data={history.map((s) => ({
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"
/>
</ChartCard>
<ChartCard title="Daily Executions Over Time">
<AreaChart
data={history.map((s) => ({
date: s.date,
value: s.executionsSuccessful,
secondaryValue: s.executionsFailed,
}))}
width={500}
height={250}
showSecondary
labels={{ primary: 'Successful', secondary: 'Failed' }}
color="#22c55e"
secondaryColor="#ef4444"
/>
</ChartCard>
<ChartCard title="NPM Downloads Over Time">
<AreaChart
data={history.map((s) => ({
date: s.date,
value: s.totalNpmDownloads,
}))}
width={500}
height={250}
color="#f97316"
showArea
/>
</ChartCard>
</div>
</section>
)}
{/* Health & Quality Charts */}
<section>
<h2 className="text-xl font-semibold text-foreground mb-4">Health & Quality</h2>

View file

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

View file

@ -48,6 +48,10 @@
{
"path": "/api/sync/metrics",
"schedule": "0 * * * *"
},
{
"path": "/api/sync/stats-snapshot",
"schedule": "0 0 * * *"
}
]
}