构建使用量分析仪表板
端到端实战指南——从 Prisma 模式到已部署功能。涵盖数据库、API、前端、权限、图表和测试。
本实战指南构建一个使用量分析仪表板——一个显示每个租户过去 30 天 API 调用使用量时间序列的仪表板页面。它涵盖技术栈的每个层级,可作为任何数据密集型仪表板功能的模板。
您将构建的内容:
- 用于每日聚合的
UsageSnapshotPrisma 模型 - 基于
@nebutra/metering的GET /api/v1/analytics/usageHono 端点 - 带有加载/错误状态的 React Server Component 页面
- 使用 CSS 变量颜色自适应深色模式的响应式 Recharts 面积图
- 通过
analytics:read权限进行 RBAC 访问控制 - 侧边栏导航条目
- API 处理器的 Vitest 单元测试
- 页面的 Playwright E2E 测试
- 图表组件的 Storybook 故事
前提条件: 运行中的开发环境(pnpm dev)以及可通过 DATABASE_URL 访问的 Postgres 数据库。
第 1 阶段:数据库
向 Prisma 模式添加 UsageSnapshot 模型。它存储预聚合的每日计数——相比查询原始 ClickHouse 数据,历史图表查询成本更低。
// packages/platform/db/prisma/schema.prisma
model UsageSnapshot {
id String @id @default(cuid())
tenantId String
date DateTime @db.Date
meterId String // 例如 "api_calls"、"storage_bytes"
value BigInt
createdAt DateTime @default(now())
@@unique([tenantId, date, meterId])
@@index([tenantId, meterId, date])
}pnpm db:generatepnpm db:migrate --name add-usage-snapshot第 2 阶段:API 端点
在 api-gateway 中创建分析路由。该端点从 @nebutra/metering(ClickHouse)读取过去 30 天的数据,并返回时间序列数组。
// backends/gateway/src/routes/analytics.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { requirePermission } from "@nebutra/permissions";
import { getMetering } from "@nebutra/metering";
import { getCurrentTenant } from "@nebutra/tenant";
const analyticsRouter = new Hono();
const usageQuerySchema = z.object({
meterId: z.string().default("api_calls"),
days: z.coerce.number().int().min(1).max(90).default(30),
});
analyticsRouter.get(
"/usage",
requirePermission("analytics:read"),
zValidator("query", usageQuerySchema),
async (c) => {
const { meterId, days } = c.req.valid("query");
const { tenantId } = getCurrentTenant();
const metering = await getMetering();
const since = new Date();
since.setDate(since.getDate() - days);
const series = await metering.getTimeSeries(tenantId, meterId, {
from: since,
to: new Date(),
granularity: "day",
});
return c.json({
success: true,
data: {
meterId,
tenantId,
series: series.map((point) => ({
date: point.timestamp.toISOString().split("T")[0],
value: Number(point.value),
})),
},
});
}
);
export { analyticsRouter };// backends/gateway/src/index.ts
import { analyticsRouter } from "./routes/analytics";
app.route("/api/v1/analytics", analyticsRouter);requirePermission 是来自 @nebutra/permissions 的 Hono 中间件。它从 Authorization 请求头读取 JWT,若解析出的角色不包含所请求的权限范围,则返回 403。
第 3 阶段:TypeScript 类型
添加端点后,重新生成有类型的 API 客户端,使前端获得完整的类型安全。
pnpm generate:api-types这会运行 openapi-typescript 流水线(脚本:scripts/generate-api-types.ts),从 backends/gateway/openapi.json 读取规范,写入 apps/web/src/lib/api/types.generated.ts。基于 openapi-fetch 的有类型客户端可通过以下方式使用:
// Server Components / route handlers(自动转发 Clerk JWT):
import { getTypedApi } from "@/lib/api/client";
const api = await getTypedApi();
// Client Components:
import { browserApiClient } from "@/lib/api/browser-client";第 4 阶段:前端——Server Component
mkdir -p apps/web/src/app/\(dashboard\)/analytics// apps/web/src/app/(dashboard)/analytics/page.tsx
import { Suspense } from "react";
import { PageHeader } from "@nebutra/ui/layout";
import { LoadingState, ErrorState } from "@nebutra/ui/layout";
import { requireAuth, getTenantContext } from "@/lib/auth";
import { getTypedApi } from "@/lib/api/client";
import { AnalyticsChart } from "./_components/analytics-chart";
export const metadata = { title: "分析" };
async function AnalyticsData() {
const { tenantId } = await getTenantContext();
const api = await getTypedApi();
const { data, error } = await api.GET("/api/v1/analytics/usage", {
params: { query: { meterId: "api_calls", days: 30 } },
next: { revalidate: 300 }, // 缓存 5 分钟
});
if (error || !data?.success) {
return <ErrorState message="加载分析数据失败" />;
}
return <AnalyticsChart series={data.data.series} />;
}
export default async function AnalyticsPage() {
await requireAuth();
return (
<div className="space-y-6">
<PageHeader
title="分析"
description="过去 30 天的 API 使用情况"
/>
<Suspense fallback={<LoadingState message="正在加载使用数据…" />}>
<AnalyticsData />
</Suspense>
</div>
);
}// apps/web/src/app/(dashboard)/analytics/loading.tsx
import { LoadingState } from "@nebutra/ui/layout";
export default function Loading() {
return <LoadingState message="正在加载分析数据…" />;
}// apps/web/src/app/(dashboard)/analytics/error.tsx
"use client";
import { ErrorState } from "@nebutra/ui/layout";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return <ErrorState message={error.message} onRetry={reset} />;
}第 5 阶段:图表组件
图表组件是 Client Component(Recharts 需要浏览器环境)。它从 CSS 变量读取颜色,因此能自动适应明暗模式和活跃主题预设。
mkdir -p apps/web/src/app/\(dashboard\)/analytics/_components// apps/web/src/app/(dashboard)/analytics/_components/analytics-chart.tsx
"use client";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { AnimateIn } from "@nebutra/ui/components";
interface DataPoint {
date: string;
value: number;
}
interface AnalyticsChartProps {
series: DataPoint[];
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
});
}
function formatValue(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
return String(value);
}
export function AnalyticsChart({ series }: AnalyticsChartProps) {
return (
<AnimateIn preset="emerge" inView>
<div className="rounded-xl border border-[var(--neutral-7)] bg-[var(--neutral-1)] p-6 shadow-sm">
<h2 className="mb-4 text-sm font-medium text-[var(--neutral-11)]">
API 调用次数 — 过去 30 天
</h2>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="usageGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--brand-primary)" stopOpacity={0.25} />
<stop offset="95%" stopColor="var(--brand-primary)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--neutral-7)"
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fill: "var(--neutral-11)", fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={formatValue}
tick={{ fill: "var(--neutral-11)", fontSize: 12 }}
axisLine={false}
tickLine={false}
width={48}
/>
<Tooltip
contentStyle={{
background: "var(--neutral-2)",
border: "1px solid var(--neutral-7)",
borderRadius: "8px",
color: "var(--neutral-12)",
fontSize: "13px",
}}
labelFormatter={formatDate}
formatter={(value: number) => [formatValue(value), "API 调用次数"]}
/>
<Area
type="monotone"
dataKey="value"
stroke="var(--brand-primary)"
strokeWidth={2}
fill="url(#usageGradient)"
dot={false}
activeDot={{ r: 4, fill: "var(--brand-primary)" }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</AnimateIn>
);
}所有 Recharts 颜色值均使用 CSS 变量("var(--brand-primary)")——从不使用硬编码的十六进制值。这确保了图表无需任何额外代码即可适应深色模式和主题预设变更。
第 6 阶段:权限控制
// packages/iam/permissions/src/roles.ts
export const ROLE_PERMISSIONS = {
OWNER: ["*"],
ADMIN: ["analytics:read", "analytics:export", /* …其他管理员权限 */],
MEMBER: ["analytics:read"],
VIEWER: [], // 访客不能查看分析数据
} as const;第 2 阶段添加的 requirePermission("analytics:read") 中间件已经足够。它对不包含该权限范围的角色返回 HTTP 403。
// apps/web/src/app/(dashboard)/analytics/page.tsx
import { PlanGate } from "@/components/plan-gate";
// 在页面组件内,用以下代码包裹内容:
<PlanGate
plans={["starter", "pro", "enterprise"]}
fallback={
<EmptyState
icon="lock"
title="免费套餐不包含分析功能"
description="升级到 Starter 或更高套餐以访问使用量分析。"
action={<UpgradeButton />}
/>
}
>
<Suspense fallback={<LoadingState message="正在加载使用数据…" />}>
<AnalyticsData />
</Suspense>
</PlanGate>第 7 阶段:侧边栏导航
// apps/web/src/components/sidebar/nav-items.ts
export const mainNavItems: NavItem[] = [
// ... 现有条目
{
title: "分析",
href: "/analytics",
icon: "chart-line",
group: "product",
plans: ["starter", "pro", "enterprise"], // 对免费套餐用户隐藏
},
];第 8 阶段:测试
Vitest 单元测试——API 处理器
// backends/gateway/src/routes/analytics.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { testClient } from "hono/testing";
import { analyticsRouter } from "./analytics";
vi.mock("@nebutra/metering", () => ({
getMetering: vi.fn().mockResolvedValue({
getTimeSeries: vi.fn().mockResolvedValue([
{ timestamp: new Date("2025-01-01"), value: BigInt(1500) },
{ timestamp: new Date("2025-01-02"), value: BigInt(2300) },
]),
}),
}));
vi.mock("@nebutra/tenant", () => ({
getCurrentTenant: vi.fn().mockReturnValue({ tenantId: "org_test_123" }),
}));
vi.mock("@nebutra/permissions", () => ({
requirePermission: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
describe("GET /usage", () => {
it("返回所请求计量器的时间序列", async () => {
const client = testClient(analyticsRouter);
const res = await client.usage.$get({ query: { meterId: "api_calls", days: "30" } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.series).toHaveLength(2);
expect(body.data.series[0]).toMatchObject({ date: "2025-01-01", value: 1500 });
});
it("省略 meterId 时默认使用 api_calls 计量器", async () => {
const client = testClient(analyticsRouter);
const res = await client.usage.$get({ query: { days: "7" } });
expect(res.status).toBe(200);
});
});运行命令:
pnpm --filter @nebutra/gateway testPlaywright E2E 测试
// apps/web/e2e/analytics.spec.ts
import { test, expect } from "@playwright/test";
test.describe("分析仪表板", () => {
test.beforeEach(async ({ page }) => {
// 以具有 analytics:read 权限的成员身份登录
await page.goto("/sign-in");
await page.getByLabel("邮箱").fill("[email protected]");
await page.getByLabel("密码").fill(process.env.E2E_TEST_PASSWORD!);
await page.getByRole("button", { name: "登录" }).click();
await page.waitForURL("/dashboard");
});
test("渲染带图表的分析页面", async ({ page }) => {
await page.goto("/analytics");
await expect(page.getByRole("heading", { name: "分析" })).toBeVisible();
await expect(page.getByText("API 调用次数 — 过去 30 天")).toBeVisible();
// Recharts 渲染一个 SVG——验证其存在
const chart = page.locator("svg.recharts-surface");
await expect(chart).toBeVisible();
});
test("对免费套餐用户显示升级提示", async ({ page }) => {
// 以免费套餐用户身份登录
await page.goto("/sign-in");
await page.getByLabel("邮箱").fill("[email protected]");
await page.getByLabel("密码").fill(process.env.E2E_TEST_PASSWORD!);
await page.getByRole("button", { name: "登录" }).click();
await page.goto("/analytics");
await expect(page.getByText("免费套餐不包含分析功能")).toBeVisible();
});
});运行命令:
pnpm --filter @nebutra/web e2e第 9 阶段:Storybook 故事
创建故事,以便图表可以独立开发和评审。
// apps/storybook/src/stories/AnalyticsChart.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { AnalyticsChart } from "../../web/src/app/(dashboard)/analytics/_components/analytics-chart";
// 生成 30 天的合成数据
function generateSeries(days = 30) {
return Array.from({ length: days }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (days - i));
return {
date: date.toISOString().split("T")[0],
value: Math.floor(Math.random() * 5000) + 500,
};
});
}
const meta: Meta<typeof AnalyticsChart> = {
title: "Patterns/AnalyticsChart",
component: AnalyticsChart,
tags: ["autodocs"],
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof AnalyticsChart>;
export const Default: Story = {
args: { series: generateSeries(30) },
};
export const ShortRange: Story = {
args: { series: generateSeries(7) },
};
export const Empty: Story = {
args: { series: [] },
};
export const HighVolume: Story = {
args: {
series: generateSeries(30).map((p) => ({ ...p, value: p.value * 1000 })),
},
};在 Storybook 中查看:
pnpm --filter @nebutra/storybook dev
# → http://localhost:6006 → Patterns/AnalyticsChart总结
您已端到端构建了一个完整的、生产就绪的功能:
| 阶段 | 产出物 |
|---|---|
| 1 | UsageSnapshot Prisma 模型 |
| 2 | GET /api/v1/analytics/usage Hono 端点 |
| 3 | 自动生成的 TypeScript 类型 |
| 4 | AnalyticsPage React Server Component |
| 5 | 使用 Token 颜色的 AnalyticsChart Recharts 组件 |
| 6 | analytics:read RBAC 权限范围 + 套餐限制 |
| 7 | 侧边栏导航条目 |
| 8 | Vitest 单元测试 + Playwright E2E 测试 |
| 9 | 带合成数据的 Storybook 故事 |
构建任何数据驱动的仪表板板块时,均可将此实战指南作为模板使用。
How is this guide?
最后更新于