204 lines
6.7 KiB
TypeScript
204 lines
6.7 KiB
TypeScript
"use client";
|
||
import { useMemo } from "react";
|
||
import useSWR from "swr";
|
||
import { Bar, Line, Pie } from "react-chartjs-2";
|
||
import {
|
||
Chart as ChartJS,
|
||
CategoryScale,
|
||
LinearScale,
|
||
BarElement,
|
||
PointElement,
|
||
LineElement,
|
||
ArcElement,
|
||
Tooltip,
|
||
Legend,
|
||
TimeScale,
|
||
} from "chart.js";
|
||
import Link from "next/link";
|
||
|
||
ChartJS.register(
|
||
CategoryScale,
|
||
LinearScale,
|
||
BarElement,
|
||
PointElement,
|
||
LineElement,
|
||
ArcElement,
|
||
Tooltip,
|
||
Legend,
|
||
TimeScale,
|
||
);
|
||
|
||
type Analysis = import("@/lib/analyzeLogs").LogAnalysisResult;
|
||
|
||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||
|
||
export default function SiteDashboard({ params }: { params: { dir: string } }) {
|
||
const { data, error, isLoading } = useSWR<Analysis>(
|
||
`/api/sites/${encodeURIComponent(params.dir)}/analysis`,
|
||
fetcher,
|
||
{ refreshInterval: 60_000 }
|
||
);
|
||
|
||
const topUrlsData = useMemo(() => {
|
||
const labels = data?.topUrls.map((x) => x.url) ?? [];
|
||
const values = data?.topUrls.map((x) => x.count) ?? [];
|
||
return {
|
||
labels,
|
||
datasets: [{ label: "URL 访问次数", data: values, backgroundColor: "#60a5fa" }],
|
||
};
|
||
}, [data]);
|
||
|
||
const statusPieData = useMemo(() => {
|
||
const labels = Object.keys(data?.statusCodeDistribution || {});
|
||
const values = Object.values(data?.statusCodeDistribution || {});
|
||
return {
|
||
labels,
|
||
datasets: [{ data: values, backgroundColor: ["#34d399", "#60a5fa", "#fbbf24", "#f87171", "#a78bfa"] }],
|
||
};
|
||
}, [data]);
|
||
|
||
const timelineData = useMemo(() => {
|
||
const labels = (data?.timeAccess || []).map((p) => new Date(p.time).toLocaleString());
|
||
const values = (data?.timeAccess || []).map((p) => p.count);
|
||
return {
|
||
labels,
|
||
datasets: [{ label: "每小时请求量", data: values, borderColor: "#34d399", backgroundColor: "#34d39933" }],
|
||
};
|
||
}, [data]);
|
||
|
||
const devicePieData = useMemo(() => {
|
||
const entries = Object.entries(data?.deviceTypes || {});
|
||
return {
|
||
labels: entries.map(([k]) => k),
|
||
datasets: [{ data: entries.map(([, v]) => v), backgroundColor: ["#60a5fa", "#f472b6", "#fbbf24", "#a78bfa"] }],
|
||
};
|
||
}, [data]);
|
||
|
||
const provinceData = useMemo(() => {
|
||
const entries = Object.entries(data?.provinceCityProportions || {}).sort((a, b) => b[1] - a[1]).slice(0, 15);
|
||
return {
|
||
labels: entries.map(([k]) => k),
|
||
datasets: [{ label: "区域占比%", data: entries.map(([, v]) => v), backgroundColor: "#a78bfa" }],
|
||
};
|
||
}, [data]);
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto py-8 space-y-8">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-semibold">{decodeURIComponent(params.dir)} · 访问分析</h1>
|
||
<p className="text-gray-500 text-sm">版本 {data?.version ?? "-"}</p>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<Link className="text-blue-600 hover:underline" href="/sites">返回列表</Link>
|
||
<a className="text-blue-600 hover:underline" href={`/api/sites/${encodeURIComponent(params.dir)}/raw`} target="_blank">原始日志</a>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading && <p>加载中...</p>}
|
||
{error && <p className="text-red-500">加载失败:{String(error)}</p>}
|
||
{data && (
|
||
<div className="space-y-8">
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<Stat label="独立 IP" value={data.uniqueIPCount} />
|
||
<Stat label="首页访问" value={data.indexAccessCount} />
|
||
<Stat label="总流量(bytes)" value={data.totalBytes} />
|
||
<Stat label="状态码种类" value={Object.keys(data.statusCodeDistribution).length} />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||
<Card title="URL Top 20">
|
||
<Bar data={topUrlsData} options={{ indexAxis: "y" as const, plugins: { legend: { display: false } } }} />
|
||
</Card>
|
||
<Card title="状态码分布">
|
||
<Pie data={statusPieData} />
|
||
</Card>
|
||
</div>
|
||
|
||
<Card title="每小时访问趋势">
|
||
<Line data={timelineData} />
|
||
</Card>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||
<Card title="设备类型">
|
||
<Pie data={devicePieData} />
|
||
</Card>
|
||
<Card title="区域占比 Top 15">
|
||
<Bar data={provinceData} options={{ indexAxis: "y" as const, plugins: { legend: { display: false } } }} />
|
||
</Card>
|
||
</div>
|
||
|
||
<Card title="浏览器版本 (前 10)">
|
||
<BrowserOsTable kind="browser" data={data.browserVersions} />
|
||
</Card>
|
||
|
||
<Card title="操作系统版本 (前 10)">
|
||
<BrowserOsTable kind="os" data={data.osVersions} />
|
||
</Card>
|
||
|
||
<Card title="最近 100 条原始日志">
|
||
<pre className="text-xs whitespace-pre-wrap leading-5 max-h-96 overflow-auto bg-black/5 p-4 rounded">{data.recentRawLog.join("\n")}</pre>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stat({ label, value }: { label: string; value: number | string }) {
|
||
return (
|
||
<div className="p-4 rounded border bg-white/50 dark:bg-white/5">
|
||
<div className="text-sm text-gray-500">{label}</div>
|
||
<div className="text-2xl font-semibold mt-1">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="p-4 rounded border bg-white/60 dark:bg-white/5">
|
||
<h3 className="font-medium mb-3">{title}</h3>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BrowserOsTable({
|
||
kind,
|
||
data,
|
||
}: {
|
||
kind: "browser" | "os";
|
||
data: Record<string, Record<string, number>>;
|
||
}) {
|
||
const rows = useMemo(() => {
|
||
const out: { group: string; version: string; count: number }[] = [];
|
||
Object.entries(data || {}).forEach(([g, versions]) => {
|
||
Object.entries(versions).forEach(([v, c]) => out.push({ group: g, version: v, count: c }));
|
||
});
|
||
return out.sort((a, b) => b.count - a.count).slice(0, 10);
|
||
}, [data]);
|
||
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left text-gray-500">
|
||
<th className="py-2 pr-4">{kind === "browser" ? "浏览器" : "操作系统"}</th>
|
||
<th className="py-2 pr-4">版本</th>
|
||
<th className="py-2">次数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r, i) => (
|
||
<tr key={i} className="border-t">
|
||
<td className="py-2 pr-4">{r.group}</td>
|
||
<td className="py-2 pr-4">{r.version}</td>
|
||
<td className="py-2">{r.count}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|