feat: add output/input ratio column to aggregation page
Add a sortable "Out/In Ratio" (completion_tokens / prompt_tokens) column with a portal-based tooltip explaining the metric. Fix hydration mismatch by switching to useSyncExternalStore for localStorage reads in TimeRangeProvider. Update CLAUDE.md with project documentation.
This commit is contained in:
62
CLAUDE.md
62
CLAUDE.md
@@ -1 +1,63 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start dev server (localhost:3000)
|
||||||
|
npm run build # Production build (standalone output)
|
||||||
|
npm run start # Start production server
|
||||||
|
npm run lint # ESLint (eslint-config-next/core-web-vitals + typescript)
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker deployment: port 8019, `docker-compose up` with `.env.production`, external network `sinobridge`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Next.js 16 App Router analytics dashboard for an API gateway. React 19, TypeScript strict mode, Tailwind CSS 4, Recharts 3, PostgreSQL.
|
||||||
|
|
||||||
|
### Three Global Contexts (wrapped in `components/ClientProviders.tsx`)
|
||||||
|
|
||||||
|
1. **ThemeProvider** (`lib/theme.tsx`) — light/dark/system, localStorage `theme`, supports iframe embedding via `?theme=` query param and postMessage sync
|
||||||
|
2. **I18nProvider** (`lib/i18n.tsx`) — zh/en, localStorage `locale`, all translation keys in a single `translations` object
|
||||||
|
3. **TimeRangeProvider** (`lib/time-range-context.tsx`) — today/7d/30d/all/custom, localStorage `time-range`, exposes `getEffectiveRange()` returning `{ start?, end? }` unix timestamps (seconds)
|
||||||
|
|
||||||
|
Pages consume these via `useTheme()`, `useI18n()`, `useTimeRange()`. The `TimeRangeSelector` component reads from context (no props).
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
Pages call `getEffectiveRange()` → `buildQuery("/api/...", { start, end })` → API route → `lib/queries.ts` SQL → PostgreSQL → JSON response → Recharts/tables.
|
||||||
|
|
||||||
|
### Database Layer (`lib/db.ts`, `lib/queries.ts`)
|
||||||
|
|
||||||
|
- `pg` pool (max 10 connections), env var `PG_CONNECTION_STRING`
|
||||||
|
- All queries filter `type = 2`, timezone `Asia/Shanghai`
|
||||||
|
- Real model name resolution: `COALESCE(other::jsonb->>'upstream_model_name', model_name)`
|
||||||
|
- Quota to USD: `quota / 500000`
|
||||||
|
- Parameterized queries with `$N` positional params
|
||||||
|
|
||||||
|
### API Routes (all GET, query-param driven)
|
||||||
|
|
||||||
|
| Route | Key Params | Returns |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| `/api/overview` | start, end | Aggregate stats (calls, tokens, users, models, channels) |
|
||||||
|
| `/api/trends` | start, end, granularity (day/week/month) | Time-series array |
|
||||||
|
| `/api/rankings` | start, end, type (user/model/channel), limit | Ranked items |
|
||||||
|
| `/api/detail/[type]/[id]` | start, end | Stats + breakdown (models or users) |
|
||||||
|
| `/api/logs` | start, end, page, page_size, username, model, token_name | Paginated logs |
|
||||||
|
| `/api/aggregation` | start, end | All users aggregated (top 500) |
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
CSS variables in `globals.css` — dark theme default (cyan `#00e5ff` accent), light theme (blue `#0284c7`). Key classes: `.glass` (glassmorphic card), `.gradient-text`, `.btn-accent`, `.input-glass`, `.row-glow`. Portal page (`/portal`) has its own extensive animation system.
|
||||||
|
|
||||||
|
### Path Alias
|
||||||
|
|
||||||
|
`@/*` maps to project root (tsconfig).
|
||||||
|
|
||||||
|
### Fonts
|
||||||
|
|
||||||
|
Outfit (sans, `--font-geist-sans`) and JetBrains Mono (mono, `--font-geist-mono`) via `next/font/google`.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
|
import { Users, Calendar, Hash, Zap, 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 } from "@/lib/utils";
|
||||||
import { useTimeRange } from "@/lib/time-range-context";
|
import { useTimeRange } from "@/lib/time-range-context";
|
||||||
@@ -13,7 +14,66 @@ interface AggItem {
|
|||||||
prompt_tokens: number; completion_tokens: number; total_tokens: number;
|
prompt_tokens: number; completion_tokens: number; total_tokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens";
|
type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens" | "ratio";
|
||||||
|
|
||||||
|
function RatioTooltip({ text }: { text: string }) {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||||
|
const iconRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
|
function handleEnter() {
|
||||||
|
if (iconRef.current) {
|
||||||
|
const rect = iconRef.current.getBoundingClientRect();
|
||||||
|
setPos({ x: rect.right, y: rect.top });
|
||||||
|
}
|
||||||
|
setShow(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={iconRef}
|
||||||
|
className="cursor-help"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseEnter={handleEnter}
|
||||||
|
onMouseLeave={() => setShow(false)}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-3 w-3" style={{ opacity: 0.5 }} />
|
||||||
|
</span>
|
||||||
|
{mounted && show && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed w-60 rounded-lg px-3.5 py-2.5 text-xs font-normal leading-relaxed z-[9999]"
|
||||||
|
style={{
|
||||||
|
top: pos.y - 8,
|
||||||
|
left: pos.x,
|
||||||
|
transform: "translate(-100%, -100%)",
|
||||||
|
background: "var(--background)",
|
||||||
|
border: "1px solid var(--surface-border)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
<span
|
||||||
|
className="absolute w-0 h-0"
|
||||||
|
style={{
|
||||||
|
bottom: "-6px",
|
||||||
|
right: "8px",
|
||||||
|
borderLeft: "6px solid transparent",
|
||||||
|
borderRight: "6px solid transparent",
|
||||||
|
borderTop: "6px solid var(--surface-border)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AggregationPage() {
|
export default function AggregationPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -34,7 +94,11 @@ export default function AggregationPage() {
|
|||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
const sorted = [...data].sort((a, b) => {
|
const sorted = [...data].sort((a, b) => {
|
||||||
const diff = (a[sortKey] as number) - (b[sortKey] as number);
|
const getVal = (item: AggItem) =>
|
||||||
|
sortKey === "ratio"
|
||||||
|
? (item.prompt_tokens > 0 ? item.completion_tokens / item.prompt_tokens : 0)
|
||||||
|
: (item[sortKey] as number);
|
||||||
|
const diff = getVal(a) - getVal(b);
|
||||||
return sortAsc ? diff : -diff;
|
return sortAsc ? diff : -diff;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,14 +168,23 @@ export default function AggregationPage() {
|
|||||||
<span className="inline-flex items-center gap-1">{h.label} <SortIcon col={h.key} /></span>
|
<span className="inline-flex items-center gap-1">{h.label} <SortIcon col={h.key} /></span>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider cursor-pointer select-none transition-colors"
|
||||||
|
style={{ color: "var(--text-muted)" }} onClick={() => handleSort("ratio")}>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{t("agg.ratio")}
|
||||||
|
<SortIcon col="ratio" />
|
||||||
|
<RatioTooltip text={t("agg.ratioTip")} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
|
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={7} 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={8} 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;
|
||||||
return (
|
return (
|
||||||
<tr key={item.name} className="row-glow transition-colors group" style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
<tr key={item.name} className="row-glow transition-colors group" style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||||
<td className="px-4 py-3 font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{i + 1}</td>
|
<td className="px-4 py-3 font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{i + 1}</td>
|
||||||
@@ -120,6 +193,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.prompt_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.prompt_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: 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">
|
||||||
<div className="w-16 h-1.5 rounded-full overflow-hidden" style={{ background: "var(--progress-bg)" }}>
|
<div className="w-16 h-1.5 rounded-full overflow-hidden" style={{ background: "var(--progress-bg)" }}>
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ const translations = {
|
|||||||
"agg.userCount": "用户数",
|
"agg.userCount": "用户数",
|
||||||
"agg.totalCalls": "总调用",
|
"agg.totalCalls": "总调用",
|
||||||
"agg.totalToken": "总 Token",
|
"agg.totalToken": "总 Token",
|
||||||
|
"agg.ratio": "转换率",
|
||||||
|
"agg.ratioTip": "输出Token / 输入Token,反映每次请求的生成效率。>1 表示输出多于输入(如生成、写作),<1 表示输入多于输出(如分析、摘要)",
|
||||||
// logs
|
// logs
|
||||||
"logs.title": "日志明细",
|
"logs.title": "日志明细",
|
||||||
"logs.filterUser": "用户名",
|
"logs.filterUser": "用户名",
|
||||||
@@ -148,6 +150,8 @@ const translations = {
|
|||||||
"agg.userCount": "Users",
|
"agg.userCount": "Users",
|
||||||
"agg.totalCalls": "Total Calls",
|
"agg.totalCalls": "Total Calls",
|
||||||
"agg.totalToken": "Total Token",
|
"agg.totalToken": "Total Token",
|
||||||
|
"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)",
|
||||||
"logs.title": "Log Details",
|
"logs.title": "Log Details",
|
||||||
"logs.filterUser": "Username",
|
"logs.filterUser": "Username",
|
||||||
"logs.filterModel": "Model",
|
"logs.filterModel": "Model",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
import { createContext, useContext, useState, useCallback, useSyncExternalStore, type ReactNode } from "react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { type TimeRange, getTimeRange } from "@/lib/utils";
|
import { type TimeRange, getTimeRange } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -25,45 +25,70 @@ const TimeRangeContext = createContext<TimeRangeContextType>({
|
|||||||
getEffectiveRange: () => ({}),
|
getEffectiveRange: () => ({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadSaved(): { range: TimeRange; customStart: string; customEnd: string } | null {
|
// Use useSyncExternalStore to safely read localStorage without hydration mismatch
|
||||||
if (typeof window === "undefined") return null;
|
const STORAGE_KEY = "time-range";
|
||||||
try {
|
const DEFAULT_RANGE = "30d";
|
||||||
const raw = localStorage.getItem("time-range");
|
|
||||||
if (!raw) return null;
|
let listeners: Array<() => void> = [];
|
||||||
const parsed = JSON.parse(raw);
|
function subscribe(cb: () => void) {
|
||||||
return {
|
listeners = [...listeners, cb];
|
||||||
range: parsed.range || "30d",
|
return () => { listeners = listeners.filter(l => l !== cb); };
|
||||||
customStart: parsed.customStart || "",
|
|
||||||
customEnd: parsed.customEnd || "",
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
function emitChange() {
|
||||||
|
for (const l of listeners) l();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): string {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) ?? "";
|
||||||
|
}
|
||||||
|
function getServerSnapshot(): string {
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function persist(range: TimeRange, customStart: string, customEnd: string) {
|
function persist(range: TimeRange, customStart: string, customEnd: string) {
|
||||||
localStorage.setItem("time-range", JSON.stringify({ range, customStart, customEnd }));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ range, customStart, customEnd }));
|
||||||
|
emitChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimeRangeProvider({ children }: { children: ReactNode }) {
|
export function TimeRangeProvider({ children }: { children: ReactNode }) {
|
||||||
const [range, setRangeState] = useState<TimeRange>(() => loadSaved()?.range ?? "30d");
|
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
const [customStart, setCustomStartState] = useState(() => loadSaved()?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD"));
|
|
||||||
const [customEnd, setCustomEndState] = useState(() => loadSaved()?.customEnd || dayjs().format("YYYY-MM-DD"));
|
const saved = (() => {
|
||||||
|
if (!raw) return null;
|
||||||
|
try { return JSON.parse(raw); } catch { return null; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const [range, setRangeState] = useState<TimeRange>(saved?.range ?? DEFAULT_RANGE);
|
||||||
|
const [customStart, setCustomStartState] = useState(
|
||||||
|
saved?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD")
|
||||||
|
);
|
||||||
|
const [customEnd, setCustomEndState] = useState(
|
||||||
|
saved?.customEnd || dayjs().format("YYYY-MM-DD")
|
||||||
|
);
|
||||||
|
|
||||||
const setRange = useCallback((r: TimeRange) => {
|
const setRange = useCallback((r: TimeRange) => {
|
||||||
setRangeState(r);
|
setRangeState(r);
|
||||||
setCustomStartState(prev => { persist(r, prev, customEnd); return prev; });
|
setCustomStartState(prev => {
|
||||||
}, [customEnd]);
|
setCustomEndState(end => { persist(r, prev, end); return end; });
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setCustomStart = useCallback((s: string) => {
|
const setCustomStart = useCallback((s: string) => {
|
||||||
setCustomStartState(s);
|
setCustomStartState(s);
|
||||||
persist(range, s, customEnd);
|
setRangeState(r => {
|
||||||
}, [range, customEnd]);
|
setCustomEndState(end => { persist(r, s, end); return end; });
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setCustomEnd = useCallback((e: string) => {
|
const setCustomEnd = useCallback((e: string) => {
|
||||||
setCustomEndState(e);
|
setCustomEndState(e);
|
||||||
persist(range, customStart, e);
|
setRangeState(r => {
|
||||||
}, [range, customStart]);
|
setCustomStartState(start => { persist(r, start, e); return start; });
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getEffectiveRange = useCallback(() => {
|
const getEffectiveRange = useCallback(() => {
|
||||||
if (range === "custom") {
|
if (range === "custom") {
|
||||||
|
|||||||
Reference in New Issue
Block a user