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:
parent
606cf1714c
commit
61c89824f9
4 changed files with 474 additions and 1 deletions
322
apps/web/src/app/api/sync/stats-snapshot/route.ts
Normal file
322
apps/web/src/app/api/sync/stats-snapshot/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@
|
|||
{
|
||||
"path": "/api/sync/metrics",
|
||||
"schedule": "0 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/sync/stats-snapshot",
|
||||
"schedule": "0 0 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue