From 2bfb55799375d163698def53426abf772387fe0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Sun, 24 May 2026 22:12:36 +0300 Subject: [PATCH] feat: add Analytics page with scan stats API Charts and JSON export for scan volume; completes core nav surface. Co-authored-by: Cursor --- src/app/analytics/page.tsx | 140 +++++++++++++++++++++++++++ src/app/api/analytics/stats/route.ts | 51 ++++++++++ src/components/layout/header.tsx | 1 + 3 files changed, 192 insertions(+) create mode 100644 src/app/analytics/page.tsx create mode 100644 src/app/api/analytics/stats/route.ts diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx new file mode 100644 index 0000000..4406f73 --- /dev/null +++ b/src/app/analytics/page.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { motion } from "framer-motion"; +import { Download, TrendingUp } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +type Stats = { + total: number; + completed: number; + failed: number; + successRate: number; + daily: { date: string; scans: number; completed: number }[]; + topDomains: { domain: string; count: number }[]; +}; + +export default function AnalyticsPage() { + const [stats, setStats] = useState(null); + + useEffect(() => { + fetch("/api/analytics/stats") + .then((r) => r.json()) + .then(setStats) + .catch(() => setStats(null)); + }, []); + + const exportSummary = () => { + if (!stats) return; + const blob = new Blob([JSON.stringify(stats, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "domain-scanner-analytics.json"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+
+

Analytics

+

+ Scan volume, success rate, and top domains — last 7 days. +

+
+ +
+ +
+ {[ + { label: "Total scans", value: stats?.total ?? "—" }, + { label: "Success rate", value: stats ? `${stats.successRate}%` : "—" }, + { label: "Failed", value: stats?.failed ?? "—" }, + ].map((kpi, i) => ( + + + +

{kpi.label}

+

{kpi.value}

+
+
+
+ ))} +
+ + + + + Scans per day + + + + {stats?.daily.length ? ( + + + + + + + + + + + ) : ( +

+ Run scans from the dashboard to populate charts. +

+ )} +
+
+ + {stats?.topDomains.length ? ( + + + Top domains + + +
    + {stats.topDomains.map((d) => ( +
  • + {d.domain} + {d.count} scans +
  • + ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/src/app/api/analytics/stats/route.ts b/src/app/api/analytics/stats/route.ts new file mode 100644 index 0000000..3976bea --- /dev/null +++ b/src/app/api/analytics/stats/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const [total, completed, failed, last7] = await Promise.all([ + prisma.scan.count(), + prisma.scan.count({ where: { status: "COMPLETED" } }), + prisma.scan.count({ where: { status: "FAILED" } }), + prisma.scan.findMany({ + where: { + createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, + }, + select: { createdAt: true, status: true }, + orderBy: { createdAt: "asc" }, + }), + ]); + + const byDay = new Map(); + for (const row of last7) { + const key = row.createdAt.toISOString().slice(0, 10); + const cur = byDay.get(key) ?? { scans: 0, completed: 0 }; + cur.scans += 1; + if (row.status === "COMPLETED") cur.completed += 1; + byDay.set(key, cur); + } + + const daily = [...byDay.entries()].map(([date, v]) => ({ + date, + scans: v.scans, + completed: v.completed, + })); + + const topDomains = await prisma.scan.groupBy({ + by: ["domain"], + _count: { domain: true }, + orderBy: { _count: { domain: "desc" } }, + take: 8, + }); + + return NextResponse.json({ + total, + completed, + failed, + successRate: total ? Math.round((completed / total) * 100) : 0, + daily, + topDomains: topDomains.map((d) => ({ + domain: d.domain, + count: d._count.domain, + })), + }); +} diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 26298ec..274a248 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -12,6 +12,7 @@ import { ThemeToggle } from "@/components/theme-toggle"; const links = [ { href: "/dashboard", label: "Dashboard" }, { href: "/monitoring", label: "Monitoring" }, + { href: "/analytics", label: "Analytics" }, { href: "/api-docs", label: "API" }, { href: "/settings", label: "Settings" }, ];