2025-08-28 11:34:44 +08:00

204 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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