Add cost metrics to analytics dashboard
This commit is contained in:
@@ -3,18 +3,19 @@
|
||||
import { useEffect, useState, useCallback, useRef, startTransition } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 { buildQuery, formatNumber, formatTokens } from "@/lib/utils";
|
||||
import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils";
|
||||
import { useTimeRange } from "@/lib/time-range-context";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface AggItem {
|
||||
rank: number; name: string; calls: 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 }) {
|
||||
const [show, setShow] = useState(false);
|
||||
@@ -103,8 +104,8 @@ export default function AggregationPage() {
|
||||
});
|
||||
|
||||
const totals = data.reduce(
|
||||
(acc, d) => ({ calls: acc.calls + d.calls, tokens: acc.tokens + d.total_tokens }),
|
||||
{ calls: 0, tokens: 0 }
|
||||
(acc, d) => ({ calls: acc.calls + d.calls, tokens: acc.tokens + d.total_tokens, cost: acc.cost + d.quota_usd }),
|
||||
{ calls: 0, tokens: 0, cost: 0 }
|
||||
);
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
@@ -126,6 +127,7 @@ export default function AggregationPage() {
|
||||
{ key: "cache_read_tokens", label: t("th.cacheRead") },
|
||||
{ key: "completion_tokens", label: t("th.output") },
|
||||
{ key: "total_tokens", label: t("th.totalToken") },
|
||||
{ key: "quota_usd", label: t("th.cost") },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -155,6 +157,11 @@ export default function AggregationPage() {
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -183,7 +190,7 @@ export default function AggregationPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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) => {
|
||||
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;
|
||||
@@ -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.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-[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">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
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) {
|
||||
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 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);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useEffect, useState, useCallback, startTransition } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
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 { StatsCard } from "@/components/StatsCard";
|
||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||
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 { 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>
|
||||
) : 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("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.cacheCreation")} value={data.cache_creation_tokens} format="tokens" icon={DatabaseZap} delay={0.15} />
|
||||
<StatsCard title={t("th.cacheRead")} value={data.cache_read_tokens} format="tokens" icon={BookOpen} delay={0.2} />
|
||||
<StatsCard title={t("th.cost")} value={data.quota / 500000} format="usd" icon={DollarSign} delay={0.1} />
|
||||
<StatsCard title={t("th.input")} value={data.prompt_tokens} format="tokens" icon={MessageSquare} delay={0.15} />
|
||||
<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>
|
||||
|
||||
<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-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.cost")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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 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)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota / 500000)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
18
app/page.tsx
18
app/page.tsx
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback, startTransition } from "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 { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||
import { TrendChart } from "@/components/charts/TrendChart";
|
||||
@@ -14,6 +14,7 @@ import { useI18n } from "@/lib/i18n";
|
||||
interface OverviewData {
|
||||
total_calls: number;
|
||||
total_tokens: number;
|
||||
total_quota: number;
|
||||
active_users: number;
|
||||
active_models: number;
|
||||
}
|
||||
@@ -24,6 +25,7 @@ interface TrendPoint {
|
||||
total_tokens: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
quota: number;
|
||||
}
|
||||
|
||||
interface RankItem {
|
||||
@@ -35,8 +37,8 @@ interface RankItem {
|
||||
export default function DashboardPage() {
|
||||
const { t } = useI18n();
|
||||
const { getEffectiveRange } = useTimeRange();
|
||||
const [granularity, setGranularity] = useState<"day" | "week" | "month">("day");
|
||||
const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens");
|
||||
const [granularity, setGranularity] = useState<"hour" | "day" | "week" | "month">("day");
|
||||
const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls" | "quota">("total_tokens");
|
||||
const [overview, setOverview] = useState<OverviewData | null>(null);
|
||||
const [trends, setTrends] = useState<TrendPoint[]>([]);
|
||||
const [userRank, setUserRank] = useState<RankItem[]>([]);
|
||||
@@ -61,6 +63,7 @@ export default function DashboardPage() {
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const grans = [
|
||||
{ key: "hour" as const, label: t("gran.hour") },
|
||||
{ key: "day" as const, label: t("gran.day") },
|
||||
{ key: "week" as const, label: t("gran.week") },
|
||||
{ key: "month" as const, label: t("gran.month") },
|
||||
@@ -76,11 +79,12 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{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.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.activeModels")} value={overview.active_models} icon={Cpu} delay={0.15} />
|
||||
<StatsCard title={t("dash.totalCost")} value={overview.total_quota / 500000} format="usd" icon={DollarSign} delay={0.1} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -104,7 +108,7 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
<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)}
|
||||
className="px-2.5 py-1 text-xs rounded transition-colors"
|
||||
style={{
|
||||
|
||||
@@ -5,16 +5,17 @@ import Link from "next/link";
|
||||
import { motion } from "motion/react";
|
||||
import { Trophy, Users, Cpu, Radio, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
|
||||
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 { useI18n } from "@/lib/i18n";
|
||||
|
||||
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 {
|
||||
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;
|
||||
quota: number; quota_usd: number;
|
||||
}
|
||||
|
||||
export default function RankingsPage() {
|
||||
@@ -74,6 +75,7 @@ export default function RankingsPage() {
|
||||
{ key: "cache_read_tokens", label: t("th.cacheRead"), align: "right" },
|
||||
{ key: "completion_tokens", label: t("th.output"), align: "right" },
|
||||
{ key: "total_tokens", label: t("th.totalToken"), align: "right" },
|
||||
{ key: "quota_usd", label: t("th.cost"), align: "right" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -127,7 +129,7 @@ export default function RankingsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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) => (
|
||||
<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)" }}>
|
||||
@@ -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.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-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota_usd)}</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user