Add cost metrics to analytics dashboard

This commit is contained in:
2026-04-28 11:27:51 +08:00
parent 67e43b02bf
commit ab915e9292
13 changed files with 226 additions and 56 deletions

View File

@@ -3,18 +3,19 @@
import { useEffect, useState, useCallback, useRef, startTransition } from "react"; import { useEffect, useState, useCallback, useRef, startTransition } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp, HelpCircle } from "lucide-react"; import { Users, Calendar, Hash, Zap, DollarSign, ArrowUpDown, ArrowDown, ArrowUp, HelpCircle } from "lucide-react";
import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { buildQuery, formatNumber, formatTokens } from "@/lib/utils"; import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context"; import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
interface AggItem { interface AggItem {
rank: number; name: string; calls: number; rank: number; name: string; calls: number;
prompt_tokens: number; completion_tokens: number; cache_creation_tokens: number; cache_read_tokens: number; total_tokens: number; prompt_tokens: number; completion_tokens: number; cache_creation_tokens: number; cache_read_tokens: number; total_tokens: number;
quota: number; quota_usd: number;
} }
type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens" | "cache_creation_tokens" | "cache_read_tokens" | "ratio"; type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens" | "cache_creation_tokens" | "cache_read_tokens" | "quota_usd" | "ratio";
function RatioTooltip({ text }: { text: string }) { function RatioTooltip({ text }: { text: string }) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
@@ -103,8 +104,8 @@ export default function AggregationPage() {
}); });
const totals = data.reduce( const totals = data.reduce(
(acc, d) => ({ calls: acc.calls + d.calls, tokens: acc.tokens + d.total_tokens }), (acc, d) => ({ calls: acc.calls + d.calls, tokens: acc.tokens + d.total_tokens, cost: acc.cost + d.quota_usd }),
{ calls: 0, tokens: 0 } { calls: 0, tokens: 0, cost: 0 }
); );
function handleSort(key: SortKey) { function handleSort(key: SortKey) {
@@ -126,6 +127,7 @@ export default function AggregationPage() {
{ key: "cache_read_tokens", label: t("th.cacheRead") }, { key: "cache_read_tokens", label: t("th.cacheRead") },
{ key: "completion_tokens", label: t("th.output") }, { key: "completion_tokens", label: t("th.output") },
{ key: "total_tokens", label: t("th.totalToken") }, { key: "total_tokens", label: t("th.totalToken") },
{ key: "quota_usd", label: t("th.cost") },
]; ];
return ( return (
@@ -155,6 +157,11 @@ export default function AggregationPage() {
<span style={{ color: "var(--text-muted)" }}>{t("agg.totalToken")}</span> <span style={{ color: "var(--text-muted)" }}>{t("agg.totalToken")}</span>
<span className="font-semibold font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(totals.tokens)}</span> <span className="font-semibold font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(totals.tokens)}</span>
</div> </div>
<div className="flex items-center gap-2">
<DollarSign className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.5 }} />
<span style={{ color: "var(--text-muted)" }}>{t("agg.totalCost")}</span>
<span className="font-semibold font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatUSD(totals.cost)}</span>
</div>
</motion.div> </motion.div>
)} )}
@@ -183,7 +190,7 @@ export default function AggregationPage() {
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr><td colSpan={10} className="px-4 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr> <tr><td colSpan={11} className="px-4 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
) : sorted.map((item, i) => { ) : sorted.map((item, i) => {
const pct = totals.tokens > 0 ? (item.total_tokens / totals.tokens * 100) : 0; const pct = totals.tokens > 0 ? (item.total_tokens / totals.tokens * 100) : 0;
const ratio = item.prompt_tokens > 0 ? (item.completion_tokens / item.prompt_tokens) : 0; const ratio = item.prompt_tokens > 0 ? (item.completion_tokens / item.prompt_tokens) : 0;
@@ -197,6 +204,7 @@ export default function AggregationPage() {
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.cache_read_tokens)}</td> <td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.cache_read_tokens)}</td>
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.completion_tokens)}</td> <td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.completion_tokens)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td> <td className="px-4 py-3 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota_usd)}</td>
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: ratio >= 1 ? "var(--accent)" : "var(--text-muted)" }}>{ratio.toFixed(2)}</td> <td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: ratio >= 1 ? "var(--accent)" : "var(--text-muted)" }}>{ratio.toFixed(2)}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">

View File

@@ -1,12 +1,22 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getTrends } from "@/lib/queries"; import { getTrends, type TrendGranularity } from "@/lib/queries";
const GRANULARITIES: TrendGranularity[] = ["hour", "day", "week", "month"];
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams; const sp = req.nextUrl.searchParams;
const granularity = (sp.get("granularity") || "day") as "day" | "week" | "month"; 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 startTs = sp.get("start") ? Number(sp.get("start")) : undefined;
const endTs = sp.get("end") ? Number(sp.get("end")) : undefined; const endTs = sp.get("end") ? Number(sp.get("end")) : undefined;
const channelId = sp.get("channel_id") ? Number(sp.get("channel_id")) : undefined;
const data = await getTrends(granularity, startTs, endTs); 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); return NextResponse.json(data);
} }

View File

@@ -3,12 +3,12 @@
import { useEffect, useState, useCallback, startTransition } from "react"; import { useEffect, useState, useCallback, startTransition } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { ArrowLeft, Hash, Zap, MessageSquare, DatabaseZap, BookOpen } from "lucide-react"; import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { StatsCard } from "@/components/StatsCard"; import { StatsCard } from "@/components/StatsCard";
import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { TrendChart } from "@/components/charts/TrendChart"; import { TrendChart } from "@/components/charts/TrendChart";
import { buildQuery, formatNumber, formatTokens } from "@/lib/utils"; import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context"; import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
@@ -75,12 +75,13 @@ export default function DetailPage() {
<div className="flex h-64 items-center justify-center"><div className="h-6 w-6 animate-spin rounded-full spinner" /></div> <div className="flex h-64 items-center justify-center"><div className="h-6 w-6 animate-spin rounded-full spinner" /></div>
) : data ? ( ) : data ? (
<> <>
<div className="grid grid-cols-2 gap-4 md:grid-cols-5"> <div className="grid grid-cols-2 gap-4 md:grid-cols-6">
<StatsCard title={t("dash.totalCalls")} value={data.calls} icon={Hash} delay={0} /> <StatsCard title={t("dash.totalCalls")} value={data.calls} icon={Hash} delay={0} />
<StatsCard title={t("th.totalToken")} value={data.total_tokens} format="tokens" icon={Zap} delay={0.05} /> <StatsCard title={t("th.totalToken")} value={data.total_tokens} format="tokens" icon={Zap} delay={0.05} />
<StatsCard title={t("th.input")} value={data.prompt_tokens} format="tokens" icon={MessageSquare} delay={0.1} /> <StatsCard title={t("th.cost")} value={data.quota / 500000} format="usd" icon={DollarSign} delay={0.1} />
<StatsCard title={t("th.cacheCreation")} value={data.cache_creation_tokens} format="tokens" icon={DatabaseZap} delay={0.15} /> <StatsCard title={t("th.input")} value={data.prompt_tokens} format="tokens" icon={MessageSquare} delay={0.15} />
<StatsCard title={t("th.cacheRead")} value={data.cache_read_tokens} format="tokens" icon={BookOpen} delay={0.2} /> <StatsCard title={t("th.cacheCreation")} value={data.cache_creation_tokens} format="tokens" icon={DatabaseZap} delay={0.2} />
<StatsCard title={t("th.cacheRead")} value={data.cache_read_tokens} format="tokens" icon={BookOpen} delay={0.25} />
</div> </div>
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="glass p-5"> <motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="glass p-5">
@@ -97,6 +98,7 @@ export default function DetailPage() {
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.name")}</th> <th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.name")}</th>
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.calls")}</th> <th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.calls")}</th>
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.totalToken")}</th> <th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.totalToken")}</th>
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.cost")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -105,6 +107,7 @@ export default function DetailPage() {
<td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.8 }}>{item.name}</td> <td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.8 }}>{item.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td> <td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td> <td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota / 500000)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback, startTransition } from "react"; import { useEffect, useState, useCallback, startTransition } from "react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Zap, Hash, Users, Cpu, TrendingUp, BarChart3 } from "lucide-react"; import { Zap, Hash, Users, Cpu, DollarSign, TrendingUp, BarChart3 } from "lucide-react";
import { StatsCard } from "@/components/StatsCard"; import { StatsCard } from "@/components/StatsCard";
import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { TrendChart } from "@/components/charts/TrendChart"; import { TrendChart } from "@/components/charts/TrendChart";
@@ -14,6 +14,7 @@ import { useI18n } from "@/lib/i18n";
interface OverviewData { interface OverviewData {
total_calls: number; total_calls: number;
total_tokens: number; total_tokens: number;
total_quota: number;
active_users: number; active_users: number;
active_models: number; active_models: number;
} }
@@ -24,6 +25,7 @@ interface TrendPoint {
total_tokens: number; total_tokens: number;
prompt_tokens: number; prompt_tokens: number;
completion_tokens: number; completion_tokens: number;
quota: number;
} }
interface RankItem { interface RankItem {
@@ -35,8 +37,8 @@ interface RankItem {
export default function DashboardPage() { export default function DashboardPage() {
const { t } = useI18n(); const { t } = useI18n();
const { getEffectiveRange } = useTimeRange(); const { getEffectiveRange } = useTimeRange();
const [granularity, setGranularity] = useState<"day" | "week" | "month">("day"); const [granularity, setGranularity] = useState<"hour" | "day" | "week" | "month">("day");
const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens"); const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls" | "quota">("total_tokens");
const [overview, setOverview] = useState<OverviewData | null>(null); const [overview, setOverview] = useState<OverviewData | null>(null);
const [trends, setTrends] = useState<TrendPoint[]>([]); const [trends, setTrends] = useState<TrendPoint[]>([]);
const [userRank, setUserRank] = useState<RankItem[]>([]); const [userRank, setUserRank] = useState<RankItem[]>([]);
@@ -61,6 +63,7 @@ export default function DashboardPage() {
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);
const grans = [ const grans = [
{ key: "hour" as const, label: t("gran.hour") },
{ key: "day" as const, label: t("gran.day") }, { key: "day" as const, label: t("gran.day") },
{ key: "week" as const, label: t("gran.week") }, { key: "week" as const, label: t("gran.week") },
{ key: "month" as const, label: t("gran.month") }, { key: "month" as const, label: t("gran.month") },
@@ -76,11 +79,12 @@ export default function DashboardPage() {
</div> </div>
{overview && ( {overview && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-5">
<StatsCard title={t("dash.totalCalls")} value={overview.total_calls} icon={Hash} delay={0} /> <StatsCard title={t("dash.totalCalls")} value={overview.total_calls} icon={Hash} delay={0} />
<StatsCard title={t("dash.tokenUsage")} value={overview.total_tokens} format="tokens" icon={Zap} delay={0.05} /> <StatsCard title={t("dash.tokenUsage")} value={overview.total_tokens} format="tokens" icon={Zap} delay={0.05} />
<StatsCard title={t("dash.activeUsers")} value={overview.active_users} icon={Users} delay={0.1} /> <StatsCard title={t("dash.totalCost")} value={overview.total_quota / 500000} format="usd" icon={DollarSign} delay={0.1} />
<StatsCard title={t("dash.activeModels")} value={overview.active_models} icon={Cpu} delay={0.15} /> <StatsCard title={t("dash.activeUsers")} value={overview.active_users} icon={Users} delay={0.15} />
<StatsCard title={t("dash.activeModels")} value={overview.active_models} icon={Cpu} delay={0.2} />
</div> </div>
)} )}
@@ -104,7 +108,7 @@ export default function DashboardPage() {
))} ))}
</div> </div>
<div className="flex gap-1 rounded-md p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}> <div className="flex gap-1 rounded-md p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
{([["total_tokens", t("metric.token")], ["calls", t("metric.calls")]] as const).map(([k, l]) => ( {([["total_tokens", t("metric.token")], ["calls", t("metric.calls")], ["quota", t("metric.cost")]] as const).map(([k, l]) => (
<button key={k} onClick={() => setTrendMetric(k)} <button key={k} onClick={() => setTrendMetric(k)}
className="px-2.5 py-1 text-xs rounded transition-colors" className="px-2.5 py-1 text-xs rounded transition-colors"
style={{ style={{

View File

@@ -5,16 +5,17 @@ import Link from "next/link";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Trophy, Users, Cpu, Radio, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; import { Trophy, Users, Cpu, Radio, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { buildQuery, formatNumber, formatTokens } from "@/lib/utils"; import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context"; import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
type Tab = "user" | "model" | "channel"; type Tab = "user" | "model" | "channel";
type SortKey = "calls" | "prompt_tokens" | "completion_tokens" | "cache_creation_tokens" | "cache_read_tokens" | "total_tokens"; type SortKey = "calls" | "prompt_tokens" | "completion_tokens" | "cache_creation_tokens" | "cache_read_tokens" | "total_tokens" | "quota_usd";
interface RankItem { interface RankItem {
rank: number; name: string; username?: string; id?: number; calls: number; rank: number; name: string; username?: string; id?: number; calls: number;
prompt_tokens: number; completion_tokens: number; cache_creation_tokens: number; cache_read_tokens: number; total_tokens: number; prompt_tokens: number; completion_tokens: number; cache_creation_tokens: number; cache_read_tokens: number; total_tokens: number;
quota: number; quota_usd: number;
} }
export default function RankingsPage() { export default function RankingsPage() {
@@ -74,6 +75,7 @@ export default function RankingsPage() {
{ key: "cache_read_tokens", label: t("th.cacheRead"), align: "right" }, { key: "cache_read_tokens", label: t("th.cacheRead"), align: "right" },
{ key: "completion_tokens", label: t("th.output"), align: "right" }, { key: "completion_tokens", label: t("th.output"), align: "right" },
{ key: "total_tokens", label: t("th.totalToken"), align: "right" }, { key: "total_tokens", label: t("th.totalToken"), align: "right" },
{ key: "quota_usd", label: t("th.cost"), align: "right" },
]; ];
return ( return (
@@ -127,7 +129,7 @@ export default function RankingsPage() {
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr><td colSpan={8} className="px-4 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr> <tr><td colSpan={9} className="px-4 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
) : sorted.map((item, i) => ( ) : sorted.map((item, i) => (
<motion.tr key={item.name} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: i * 0.02 }} <motion.tr key={item.name} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: i * 0.02 }}
className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}> className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
@@ -141,6 +143,7 @@ export default function RankingsPage() {
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.cache_read_tokens)}</td> <td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.cache_read_tokens)}</td>
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.completion_tokens)}</td> <td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.completion_tokens)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td> <td className="px-4 py-3 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota_usd)}</td>
</motion.tr> </motion.tr>
))} ))}
</tbody> </tbody>

20
bun-test.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
declare module "bun:test" {
export const beforeEach: (fn: () => void | Promise<void>) => void;
export const describe: (name: string, fn: () => void) => void;
export const expect: (value: unknown) => {
toBe: (expected: unknown) => void;
toBeNull: () => void;
toContain: (expected: unknown) => void;
toEqual: (expected: unknown) => void;
};
export const mock: {
<T extends (...args: never[]) => unknown>(fn: T): T & {
mock: {
calls: unknown[][];
};
mockClear: () => void;
};
module: (path: string, factory: () => Record<string, unknown>) => void;
};
export const test: (name: string, fn: () => void | Promise<void>) => void;
}

View File

@@ -101,7 +101,7 @@ export function Sidebar() {
style={{ color: "var(--text-muted)", background: "var(--row-hover)", border: "1px solid var(--surface-border)" }} style={{ color: "var(--text-muted)", background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}
> >
<Languages className="h-3.5 w-3.5" /> <Languages className="h-3.5 w-3.5" />
<span className="font-medium">{locale === "zh" ? "中文" : "English"}</span> <span className="font-medium" suppressHydrationWarning>{locale === "zh" ? "中文" : "English"}</span>
</button> </button>
{/* Status */} {/* Status */}

View File

@@ -1,19 +1,19 @@
"use client"; "use client";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { formatNumber, formatTokens } from "@/lib/utils"; import { formatNumber, formatTokens, formatUSD } from "@/lib/utils";
import { type LucideIcon } from "lucide-react"; import { type LucideIcon } from "lucide-react";
interface StatsCardProps { interface StatsCardProps {
title: string; title: string;
value: number; value: number;
format?: "number" | "tokens"; format?: "number" | "tokens" | "usd";
icon: LucideIcon; icon: LucideIcon;
delay?: number; delay?: number;
} }
export function StatsCard({ title, value, format = "number", icon: Icon, delay = 0 }: StatsCardProps) { export function StatsCard({ title, value, format = "number", icon: Icon, delay = 0 }: StatsCardProps) {
const display = format === "tokens" ? formatTokens(value) : formatNumber(value); const display = format === "tokens" ? formatTokens(value) : format === "usd" ? formatUSD(value) : formatNumber(value);
return ( return (
<motion.div <motion.div

View File

@@ -1,24 +1,42 @@
"use client"; "use client";
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts"; import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
import { formatTokens } from "@/lib/utils"; import { formatTokens, formatUSD } from "@/lib/utils";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
interface TrendPoint { date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number; } interface TrendPoint { date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number; quota?: number; }
export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint[]; metric?: "total_tokens" | "calls" }) { export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint[]; metric?: "total_tokens" | "calls" | "quota" }) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
if (!data.length) if (!data.length)
return <div className="flex h-64 items-center justify-center" style={{ color: "var(--text-muted)" }}>{t("common.noData")}</div>; return <div className="flex h-64 items-center justify-center" style={{ color: "var(--text-muted)" }}>{t("common.noData")}</div>;
// 本地化日期格式 const parseTrendDate = (dateStr: string) => {
const [datePart, timePart] = dateStr.replace("T", " ").split(" ");
const [year, month, day] = datePart.split("-").map(Number);
const hour = timePart ? timePart.slice(0, 5) : "";
return { year, month, day, hour };
};
const formatDateLabel = (dateStr: string) => { const formatDateLabel = (dateStr: string) => {
const d = new Date(dateStr); const { year, month, day, hour } = parseTrendDate(dateStr);
const d = new Date(year, month - 1, day);
if (locale === "zh") { if (locale === "zh") {
return `${d.getMonth() + 1}/${d.getDate()}`; return hour ? `${month}/${day} ${hour}` : `${month}/${day}`;
} }
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); const dateLabel = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return hour ? `${dateLabel}, ${hour}` : dateLabel;
};
const formatTooltipLabel = (label: string) => {
const { year, month, day, hour } = parseTrendDate(label);
const d = new Date(year, month - 1, day);
if (locale === "zh") {
return hour ? `${year}/${month}/${day} ${hour}` : `${year}/${month}/${day}`;
}
const dateLabel = d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
return hour ? `${dateLabel}, ${hour}` : dateLabel;
}; };
const tooltipStyle = { const tooltipStyle = {
@@ -66,12 +84,7 @@ export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint
/> />
<Tooltip <Tooltip
contentStyle={tooltipStyle} contentStyle={tooltipStyle}
labelFormatter={(label) => { labelFormatter={(label) => formatTooltipLabel(String(label))}
const d = new Date(String(label));
return locale === "zh"
? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
: d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}}
formatter={(value, name) => [ formatter={(value, name) => [
formatTokens(Number(value)), formatTokens(Number(value)),
name === t("th.input") ? t("th.input") : t("th.output"), name === t("th.input") ? t("th.input") : t("th.output"),
@@ -85,6 +98,26 @@ export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint
); );
} }
// 金额模式:单 Y 轴
if (metric === "quota") {
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
<XAxis dataKey="date" tickFormatter={formatDateLabel} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
<YAxis tickFormatter={(v) => formatUSD(v / 500000)} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={(label) => formatTooltipLabel(String(label))}
formatter={(value) => [formatUSD(Number(value) / 500000), t("th.cost")]}
/>
<Legend />
<Line type="monotone" dataKey="quota" name={t("th.cost")} stroke="var(--accent)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
// 调用量模式:单 Y 轴 // 调用量模式:单 Y 轴
return ( return (
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
@@ -94,12 +127,7 @@ export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint
<YAxis tickFormatter={(v) => formatTokens(v)} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" /> <YAxis tickFormatter={(v) => formatTokens(v)} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
<Tooltip <Tooltip
contentStyle={tooltipStyle} contentStyle={tooltipStyle}
labelFormatter={(label) => { labelFormatter={(label) => formatTooltipLabel(String(label))}
const d = new Date(String(label));
return locale === "zh"
? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
: d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}}
formatter={(value, name) => [ formatter={(value, name) => [
formatTokens(Number(value)), formatTokens(Number(value)),
name === t("th.calls") ? t("th.calls") : String(name), name === t("th.calls") ? t("th.calls") : String(name),

View File

@@ -31,11 +31,13 @@ const translations = {
"time.endDate": "结束日期", "time.endDate": "结束日期",
"time.confirm": "确认", "time.confirm": "确认",
// granularity // granularity
"gran.hour": "小时",
"gran.day": "日", "gran.day": "日",
"gran.week": "周", "gran.week": "周",
"gran.month": "月", "gran.month": "月",
// metrics // metrics
"metric.token": "Token", "metric.token": "Token",
"metric.cost": "金额",
"metric.calls": "调用量", "metric.calls": "调用量",
// dashboard // dashboard
"dash.title": "仪表盘", "dash.title": "仪表盘",
@@ -45,6 +47,7 @@ const translations = {
"dash.activeModels": "活跃模型", "dash.activeModels": "活跃模型",
"dash.trend": "使用趋势", "dash.trend": "使用趋势",
"dash.userTop10": "用户 Top 10 — Token 消耗", "dash.userTop10": "用户 Top 10 — Token 消耗",
"dash.totalCost": "消费金额",
"dash.modelTop10": "模型 Top 10 — Token 消耗", "dash.modelTop10": "模型 Top 10 — Token 消耗",
// table headers // table headers
"th.rank": "#", "th.rank": "#",
@@ -55,6 +58,7 @@ const translations = {
"th.output": "输出", "th.output": "输出",
"th.cacheCreation": "缓存创建", "th.cacheCreation": "缓存创建",
"th.cacheRead": "缓存读取", "th.cacheRead": "缓存读取",
"th.cost": "金额",
"th.totalToken": "总 Token", "th.totalToken": "总 Token",
"th.time": "时间", "th.time": "时间",
"th.realModel": "真实模型", "th.realModel": "真实模型",
@@ -69,6 +73,7 @@ const translations = {
"agg.title": "用户聚合", "agg.title": "用户聚合",
"agg.userCount": "用户数", "agg.userCount": "用户数",
"agg.totalCalls": "总调用", "agg.totalCalls": "总调用",
"agg.totalCost": "总金额",
"agg.totalToken": "总 Token", "agg.totalToken": "总 Token",
"agg.ratio": "转换率", "agg.ratio": "转换率",
"agg.ratioTip": "输出Token / 输入Token反映每次请求的生成效率。>1 表示输出多于输入(如生成、写作),<1 表示输入多于输出(如分析、摘要)", "agg.ratioTip": "输出Token / 输入Token反映每次请求的生成效率。>1 表示输出多于输入(如生成、写作),<1 表示输入多于输出(如分析、摘要)",
@@ -154,10 +159,12 @@ const translations = {
"time.startDate": "Start", "time.startDate": "Start",
"time.endDate": "End", "time.endDate": "End",
"time.confirm": "Confirm", "time.confirm": "Confirm",
"gran.hour": "Hour",
"gran.day": "Day", "gran.day": "Day",
"gran.week": "Week", "gran.week": "Week",
"gran.month": "Month", "gran.month": "Month",
"metric.token": "Token", "metric.token": "Token",
"metric.cost": "Cost",
"metric.calls": "Calls", "metric.calls": "Calls",
"dash.title": "Dashboard", "dash.title": "Dashboard",
"dash.totalCalls": "Total Calls", "dash.totalCalls": "Total Calls",
@@ -166,6 +173,7 @@ const translations = {
"dash.activeModels": "Active Models", "dash.activeModels": "Active Models",
"dash.trend": "Usage Trend", "dash.trend": "Usage Trend",
"dash.userTop10": "User Top 10 — Token Usage", "dash.userTop10": "User Top 10 — Token Usage",
"dash.totalCost": "Total Cost",
"dash.modelTop10": "Model Top 10 — Token Usage", "dash.modelTop10": "Model Top 10 — Token Usage",
"th.rank": "#", "th.rank": "#",
"th.name": "Name", "th.name": "Name",
@@ -175,6 +183,7 @@ const translations = {
"th.output": "Output", "th.output": "Output",
"th.cacheCreation": "Cache Write", "th.cacheCreation": "Cache Write",
"th.cacheRead": "Cache Read", "th.cacheRead": "Cache Read",
"th.cost": "Cost",
"th.totalToken": "Total Token", "th.totalToken": "Total Token",
"th.time": "Time", "th.time": "Time",
"th.realModel": "Real Model", "th.realModel": "Real Model",
@@ -187,6 +196,7 @@ const translations = {
"agg.title": "User Aggregation", "agg.title": "User Aggregation",
"agg.userCount": "Users", "agg.userCount": "Users",
"agg.totalCalls": "Total Calls", "agg.totalCalls": "Total Calls",
"agg.totalCost": "Total Cost",
"agg.totalToken": "Total Token", "agg.totalToken": "Total Token",
"agg.ratio": "Out/In Ratio", "agg.ratio": "Out/In Ratio",
"agg.ratioTip": "Completion tokens / Prompt tokens. >1 means more output than input (generation, writing); <1 means more input than output (analysis, summarization)", "agg.ratioTip": "Completion tokens / Prompt tokens. >1 means more output than input (generation, writing); <1 means more input than output (analysis, summarization)",

46
lib/queries.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
const queryMock = mock(async () => [
{
date: "2026-04-01 13:00:00",
calls: 1,
prompt_tokens: 10,
completion_tokens: 20,
cache_creation_tokens: 3,
cache_read_tokens: 4,
quota: 100,
},
]);
mock.module("./db", () => ({
query: queryMock,
}));
const { getTrends } = await import("./queries");
describe("getTrends", () => {
beforeEach(() => {
queryMock.mockClear();
});
test("adds optional detail filters to the trend query", async () => {
await getTrends("day", 101, 201, { username: "alice" });
expect(queryMock.mock.calls[0][0]).toContain("username = $3");
expect(queryMock.mock.calls[0][1]).toEqual([101, 201, "alice"]);
await getTrends("day", 102, 202, { model: "gpt-4.1" });
expect(queryMock.mock.calls[1][0]).toContain("= $3");
expect(queryMock.mock.calls[1][1]).toEqual([102, 202, "gpt-4.1"]);
await getTrends("day", 103, 203, { channelId: 7 });
expect(queryMock.mock.calls[2][0]).toContain("channel_id = $3");
expect(queryMock.mock.calls[2][1]).toEqual([103, 203, 7]);
});
test("keeps the hour in hourly trend buckets", async () => {
const trends = await getTrends("hour", 104, 204);
expect(queryMock.mock.calls[0][0]).toContain("date_trunc('hour'");
expect(trends[0].date).toBe("2026-04-01 13:00");
});
});

View File

@@ -130,19 +130,50 @@ export interface TrendPoint {
quota: number; quota: number;
} }
export type TrendGranularity = "hour" | "day" | "week" | "month";
export interface TrendFilters {
username?: string;
model?: string;
channelId?: number;
}
function appendTrendFilters(
where: string,
params: (string | number | boolean | null)[],
filters: TrendFilters = {}
): string {
if (filters.username) {
params.push(filters.username);
where += ` AND username = $${params.length}`;
}
if (filters.model) {
params.push(filters.model);
where += ` AND ${REAL_MODEL} = $${params.length}`;
}
if (filters.channelId !== undefined) {
params.push(filters.channelId);
where += ` AND channel_id = $${params.length}`;
}
return where;
}
export function getTrends( export function getTrends(
granularity: "day" | "week" | "month" = "day", granularity: TrendGranularity = "day",
startTs?: number, startTs?: number,
endTs?: number endTs?: number,
filters: TrendFilters = {}
): Promise<TrendPoint[]> { ): Promise<TrendPoint[]> {
return cached(cacheKey("trends", granularity, startTs, endTs), async () => { return cached(cacheKey("trends", granularity, startTs, endTs, filters.username, filters.model, filters.channelId), async () => {
const params: (string | number | boolean | null)[] = []; const params: (string | number | boolean | null)[] = [];
const where = timeWhere(params, startTs, endTs); const where = appendTrendFilters(timeWhere(params, startTs, endTs), params, filters);
const truncExpr = const truncExpr =
granularity === "day" granularity === "hour"
? `((to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text` ? `to_char(date_trunc('hour', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai'), 'YYYY-MM-DD HH24:00')`
: `(date_trunc('${granularity}', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`; : granularity === "day"
? `((to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`
: `(date_trunc('${granularity}', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`;
const rows = await query( const rows = await query(
`SELECT `SELECT
@@ -159,7 +190,7 @@ export function getTrends(
); );
return rows.map((r) => ({ return rows.map((r) => ({
date: String(r.date).slice(0, 10), date: String(r.date).slice(0, granularity === "hour" ? 16 : 10),
calls: Number(r.calls), calls: Number(r.calls),
prompt_tokens: Number(r.prompt_tokens), prompt_tokens: Number(r.prompt_tokens),
completion_tokens: Number(r.completion_tokens), completion_tokens: Number(r.completion_tokens),

View File

@@ -1,7 +1,14 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
turbopack: {
root: projectRoot,
},
}; };
export default nextConfig; export default nextConfig;