fix: resolve all eslint errors (set-state-in-effect, nested components, no-explicit-any)
- Wrap synchronous setState calls in useEffect with startTransition to avoid cascading renders - Convert nested SortIcon components to renderSortIcon helper functions - Replace all `any` types with proper interfaces (OverviewData, TrendPoint, RankItem) - Remove unused formatTokens import in logs page - Add no-any rule to CLAUDE.md
This commit is contained in:
@@ -19,6 +19,8 @@ Docker deployment: port 8019, `docker-compose up` with `.env.production`, extern
|
|||||||
|
|
||||||
Next.js 16 App Router analytics dashboard for an API gateway. React 19, TypeScript strict mode, Tailwind CSS 4, Recharts 3, PostgreSQL.
|
Next.js 16 App Router analytics dashboard for an API gateway. React 19, TypeScript strict mode, Tailwind CSS 4, Recharts 3, PostgreSQL.
|
||||||
|
|
||||||
|
**TypeScript: Never use `any` type.** Always define proper interfaces/types for all data structures. Use `unknown` with type narrowing if the type is truly unknown.
|
||||||
|
|
||||||
### Three Global Contexts (wrapped in `components/ClientProviders.tsx`)
|
### 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
|
1. **ThemeProvider** (`lib/theme.tsx`) — light/dark/system, localStorage `theme`, supports iframe embedding via `?theme=` query param and postMessage sync
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } 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, ArrowUpDown, ArrowDown, ArrowUp, HelpCircle } from "lucide-react";
|
||||||
@@ -22,7 +22,7 @@ function RatioTooltip({ text }: { text: string }) {
|
|||||||
const iconRef = useRef<HTMLSpanElement>(null);
|
const iconRef = useRef<HTMLSpanElement>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { setMounted(true); }, []);
|
useEffect(() => { startTransition(() => setMounted(true)); }, []);
|
||||||
|
|
||||||
function handleEnter() {
|
function handleEnter() {
|
||||||
if (iconRef.current) {
|
if (iconRef.current) {
|
||||||
@@ -84,11 +84,11 @@ export default function AggregationPage() {
|
|||||||
const [sortAsc, setSortAsc] = useState(false);
|
const [sortAsc, setSortAsc] = useState(false);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
startTransition(() => setLoading(true));
|
||||||
const { start, end } = getEffectiveRange();
|
const { start, end } = getEffectiveRange();
|
||||||
const res = await fetch(buildQuery("/api/aggregation", { start, end }));
|
const res = await fetch(buildQuery("/api/aggregation", { start, end }));
|
||||||
setData(await res.json());
|
const json = await res.json();
|
||||||
setLoading(false);
|
startTransition(() => { setData(json); setLoading(false); });
|
||||||
}, [getEffectiveRange]);
|
}, [getEffectiveRange]);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
@@ -112,12 +112,12 @@ export default function AggregationPage() {
|
|||||||
else { setSortKey(key); setSortAsc(false); }
|
else { setSortKey(key); setSortAsc(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortIcon({ col }: { col: SortKey }) {
|
const renderSortIcon = (col: SortKey) => {
|
||||||
if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.5 }} />;
|
if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.5 }} />;
|
||||||
return sortAsc
|
return sortAsc
|
||||||
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
|
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
|
||||||
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
|
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
|
||||||
}
|
};
|
||||||
|
|
||||||
const sortHeaders: { key: SortKey; label: string }[] = [
|
const sortHeaders: { key: SortKey; label: string }[] = [
|
||||||
{ key: "calls", label: t("th.calls") },
|
{ key: "calls", label: t("th.calls") },
|
||||||
@@ -165,14 +165,14 @@ export default function AggregationPage() {
|
|||||||
{sortHeaders.map(h => (
|
{sortHeaders.map(h => (
|
||||||
<th key={h.key} className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider cursor-pointer select-none transition-colors"
|
<th key={h.key} 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(h.key)}>
|
style={{ color: "var(--text-muted)" }} onClick={() => handleSort(h.key)}>
|
||||||
<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} {renderSortIcon(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"
|
<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")}>
|
style={{ color: "var(--text-muted)" }} onClick={() => handleSort("ratio")}>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("agg.ratio")}
|
{t("agg.ratio")}
|
||||||
<SortIcon col="ratio" />
|
{renderSortIcon("ratio")}
|
||||||
<RatioTooltip text={t("agg.ratioTip")} />
|
<RatioTooltip text={t("agg.ratioTip")} />
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } 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 } from "lucide-react";
|
import { ArrowLeft, Hash, Zap, MessageSquare } from "lucide-react";
|
||||||
@@ -30,11 +30,11 @@ export default function DetailPage() {
|
|||||||
|
|
||||||
const { getEffectiveRange } = useTimeRange();
|
const { getEffectiveRange } = useTimeRange();
|
||||||
const [data, setData] = useState<DetailData | null>(null);
|
const [data, setData] = useState<DetailData | null>(null);
|
||||||
const [trends, setTrends] = useState<any[]>([]);
|
const [trends, setTrends] = useState<{ date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number }[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
startTransition(() => setLoading(true));
|
||||||
const { start, end } = getEffectiveRange();
|
const { start, end } = getEffectiveRange();
|
||||||
const tp = { start, end };
|
const tp = { start, end };
|
||||||
const [detail, tr] = await Promise.all([
|
const [detail, tr] = await Promise.all([
|
||||||
@@ -45,7 +45,7 @@ export default function DetailPage() {
|
|||||||
...(type === "channel" ? { channel_id: decodedId } : {}),
|
...(type === "channel" ? { channel_id: decodedId } : {}),
|
||||||
})).then(r => r.json()),
|
})).then(r => r.json()),
|
||||||
]);
|
]);
|
||||||
setData(detail); setTrends(tr); setLoading(false);
|
startTransition(() => { setData(detail); setTrends(tr); setLoading(false); });
|
||||||
}, [type, decodedId, getEffectiveRange]);
|
}, [type, decodedId, getEffectiveRange]);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, startTransition } from "react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react";
|
import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react";
|
||||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||||
import { buildQuery, formatNumber, formatTokens, formatDate } from "@/lib/utils";
|
import { buildQuery, formatNumber, formatDate } 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";
|
||||||
|
|
||||||
@@ -26,15 +26,15 @@ export default function LogsPage() {
|
|||||||
const pageSize = 100;
|
const pageSize = 100;
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
startTransition(() => setLoading(true));
|
||||||
const { start, end } = getEffectiveRange();
|
const { start, end } = getEffectiveRange();
|
||||||
const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters }));
|
const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters }));
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setLogs(data.logs); setTotal(data.total); setLoading(false);
|
startTransition(() => { setLogs(data.logs); setTotal(data.total); setLoading(false); });
|
||||||
}, [page, filters, getEffectiveRange]);
|
}, [page, filters, getEffectiveRange]);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
useEffect(() => { setPage(1); }, [getEffectiveRange, filters]);
|
useEffect(() => { startTransition(() => setPage(1)); }, [getEffectiveRange, filters]);
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
const headers = [t("th.time"), t("th.user"), t("th.realModel"), t("th.channel"), t("th.input"), t("th.output"), t("th.totalToken"), t("th.latency"), ""];
|
const headers = [t("th.time"), t("th.user"), t("th.realModel"), t("th.channel"), t("th.input"), t("th.output"), t("th.totalToken"), t("th.latency"), ""];
|
||||||
|
|||||||
37
app/page.tsx
37
app/page.tsx
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } 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, TrendingUp, BarChart3 } from "lucide-react";
|
||||||
import { StatsCard } from "@/components/StatsCard";
|
import { StatsCard } from "@/components/StatsCard";
|
||||||
@@ -11,19 +11,40 @@ import { buildQuery } 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 OverviewData {
|
||||||
|
total_calls: number;
|
||||||
|
total_tokens: number;
|
||||||
|
active_users: number;
|
||||||
|
active_models: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendPoint {
|
||||||
|
date: string;
|
||||||
|
calls: number;
|
||||||
|
total_tokens: number;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RankItem {
|
||||||
|
name: string;
|
||||||
|
total_tokens: number;
|
||||||
|
calls: number;
|
||||||
|
}
|
||||||
|
|
||||||
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<"day" | "week" | "month">("day");
|
||||||
const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens");
|
const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens");
|
||||||
const [overview, setOverview] = useState<any>(null);
|
const [overview, setOverview] = useState<OverviewData | null>(null);
|
||||||
const [trends, setTrends] = useState<any[]>([]);
|
const [trends, setTrends] = useState<TrendPoint[]>([]);
|
||||||
const [userRank, setUserRank] = useState<any[]>([]);
|
const [userRank, setUserRank] = useState<RankItem[]>([]);
|
||||||
const [modelRank, setModelRank] = useState<any[]>([]);
|
const [modelRank, setModelRank] = useState<RankItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
startTransition(() => setLoading(true));
|
||||||
const { start, end } = getEffectiveRange();
|
const { start, end } = getEffectiveRange();
|
||||||
const tp = { start, end };
|
const tp = { start, end };
|
||||||
const [ov, tr, ur, mr] = await Promise.all([
|
const [ov, tr, ur, mr] = await Promise.all([
|
||||||
@@ -32,7 +53,9 @@ export default function DashboardPage() {
|
|||||||
fetch(buildQuery("/api/rankings", { ...tp, type: "user", limit: 10 })).then(r => r.json()),
|
fetch(buildQuery("/api/rankings", { ...tp, type: "user", limit: 10 })).then(r => r.json()),
|
||||||
fetch(buildQuery("/api/rankings", { ...tp, type: "model", limit: 10 })).then(r => r.json()),
|
fetch(buildQuery("/api/rankings", { ...tp, type: "model", limit: 10 })).then(r => r.json()),
|
||||||
]);
|
]);
|
||||||
|
startTransition(() => {
|
||||||
setOverview(ov); setTrends(tr); setUserRank(ur); setModelRank(mr); setLoading(false);
|
setOverview(ov); setTrends(tr); setUserRank(ur); setModelRank(mr); setLoading(false);
|
||||||
|
});
|
||||||
}, [granularity, getEffectiveRange]);
|
}, [granularity, getEffectiveRange]);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
@@ -82,7 +105,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")]] as const).map(([k, l]) => (
|
||||||
<button key={k} onClick={() => setTrendMetric(k as any)}
|
<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={{
|
||||||
background: trendMetric === k ? "var(--btn-active-bg)" : "transparent",
|
background: trendMetric === k ? "var(--btn-active-bg)" : "transparent",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, startTransition } from "react";
|
||||||
import Link from "next/link";
|
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";
|
||||||
@@ -33,11 +33,11 @@ export default function RankingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
startTransition(() => setLoading(true));
|
||||||
const { start, end } = getEffectiveRange();
|
const { start, end } = getEffectiveRange();
|
||||||
const res = await fetch(buildQuery("/api/rankings", { start, end, type: tab, limit: 100 }));
|
const res = await fetch(buildQuery("/api/rankings", { start, end, type: tab, limit: 100 }));
|
||||||
setData(await res.json());
|
const json = await res.json();
|
||||||
setLoading(false);
|
startTransition(() => { setData(json); setLoading(false); });
|
||||||
}, [tab, getEffectiveRange]);
|
}, [tab, getEffectiveRange]);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
@@ -58,12 +58,12 @@ export default function RankingsPage() {
|
|||||||
return sortAsc ? diff : -diff;
|
return sortAsc ? diff : -diff;
|
||||||
});
|
});
|
||||||
|
|
||||||
function SortIcon({ col }: { col: SortKey }) {
|
const renderSortIcon = (col: SortKey) => {
|
||||||
if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.4 }} />;
|
if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.4 }} />;
|
||||||
return sortAsc
|
return sortAsc
|
||||||
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
|
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
|
||||||
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
|
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
|
||||||
}
|
};
|
||||||
|
|
||||||
const columns: { key: SortKey | null; label: string; align: "left" | "right" }[] = [
|
const columns: { key: SortKey | null; label: string; align: "left" | "right" }[] = [
|
||||||
{ key: null, label: t("th.rank"), align: "left" },
|
{ key: null, label: t("th.rank"), align: "left" },
|
||||||
@@ -116,7 +116,7 @@ export default function RankingsPage() {
|
|||||||
>
|
>
|
||||||
{col.key ? (
|
{col.key ? (
|
||||||
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "justify-end" : ""}`}>
|
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "justify-end" : ""}`}>
|
||||||
{col.label} <SortIcon col={col.key} />
|
{col.label} {renderSortIcon(col.key)}
|
||||||
</span>
|
</span>
|
||||||
) : col.label}
|
) : col.label}
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
|
import { createContext, useContext, useState, useEffect, startTransition, type ReactNode } from "react";
|
||||||
|
|
||||||
export type Locale = "zh" | "en";
|
export type Locale = "zh" | "en";
|
||||||
|
|
||||||
@@ -195,7 +195,9 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem("locale") as Locale | null;
|
const saved = localStorage.getItem("locale") as Locale | null;
|
||||||
if (saved && (saved === "zh" || saved === "en")) setLocale(saved);
|
if (saved && (saved === "zh" || saved === "en")) {
|
||||||
|
startTransition(() => setLocale(saved));
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSetLocale = (l: Locale) => {
|
const handleSetLocale = (l: Locale) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user