From 356039d9cfb3a482513627876b1bc862cb116a54 Mon Sep 17 00:00:00 2001 From: shangzy Date: Wed, 27 May 2026 15:19:31 +0800 Subject: [PATCH] feat: harden analytics dashboard --- README.md | 63 ++- app/api/aggregation/route.ts | 17 +- app/api/date-range/route.ts | 10 +- app/api/detail/[type]/[id]/route.ts | 47 +- app/api/logs/route.test.ts | 44 ++ app/api/logs/route.ts | 51 +- app/api/overview/route.ts | 15 +- app/api/rankings/route.test.ts | 41 ++ app/api/rankings/route.ts | 47 +- app/api/trends/route.ts | 37 +- app/detail/[...slug]/page.tsx | 7 +- app/page.tsx | 7 +- components/ClientProviders.tsx | 2 +- components/Sidebar.tsx | 14 +- components/charts/RankingBar.tsx | 4 +- components/charts/TrendChart.tsx | 5 +- docs/database.md | 71 +++ docs/metrics.md | 29 ++ lib/api-params.test.ts | 45 ++ lib/api-params.ts | 62 +++ lib/detail-stats.ts | 3 +- lib/metrics.test.ts | 10 + lib/metrics.ts | 5 + lib/queries.test.ts | 37 +- lib/queries.ts | 770 +--------------------------- lib/query-cache.test.ts | 51 ++ lib/query-cache.ts | 39 ++ lib/query-date-range.ts | 13 + lib/query-details.ts | 287 +++++++++++ lib/query-logs.ts | 129 +++++ lib/query-overview.ts | 55 ++ lib/query-rankings.ts | 145 ++++++ lib/query-shared.ts | 54 ++ lib/query-trends.ts | 87 ++++ 34 files changed, 1424 insertions(+), 879 deletions(-) create mode 100644 app/api/logs/route.test.ts create mode 100644 app/api/rankings/route.test.ts create mode 100644 docs/database.md create mode 100644 docs/metrics.md create mode 100644 lib/api-params.test.ts create mode 100644 lib/api-params.ts create mode 100644 lib/metrics.test.ts create mode 100644 lib/metrics.ts create mode 100644 lib/query-cache.test.ts create mode 100644 lib/query-cache.ts create mode 100644 lib/query-date-range.ts create mode 100644 lib/query-details.ts create mode 100644 lib/query-logs.ts create mode 100644 lib/query-overview.ts create mode 100644 lib/query-rankings.ts create mode 100644 lib/query-shared.ts create mode 100644 lib/query-trends.ts diff --git a/README.md b/README.md index e215bc4..dcc0f0f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,57 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# New API Analytics -## Getting Started +Internal analytics dashboard for New API usage logs. The app reads PostgreSQL log data and visualizes calls, token consumption, model usage, channel usage, per-user aggregation, and raw request logs. -First, run the development server: +## Requirements + +- Bun for local development and tests. +- Node.js 20 for the production Docker image. +- PostgreSQL database with New API tables described in `docs/database.md`. +- `PG_CONNECTION_STRING` set to a PostgreSQL connection string. + +## Local Development ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +bun install +bun run dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open `http://localhost:3000`. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Verification -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```bash +bun test +bun run lint +bun run build +``` -## Learn More +`bun test` covers parser helpers, metric conversion, query behavior, cache behavior, theme helpers, and selected API route guardrails. -To learn more about Next.js, take a look at the following resources: +## Configuration -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +Create an environment file or export: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +PG_CONNECTION_STRING=postgres://user:password@host:5432/database +``` -## Deploy on Vercel +The app uses this variable in `lib/db.ts` to create a `pg` connection pool. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Deployment -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +The included Dockerfile builds a standalone Next.js output and starts `server.js` on port `8019`. + +```bash +docker compose up -d --build +``` + +`docker-compose.yml` expects `.env.production` and an external Docker network named `sinobridge`. + +## Operational Notes + +- API query parameters are validated at the route layer. Invalid integers or reversed date ranges return HTTP 400. +- Ranking limits are capped at 100 rows per request. +- Log page size is capped at 200 rows per request. +- Query results are cached in-process for 120 seconds with a 500-entry cap. +- Metric definitions and quota conversion are documented in `docs/metrics.md`. diff --git a/app/api/aggregation/route.ts b/app/api/aggregation/route.ts index 26eef4a..d84990c 100644 --- a/app/api/aggregation/route.ts +++ b/app/api/aggregation/route.ts @@ -1,12 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; +import { jsonError, parseTimestampRange } from "@/lib/api-params"; import { getUserRanking } from "@/lib/queries"; export async function GET(req: NextRequest) { - const sp = req.nextUrl.searchParams; - const startTs = sp.get("start") ? Number(sp.get("start")) : undefined; - const endTs = sp.get("end") ? Number(sp.get("end")) : undefined; + try { + const range = parseTimestampRange(req.nextUrl.searchParams); + if (!range.ok) return jsonError(range.field); - // Get ALL users (no limit) for aggregation view - const data = await getUserRanking(startTs, endTs, 500); - return NextResponse.json(data); + // Get all visible users for aggregation view, capped by query-layer safeguards. + const data = await getUserRanking(range.value.startTs, range.value.endTs, 500); + return NextResponse.json(data); + } catch (error) { + console.error("Failed to load aggregation", error); + return jsonError(undefined, 500); + } } diff --git a/app/api/date-range/route.ts b/app/api/date-range/route.ts index a1a37e5..2ef4b76 100644 --- a/app/api/date-range/route.ts +++ b/app/api/date-range/route.ts @@ -1,7 +1,13 @@ import { NextResponse } from "next/server"; +import { jsonError } from "@/lib/api-params"; import { getDateRange } from "@/lib/queries"; export async function GET() { - const data = await getDateRange(); - return NextResponse.json(data); + try { + const data = await getDateRange(); + return NextResponse.json(data); + } catch (error) { + console.error("Failed to load date range", error); + return jsonError(undefined, 500); + } } diff --git a/app/api/detail/[type]/[id]/route.ts b/app/api/detail/[type]/[id]/route.ts index 86d1f36..20cd433 100644 --- a/app/api/detail/[type]/[id]/route.ts +++ b/app/api/detail/[type]/[id]/route.ts @@ -1,29 +1,38 @@ import { NextRequest, NextResponse } from "next/server"; +import { jsonError, parseTimestampRange } from "@/lib/api-params"; import { getUserDetail, getModelDetail, getChannelDetail } from "@/lib/queries"; export async function GET( req: NextRequest, { params }: { params: Promise<{ type: string; id: string }> } ) { - const { type, id } = await params; - const sp = req.nextUrl.searchParams; - const startTs = sp.get("start") ? Number(sp.get("start")) : undefined; - const endTs = sp.get("end") ? Number(sp.get("end")) : undefined; + try { + const { type, id } = await params; + const range = parseTimestampRange(req.nextUrl.searchParams); + if (!range.ok) return jsonError(range.field); - let data; - switch (type) { - case "user": - data = await getUserDetail(decodeURIComponent(id), startTs, endTs); - break; - case "model": - data = await getModelDetail(decodeURIComponent(id), startTs, endTs); - break; - case "channel": - data = await getChannelDetail(Number(id), startTs, endTs); - break; - default: - return NextResponse.json({ error: "Invalid type" }, { status: 400 }); + let data; + switch (type) { + case "user": + data = await getUserDetail(decodeURIComponent(id), range.value.startTs, range.value.endTs); + break; + case "model": + data = await getModelDetail(decodeURIComponent(id), range.value.startTs, range.value.endTs); + break; + case "channel": { + if (!/^\d+$/.test(id)) return jsonError("id"); + const channelId = Number(id); + if (!Number.isSafeInteger(channelId) || channelId < 1) return jsonError("id"); + data = await getChannelDetail(channelId, range.value.startTs, range.value.endTs); + break; + } + default: + return jsonError("type"); + } + + return NextResponse.json(data); + } catch (error) { + console.error("Failed to load detail", error); + return jsonError(undefined, 500); } - - return NextResponse.json(data); } diff --git a/app/api/logs/route.test.ts b/app/api/logs/route.test.ts new file mode 100644 index 0000000..38e0f4a --- /dev/null +++ b/app/api/logs/route.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { NextRequest } from "next/server"; + +const getLogsMock = mock(async () => ({ logs: [], total: 0, page: 1, page_size: 100 })); +const getUserRankingMock = mock(async () => []); +const getModelRankingMock = mock(async () => []); +const getChannelRankingMock = mock(async () => []); + +mock.module("@/lib/queries", () => ({ + getLogs: getLogsMock, + getUserRanking: getUserRankingMock, + getModelRanking: getModelRankingMock, + getChannelRanking: getChannelRankingMock, +})); + +const { GET } = await import("./route"); + +function request(path: string) { + return { nextUrl: new URL(`http://localhost${path}`) } as NextRequest; +} + +describe("/api/logs", () => { + test("rejects reversed date ranges", async () => { + const res = await GET(request("/api/logs?start=200&end=100")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid query parameter", + field: "range", + }); + }); + + test("clamps page size before querying", async () => { + getLogsMock.mockClear(); + + const res = await GET(request("/api/logs?page=2&page_size=10000")); + + expect(res.status).toBe(200); + expect(getLogsMock.mock.calls[0][0]).toMatchObject({ + page: 2, + pageSize: 200, + }); + }); +}); diff --git a/app/api/logs/route.ts b/app/api/logs/route.ts index 7e7c9a9..52642c4 100644 --- a/app/api/logs/route.ts +++ b/app/api/logs/route.ts @@ -1,19 +1,46 @@ import { NextRequest, NextResponse } from "next/server"; +import { jsonError, parseOptionalInt, parsePositiveInt, parseTimestampRange } from "@/lib/api-params"; import { getLogs } from "@/lib/queries"; export async function GET(req: NextRequest) { - const sp = req.nextUrl.searchParams; + try { + const sp = req.nextUrl.searchParams; + const range = parseTimestampRange(sp); + if (!range.ok) return jsonError(range.field); - const data = await getLogs({ - page: sp.get("page") ? Number(sp.get("page")) : 1, - pageSize: sp.get("page_size") ? Number(sp.get("page_size")) : 100, - startTs: sp.get("start") ? Number(sp.get("start")) : undefined, - endTs: sp.get("end") ? Number(sp.get("end")) : undefined, - username: sp.get("username") || undefined, - model: sp.get("model") || undefined, - channelId: sp.get("channel_id") ? Number(sp.get("channel_id")) : undefined, - tokenName: sp.get("token_name") || undefined, - }); + const page = parsePositiveInt(sp.get("page"), { + field: "page", + defaultValue: 1, + min: 1, + }); + if (!page.ok) return jsonError(page.field); - return NextResponse.json(data); + const pageSize = parsePositiveInt(sp.get("page_size"), { + field: "page_size", + defaultValue: 100, + min: 1, + max: 200, + }); + if (!pageSize.ok) return jsonError(pageSize.field); + + const channelId = parseOptionalInt(sp.get("channel_id"), "channel_id"); + if (!channelId.ok) return jsonError(channelId.field); + if (channelId.value === 0) return jsonError("channel_id"); + + const data = await getLogs({ + page: page.value, + pageSize: pageSize.value, + startTs: range.value.startTs, + endTs: range.value.endTs, + username: sp.get("username") || undefined, + model: sp.get("model") || undefined, + channelId: channelId.value, + tokenName: sp.get("token_name") || undefined, + }); + + return NextResponse.json(data); + } catch (error) { + console.error("Failed to load logs", error); + return jsonError(undefined, 500); + } } diff --git a/app/api/overview/route.ts b/app/api/overview/route.ts index f4d34cb..2724aa9 100644 --- a/app/api/overview/route.ts +++ b/app/api/overview/route.ts @@ -1,11 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; +import { jsonError, parseTimestampRange } from "@/lib/api-params"; import { getOverview } from "@/lib/queries"; export async function GET(req: NextRequest) { - const sp = req.nextUrl.searchParams; - const startTs = sp.get("start") ? Number(sp.get("start")) : undefined; - const endTs = sp.get("end") ? Number(sp.get("end")) : undefined; + try { + const range = parseTimestampRange(req.nextUrl.searchParams); + if (!range.ok) return jsonError(range.field); - const data = await getOverview(startTs, endTs); - return NextResponse.json(data); + const data = await getOverview(range.value.startTs, range.value.endTs); + return NextResponse.json(data); + } catch (error) { + console.error("Failed to load overview", error); + return jsonError(undefined, 500); + } } diff --git a/app/api/rankings/route.test.ts b/app/api/rankings/route.test.ts new file mode 100644 index 0000000..9bf986f --- /dev/null +++ b/app/api/rankings/route.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { NextRequest } from "next/server"; + +const getUserRankingMock = mock(async () => []); +const getModelRankingMock = mock(async () => []); +const getChannelRankingMock = mock(async () => []); +const getLogsMock = mock(async () => ({ logs: [], total: 0, page: 1, page_size: 100 })); + +mock.module("@/lib/queries", () => ({ + getUserRanking: getUserRankingMock, + getModelRanking: getModelRankingMock, + getChannelRanking: getChannelRankingMock, + getLogs: getLogsMock, +})); + +const { GET } = await import("./route"); + +function request(path: string) { + return { nextUrl: new URL(`http://localhost${path}`) } as NextRequest; +} + +describe("/api/rankings", () => { + test("rejects invalid limit", async () => { + const res = await GET(request("/api/rankings?limit=abc")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid query parameter", + field: "limit", + }); + }); + + test("clamps oversized limit before querying", async () => { + getUserRankingMock.mockClear(); + + const res = await GET(request("/api/rankings?limit=500")); + + expect(res.status).toBe(200); + expect(getUserRankingMock.mock.calls[0]).toEqual([undefined, undefined, 100]); + }); +}); diff --git a/app/api/rankings/route.ts b/app/api/rankings/route.ts index 29875f0..2edcf98 100644 --- a/app/api/rankings/route.ts +++ b/app/api/rankings/route.ts @@ -1,24 +1,37 @@ import { NextRequest, NextResponse } from "next/server"; +import { jsonError, parsePositiveInt, parseTimestampRange } from "@/lib/api-params"; import { getUserRanking, getModelRanking, getChannelRanking } from "@/lib/queries"; export async function GET(req: NextRequest) { - const sp = req.nextUrl.searchParams; - const type = sp.get("type") || "user"; - const startTs = sp.get("start") ? Number(sp.get("start")) : undefined; - const endTs = sp.get("end") ? Number(sp.get("end")) : undefined; - const limit = sp.get("limit") ? Number(sp.get("limit")) : 50; + try { + const sp = req.nextUrl.searchParams; + const type = sp.get("type") || "user"; + const range = parseTimestampRange(sp); + if (!range.ok) return jsonError(range.field); - let data; - switch (type) { - case "model": - data = await getModelRanking(startTs, endTs, limit); - break; - case "channel": - data = await getChannelRanking(startTs, endTs, limit); - break; - default: - data = await getUserRanking(startTs, endTs, limit); + const limit = parsePositiveInt(sp.get("limit"), { + field: "limit", + defaultValue: 50, + min: 1, + max: 100, + }); + if (!limit.ok) return jsonError(limit.field); + + let data; + switch (type) { + case "model": + data = await getModelRanking(range.value.startTs, range.value.endTs, limit.value); + break; + case "channel": + data = await getChannelRanking(range.value.startTs, range.value.endTs, limit.value); + break; + default: + data = await getUserRanking(range.value.startTs, range.value.endTs, limit.value); + } + + return NextResponse.json(data); + } catch (error) { + console.error("Failed to load rankings", error); + return jsonError(undefined, 500); } - - return NextResponse.json(data); } diff --git a/app/api/trends/route.ts b/app/api/trends/route.ts index 9dac7eb..5998b51 100644 --- a/app/api/trends/route.ts +++ b/app/api/trends/route.ts @@ -1,22 +1,31 @@ import { NextRequest, NextResponse } from "next/server"; +import { jsonError, parseOptionalInt, parseTimestampRange } from "@/lib/api-params"; import { getTrends, type TrendGranularity } from "@/lib/queries"; const GRANULARITIES: TrendGranularity[] = ["hour", "day", "week", "month"]; export async function GET(req: NextRequest) { - const sp = req.nextUrl.searchParams; - const requestedGranularity = sp.get("granularity"); - const granularity = GRANULARITIES.includes(requestedGranularity as TrendGranularity) - ? requestedGranularity as TrendGranularity - : "day"; - const startTs = sp.get("start") ? Number(sp.get("start")) : undefined; - const endTs = sp.get("end") ? Number(sp.get("end")) : undefined; - const channelId = sp.get("channel_id") ? Number(sp.get("channel_id")) : undefined; + try { + const sp = req.nextUrl.searchParams; + const requestedGranularity = sp.get("granularity"); + const granularity = GRANULARITIES.includes(requestedGranularity as TrendGranularity) + ? requestedGranularity as TrendGranularity + : "day"; + const range = parseTimestampRange(sp); + if (!range.ok) return jsonError(range.field); - const data = await getTrends(granularity, startTs, endTs, { - username: sp.get("username") || undefined, - model: sp.get("model") || undefined, - channelId: Number.isFinite(channelId) ? channelId : undefined, - }); - return NextResponse.json(data); + const channelId = parseOptionalInt(sp.get("channel_id"), "channel_id"); + if (!channelId.ok) return jsonError(channelId.field); + if (channelId.value === 0) return jsonError("channel_id"); + + const data = await getTrends(granularity, range.value.startTs, range.value.endTs, { + username: sp.get("username") || undefined, + model: sp.get("model") || undefined, + channelId: channelId.value, + }); + return NextResponse.json(data); + } catch (error) { + console.error("Failed to load trends", error); + return jsonError(undefined, 500); + } } diff --git a/app/detail/[...slug]/page.tsx b/app/detail/[...slug]/page.tsx index c01f351..2357c09 100644 --- a/app/detail/[...slug]/page.tsx +++ b/app/detail/[...slug]/page.tsx @@ -12,6 +12,7 @@ import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils"; import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "@/lib/detail-sort"; import { getPrimaryModelNames, getSharePercent, getTokenDisplayName, getTokenRowKey, shouldShowTokenTab, sortTokenBreakdown, type TokenBreakdownItem } from "@/lib/token-breakdown"; import { getDetailStats, type DetailStatKey } from "@/lib/detail-stats"; +import { quotaToUsd } from "@/lib/metrics"; import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; @@ -170,7 +171,7 @@ export default function DetailPage() { {formatNumber(item.calls)} {formatTokens(item.total_tokens)} - {formatUSD(item.quota / 500000)} + {formatUSD(quotaToUsd(item.quota))} {userShare.toFixed(1)}% {getPrimaryModelNames(item.models) || t("common.noData")} @@ -196,7 +197,7 @@ export default function DetailPage() { {model.name} {formatNumber(model.calls)} {formatTokens(model.total_tokens)} - {formatUSD(model.quota / 500000)} + {formatUSD(quotaToUsd(model.quota))} {modelShare.toFixed(1)}% ); @@ -311,7 +312,7 @@ export default function DetailPage() { {item.name} {formatNumber(item.calls)} {formatTokens(item.total_tokens)} - {formatUSD(item.quota / 500000)} + {formatUSD(quotaToUsd(item.quota))} ))} diff --git a/app/page.tsx b/app/page.tsx index 64599d1..7845984 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,6 +8,7 @@ import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TrendChart } from "@/components/charts/TrendChart"; import { RankingBar } from "@/components/charts/RankingBar"; import { buildQuery } from "@/lib/utils"; +import { quotaToUsd } from "@/lib/metrics"; import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; @@ -82,19 +83,19 @@ export default function DashboardPage() {
- +
)} -
+

{t("dash.trend")}

-
+
{grans.map(g => (