first commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { safeJoinSitePath } from "@/lib/config";
|
||||
import { analyzeLogs } from "@/lib/analyzeLogs";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
ctx: { params: Promise<{ dir: string }> }
|
||||
) {
|
||||
try {
|
||||
const { dir } = await ctx.params;
|
||||
const filePath = safeJoinSitePath(dir, "log", "access.log");
|
||||
const content = await readFile(filePath, { encoding: "utf-8" });
|
||||
const data = await analyzeLogs(content);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { safeJoinSitePath } from "@/lib/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
ctx: { params: Promise<{ dir: string }> }
|
||||
) {
|
||||
try {
|
||||
const { dir } = await ctx.params;
|
||||
const filePath = safeJoinSitePath(dir, "log", "access.log");
|
||||
const content = await readFile(filePath, { encoding: "utf-8" });
|
||||
return new NextResponse(content, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { WEBSITE_FILE_DIR } from "@/lib/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const entries = await readdir(WEBSITE_FILE_DIR, { withFileTypes: true });
|
||||
const dirs: string[] = [];
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) {
|
||||
// 仅列出存在 log/access.log 的站点
|
||||
try {
|
||||
const s = await stat(`${WEBSITE_FILE_DIR}/${e.name}/log/access.log`);
|
||||
if (s.isFile()) dirs.push(e.name);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
dirs.sort();
|
||||
return NextResponse.json({ sites: dirs });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+3
-7
@@ -5,13 +5,6 @@
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
@@ -24,3 +17,6 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
a { color: inherit; }
|
||||
|
||||
|
||||
+13
-6
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "日志分析面板",
|
||||
description: "基于 Next.js 的访问日志分析面板",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +24,17 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<header className="border-b bg-white/60 dark:bg-white/5">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<a href="/" className="font-medium">日志分析面板</a>
|
||||
<nav className="text-sm text-gray-600 flex gap-4">
|
||||
<a className="hover:underline" href="/sites">Sites</a>
|
||||
<a className="hover:underline" href="https://nextjs.org" target="_blank" rel="noreferrer">Docs</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-4">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+7
-97
@@ -1,103 +1,13 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
<div className="min-h-screen grid place-items-center p-10">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-semibold">日志分析仪表盘</h1>
|
||||
<p className="text-gray-500">浏览服务器上的站点列表并查看访问分析</p>
|
||||
<a href="/sites" className="inline-block px-5 py-2 rounded bg-blue-600 text-white hover:bg-blue-500">
|
||||
进入 Site 列表
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export { GET } from "@/app/api/sites/[dir]/analysis/route";
|
||||
@@ -0,0 +1,203 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export { GET } from "@/app/api/sites/[dir]/raw/route";
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { WEBSITE_FILE_DIR } from "@/lib/config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SitesPage() {
|
||||
let sites: string[] = [];
|
||||
try {
|
||||
const entries = await readdir(WEBSITE_FILE_DIR, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) {
|
||||
try {
|
||||
const s = await stat(`${WEBSITE_FILE_DIR}/${e.name}/log/access.log`);
|
||||
if (s.isFile()) sites.push(e.name);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 显示错误信息
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-10">
|
||||
<h1 className="text-3xl font-semibold mb-6">Site 列表</h1>
|
||||
<p className="text-red-500">读取目录失败:{String(error)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
sites.sort();
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-10">
|
||||
<h1 className="text-3xl font-semibold mb-6">Site 列表</h1>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sites.map((s) => (
|
||||
<li key={s} className="border rounded-lg p-4 hover:shadow">
|
||||
<div className="font-mono text-sm text-gray-500 mb-2">{s}</div>
|
||||
<div className="flex gap-3">
|
||||
<Link className="text-blue-600 hover:underline" href={`/sites/${encodeURIComponent(s)}`}>
|
||||
仪表盘
|
||||
</Link>
|
||||
<a className="text-blue-600 hover:underline" href={`/api/sites/${encodeURIComponent(s)}/raw`} target="_blank">
|
||||
原始日志
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user