Skip to content

Comments

Feat/data dashboard#183

Merged
ding113 merged 4 commits intodevfrom
feat/data-dashboard
Nov 22, 2025
Merged

Feat/data dashboard#183
ding113 merged 4 commits intodevfrom
feat/data-dashboard

Conversation

@ding113
Copy link
Owner

@ding113 ding113 commented Nov 22, 2025

No description provided.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @ding113, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request delivers a significant new feature: a real-time data dashboard. This dashboard provides administrators and users with global view permissions a comprehensive, live overview of system performance and usage. It integrates various data points such as key metrics, active sessions, user and provider rankings, model distribution, and traffic trends into an intuitive, full-screen interface, enhancing operational visibility and monitoring capabilities.

Highlights

  • Real-time Data Dashboard: Introduced a new full-screen dashboard at /internal/dashboard/big-screen to monitor system metrics, activity, and usage in real-time.
  • Comprehensive Data Aggregation: A new server action getDashboardRealtimeData was added to efficiently fetch all necessary data for the dashboard, including concurrent sessions, requests, cost, latency, error rate, live activity stream, user rankings, provider slot usage, model distribution, and 24-hour traffic trends.
  • Internationalization Support: New bigScreen.json translation files were added for English, Japanese, Russian, Simplified Chinese, and Traditional Chinese, along with updates to their respective index.ts files to integrate these translations.
  • Enhanced Metrics: The OverviewData and OverviewMetrics interfaces were extended to include todayErrorRate, and the getOverviewMetrics function was updated to calculate this new metric.
  • Provider Slot Monitoring: A new server action getProviderSlots was implemented to retrieve real-time concurrent session usage and limits for each provider.
  • Model Leaderboard: New interfaces and functions were added to leaderboard.ts to support daily and monthly model call rankings, including total requests, cost, tokens, and success rate.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@ding113 ding113 added the size/XL Extra Large PR (> 1000 lines) label Nov 22, 2025
@ding113
Copy link
Owner Author

ding113 commented Nov 22, 2025

📊 PR Size Analysis

This PR is XL with 1,422 lines changed across 17 files.

Large PRs are harder to review and more likely to introduce bugs.

🔀 Suggested Split:

Based on the changes, this PR could be split into:

  1. PR 1: Backend/Repository Layer

    • src/repository/leaderboard.ts
    • src/repository/overview.ts
    • src/actions/dashboard-realtime.ts
    • src/actions/overview.ts
    • src/actions/provider-slots.ts
  2. PR 2: Big Screen UI Component

    • src/app/[locale]/internal/dashboard/big-screen/layout.tsx
    • src/app/[locale]/internal/dashboard/big-screen/page.tsx
  3. PR 3: Internationalization

    • All messages/*/bigScreen.json files (5 files)
    • All messages/*/index.ts imports (5 files)

Why Split?

  • Easier to review - repository logic separate from UI
  • Faster CI feedback
  • Easier to revert if needed
  • Better git history - clearer commit purposes

Note

If splitting isn't practical due to tight coupling between these changes, consider at least:

  • Adding comprehensive PR description explaining the feature
  • Including screenshots of the big screen dashboard
  • Breaking the review into logical sections

🤖 Automated analysis by Claude AI

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Code Review Summary

This PR implements a real-time data dashboard ("big screen") feature with comprehensive monitoring capabilities. However, there is 1 critical runtime error that will cause the dashboard to fail completely, along with several high and medium priority issues.

🔍 Issues Found

  • Critical (🔴): 1 issue
  • High (🟠): 2 issues
  • Medium (🟡): 3 issues
  • Low (🟢): 0 issues

🎯 Priority Actions

  1. [CRITICAL] Add missing findDailyModelLeaderboard() function in src/repository/leaderboard.ts - the dashboard will crash without this
  2. [HIGH] Fix timezone inconsistency in getOverviewMetrics() - uses client timezone instead of configured TZ environment variable
  3. [HIGH] Add error handling for parallel data fetching in getDashboardRealtimeData() - current implementation will fail silently or crash if any data source fails
  4. [MEDIUM] Fix hardcoded slice limit (20) for activity stream - should be configurable or use constant
  5. [MEDIUM] Add proper null checks for provider rankings lookup in slot data merging
  6. [MEDIUM] Avoid parsing numeric data as strings then converting back to numbers in trend data processing

💡 General Observations

Correctness Issues:

  • Missing critical function implementation will cause immediate runtime failure
  • Timezone handling inconsistency between overview metrics and leaderboard queries
  • Insufficient error handling in parallel async operations

Performance Concerns:

  • Parallel queries are good, but no timeout or circuit breaker pattern
  • Multiple array transformations and lookups could be optimized

Code Quality:

  • Good use of TypeScript interfaces and type safety
  • Good separation of concerns between data fetching and UI
  • I18n support properly implemented across all locales
  • Well-structured component hierarchy in the UI layer

Testing Gap: No test files included for new server actions or repository methods


🤖 Automated review by Claude AI - focused on identifying issues for improvement

@ding113
Copy link
Owner Author

ding113 commented Nov 22, 2025

🔒 Security Scan Results

No security vulnerabilities detected

This PR has been scanned against OWASP Top 10, CWE Top 25, and common security anti-patterns. No security issues were identified in the code changes.

Scanned Categories

  • ✅ Injection attacks (SQL, NoSQL, Command, LDAP, etc.)
  • ✅ Authentication and session management
  • ✅ Sensitive data exposure
  • ✅ Access control and authorization
  • ✅ Security misconfiguration
  • ✅ Cross-site scripting (XSS)
  • ✅ Insecure deserialization
  • ✅ SSRF and path traversal
  • ✅ Cryptographic weaknesses

🛡️ Security Highlights

Strong Points:

  • ✅ All Server Actions enforce authentication via getSession()
  • ✅ Role-based access control (admin OR allowGlobalUsageView)
  • ✅ Parameterized SQL queries using Drizzle ORM
  • ✅ Proper timezone handling with SQL AT TIME ZONE
  • ✅ Soft-delete filtering (isNull(deletedAt))
  • ✅ Generic error messages (no information disclosure)
  • ✅ Structured logging with user context
  • ✅ TypeScript type safety throughout

📋 OWASP Top 10 Coverage

  • A01: Injection - Clean
  • A02: Broken Authentication - Clean
  • A03: Sensitive Data Exposure - Clean
  • A04: XML External Entities - N/A
  • A05: Broken Access Control - Clean
  • A06: Security Misconfiguration - Clean
  • A07: XSS - Clean
  • A08: Insecure Deserialization - Clean
  • A09: Known Vulnerabilities - Clean
  • A10: Logging & Monitoring - Clean

✅ Security Posture: STRONG

Recommendation: APPROVED from security perspective


🤖 Automated security scan by Claude AI - OWASP Top 10 & CWE coverage

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new real-time data dashboard feature, including backend actions to fetch data and a comprehensive frontend implementation. The changes are well-structured, with new i18n files for localization and efficient data fetching using parallel requests.

My review focuses on correctness and maintainability. I've identified a critical issue in the data processing for the trend chart, which would prevent it from rendering correctly. I've also found a UI bug in the user rankings component that could break the layout, and a couple of medium-severity issues related to code maintainability, such as a very large component file and an inconsistent comment language.

Overall, this is a great feature addition. Addressing the identified issues will improve the robustness and maintainability of the new dashboard.

Comment on lines 164 to 173
const trendData =
statisticsResult.ok && statisticsResult.data?.chartData
? statisticsResult.data.chartData.map((item) => ({
hour: typeof item.hour === "number" ? item.hour : parseInt(String(item.hour), 10),
value:
typeof item.requests === "number"
? item.requests
: parseInt(String(item.requests), 10),
}))
: Array.from({ length: 24 }, (_, i) => ({ hour: i, value: 0 }));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The logic for calculating trendData is incorrect. The item from statisticsResult.data.chartData is of type ChartDataItem, which does not have hour or requests properties. Instead, it has a date property (as an ISO string) and dynamic properties for costs and calls per user/key (e.g., 'user-1_calls'). This will result in trendData being an array of { hour: NaN, value: NaN }, breaking the trend chart on the dashboard.

The logic needs to be updated to correctly parse the hour from the date string and aggregate the total requests by summing up all *_calls properties for each data point.

Suggested change
const trendData =
statisticsResult.ok && statisticsResult.data?.chartData
? statisticsResult.data.chartData.map((item) => ({
hour: typeof item.hour === "number" ? item.hour : parseInt(String(item.hour), 10),
value:
typeof item.requests === "number"
? item.requests
: parseInt(String(item.requests), 10),
}))
: Array.from({ length: 24 }, (_, i) => ({ hour: i, value: 0 }));
const trendData =
statisticsResult.ok && statisticsResult.data?.chartData
? statisticsResult.data.chartData.map((item) => {
const hour = new Date(item.date).getUTCHours();
const value = Object.keys(item)
.filter((key) => key.endsWith("_calls"))
.reduce((sum, key) => sum + (Number(item[key]) || 0), 0);
return { hour, value };
})
: Array.from({ length: 24 }, (_, i) => ({ hour: i, value: 0 }));

name: provider.name,
usedSlots,
totalSlots: provider.limitConcurrentSessions ?? 0,
totalVolume: 0, // 会由前端从排行榜数据中填充,或由统一接口提供
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This comment is in Chinese, while the rest of the file, including other comments and code, is in English. For consistency and readability for all contributors, comments should be in English.

Additionally, the comment states that the frontend will populate totalVolume, but it's actually the getDashboardRealtimeData server action that orchestrates this. The comment could be clarified.

Suggested change
totalVolume: 0, // 会由前端从排行榜数据中填充,或由统一接口提供
totalVolume: 0, // This will be populated by the calling action from leaderboard data.

Comment on lines 1 to 829
"use client";

import React, { useState, useEffect, useRef } from "react";
import {
Activity,
Server,
Zap,
DollarSign,
Clock,
AlertTriangle,
Globe,
Moon,
Sun,
RefreshCw,
ArrowUp,
ArrowDown,
Wifi,
Layers,
Shield,
User,
PieChart as PieIcon,
} from "lucide-react";
import {
AreaChart,
Area,
XAxis,
YAxis,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Tooltip,
Legend,
} from "recharts";
import { motion, AnimatePresence } from "framer-motion";
import useSWR from "swr";
import { useTranslations, useLocale } from "next-intl";
import { getDashboardRealtimeData } from "@/actions/dashboard-realtime";
import type { DashboardRealtimeData } from "@/actions/dashboard-realtime";

/**
* ============================================================================
* 配置与常量
* ============================================================================
*/
const COLORS = {
models: ["#ff6b35", "#00d4ff", "#ffd60a", "#00ff88", "#a855f7"],
};

const THEMES = {
dark: {
bg: "bg-[#0a0a0f]",
text: "text-[#e6e6e6]",
card: "bg-[#1a1a2e]/60 backdrop-blur-md border border-white/5",
accent: "text-[#ff6b35]",
border: "border-white/5",
},
light: {
bg: "bg-[#fafafa]",
text: "text-[#1a1a1a]",
card: "bg-white/80 backdrop-blur-md border border-black/5 shadow-sm",
accent: "text-[#ff5722]",
border: "border-black/5",
},
};

/**
* ============================================================================
* 实用组件:粒子背景 (Canvas)
* ============================================================================
*/
const ParticleBackground = ({ themeMode }: { themeMode: string }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;

const ctx = canvas.getContext("2d");
if (!ctx) return;

let animationFrameId: number;
let particles: Array<{
x: number;
y: number;
vx: number;
vy: number;
size: number;
alpha: number;
}> = [];

const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener("resize", resize);
resize();

const createParticles = () => {
const count = window.innerWidth < 1000 ? 50 : 80;
particles = [];
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
size: Math.random() * 2 + 0.5,
alpha: Math.random() * 0.4 + 0.1,
});
}
};
createParticles();

const render = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const particleColor = themeMode === "dark" ? "255, 107, 53" : "2, 119, 189";

particles.forEach((p) => {
p.x += p.vx;
p.y += p.vy;

if (p.x < 0) p.x = canvas.width;
if (p.x > canvas.width) p.x = 0;
if (p.y < 0) p.y = canvas.height;
if (p.y > canvas.height) p.y = 0;

ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${particleColor}, ${p.alpha})`;
ctx.fill();
});

animationFrameId = requestAnimationFrame(render);
};
render();

return () => {
window.removeEventListener("resize", resize);
cancelAnimationFrame(animationFrameId);
};
}, [themeMode]);

return (
<canvas
ref={canvasRef}
className="absolute top-0 left-0 w-full h-full pointer-events-none z-0"
/>
);
};

/**
* ============================================================================
* 实用组件:数字滚动动画
* ============================================================================
*/
const CountUp = ({
value,
prefix = "",
suffix = "",
decimals = 0,
className = "",
}: {
value: number;
prefix?: string;
suffix?: string;
decimals?: number;
className?: string;
}) => {
const [displayValue, setDisplayValue] = useState(value);
useEffect(() => {
const start = displayValue;
const end = value;
if (start === end) return;
const duration = 1000;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - progress, 4);
const current = start + (end - start) * ease;
setDisplayValue(current);
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [value, displayValue]);
return (
<span className={`font-mono ${className}`}>
{prefix}
{displayValue.toFixed(decimals)}
{suffix}
</span>
);
};

/**
* ============================================================================
* 核心业务组件
* ============================================================================
*/

// 1. 顶部核心指标
const MetricCard = ({
title,
value,
subValue,
icon: Icon,
type = "neutral",
theme,
}: {
title: string;
value: React.ReactNode;
subValue?: string;
icon: React.ComponentType<{ size: number; className?: string }>;
type?: string;
theme: (typeof THEMES)[keyof typeof THEMES];
}) => {
const isPositive = type === "positive";
const isNegative = type === "negative";
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className={`relative overflow-hidden rounded-lg p-4 flex flex-col justify-between h-full ${theme.card} hover:border-orange-500/30 transition-colors group`}
>
<div className="absolute -top-6 -right-6 w-24 h-24 bg-orange-500/10 rounded-full blur-2xl" />
<div className="flex justify-between items-start z-10">
<span className={`text-xs uppercase tracking-wider font-semibold ${theme.text} opacity-50`}>
{title}
</span>
<Icon size={16} className={`${theme.accent} opacity-80`} />
</div>
<div className="flex items-end gap-3 mt-1 z-10">
<div className="relative">
{type === "pulse" && (
<span className="absolute -left-2.5 top-1/2 -translate-y-1/2 flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-orange-500"></span>
</span>
)}
<span className={`text-3xl font-bold font-mono tracking-tight ${theme.text}`}>
{value}
</span>
</div>
</div>
<div className="mt-1 flex items-center text-[10px] font-medium z-10">
{subValue && (
<span
className={`flex items-center gap-0.5 ${isPositive ? "text-[#00ff88]" : isNegative ? "text-[#ff006e]" : "text-gray-400"}`}
>
{isPositive ? <ArrowUp size={10} /> : isNegative ? <ArrowDown size={10} /> : null}
{subValue}
</span>
)}
</div>
</motion.div>
);
};

// 2. 实时活动流
const ActivityStream = ({
activities,
theme,
t,
}: {
activities: Array<{
id: string;
user: string;
model: string;
provider: string;
latency: number;
status: number;
}>;
theme: (typeof THEMES)[keyof typeof THEMES];
t: (key: string) => string;
}) => {
return (
<div className="h-full flex flex-col">
<div
className={`text-xs font-bold mb-2 flex items-center gap-2 ${theme.text} uppercase tracking-wider px-1`}
>
<Zap size={12} className="text-yellow-400" />
{t("sections.activity")}
</div>
<div className="flex-1 overflow-hidden relative rounded-lg border border-white/5 bg-black/20">
<div
className={`grid grid-cols-12 gap-2 text-[10px] font-mono opacity-50 py-2 px-3 border-b border-white/5 ${theme.text}`}
>
<div className="col-span-2">{t("headers.user")}</div>
<div className="col-span-3">{t("headers.model")}</div>
<div className="col-span-3">{t("headers.provider")}</div>
<div className="col-span-2 text-right">{t("headers.latency")}</div>
<div className="col-span-2 text-right">{t("headers.status")}</div>
</div>

<div className="space-y-0.5 relative h-full overflow-y-auto no-scrollbar p-1">
<AnimatePresence initial={false}>
{activities.map((item) => (
<motion.div
key={item.id}
initial={{ opacity: 0, x: 20, backgroundColor: "rgba(255, 107, 53, 0.2)" }}
animate={{ opacity: 1, x: 0, backgroundColor: "rgba(255,255,255,0.02)" }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className={`grid grid-cols-12 gap-2 p-2 rounded text-[10px] font-mono items-center hover:bg-white/10 transition-colors`}
>
<div className={`col-span-2 truncate font-bold text-orange-400`}>{item.user}</div>
<div className={`col-span-3 truncate text-gray-300`}>{item.model}</div>
<div className={`col-span-3 truncate text-gray-500`}>{item.provider}</div>
<div
className={`col-span-2 text-right ${item.latency > 1000 ? "text-red-400" : "text-green-400"}`}
>
{item.latency}ms
</div>
<div className="col-span-2 text-right flex justify-end">
<span
className={`px-1.5 rounded-sm ${
item.status === 200
? "bg-green-500/10 text-green-500"
: "bg-red-500/10 text-red-500"
}`}
>
{item.status}
</span>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
<style jsx>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
);
};

// 3. 供应商并发插槽
const ProviderQuotas = ({
providers,
theme,
t,
}: {
providers: Array<{
name: string;
usedSlots: number;
totalSlots: number;
}>;
theme: (typeof THEMES)[keyof typeof THEMES];
t: (key: string) => string;
}) => {
return (
<div className="h-full flex flex-col">
<div
className={`text-xs font-bold mb-3 flex items-center gap-2 ${theme.text} uppercase tracking-wider`}
>
<Server size={12} className="text-blue-400" />
{t("sections.providerQuotas")}
</div>
<div className="flex-1 flex flex-col justify-around gap-2">
{providers.map((p, idx) => {
const percent = p.totalSlots > 0 ? (p.usedSlots / p.totalSlots) * 100 : 0;
const isCritical = percent > 90;
const isWarning = percent > 70;

return (
<div key={idx} className="flex flex-col gap-1">
<div className="flex justify-between items-end text-[10px]">
<span className={`font-mono font-bold ${theme.text}`}>{p.name}</span>
<span className={`font-mono ${isCritical ? "text-red-400" : "text-gray-400"}`}>
{p.usedSlots}/{p.totalSlots} Slots
</span>
</div>
<div className="h-2.5 w-full bg-gray-700/30 rounded-sm overflow-hidden relative flex gap-[1px]">
<div
className={`h-full absolute left-0 top-0 transition-all duration-1000 ease-out ${
isCritical
? "bg-gradient-to-r from-red-500 to-red-400"
: isWarning
? "bg-gradient-to-r from-yellow-500 to-orange-500"
: "bg-gradient-to-r from-blue-600 to-cyan-400"
}`}
style={{ width: `${percent}%` }}
/>
<div className="absolute inset-0 w-full h-full flex">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="flex-1 border-r border-black/20 h-full" />
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

// 4. 用户排行
const UserRankings = ({
users,
theme,
t,
}: {
users: Array<{
userId: number;
userName: string;
totalCost: number;
totalRequests: number;
}>;
theme: (typeof THEMES)[keyof typeof THEMES];
t: (key: string) => string;
}) => {
return (
<div className="h-full flex flex-col relative">
<div
className={`text-xs font-bold mb-3 flex items-center gap-2 ${theme.text} uppercase tracking-wider`}
>
<User size={12} className="text-purple-400" />
{t("sections.userRank")}
<span className="ml-auto flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
</div>

<div className="flex-1 space-y-2 overflow-hidden">
{users.slice(0, 5).map((user, index) => (
<motion.div
key={user.userId}
layout
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className={`flex items-center gap-3 p-2 rounded border ${
index === 0
? "bg-gradient-to-r from-orange-500/20 to-transparent border-orange-500/30"
: theme.border + " bg-white/5"
}`}
>
<div
className={`
w-6 h-6 rounded flex items-center justify-center text-xs font-bold
${
index === 0
? "bg-[#ff6b35] text-white shadow-lg shadow-orange-500/50"
: index === 1
? "bg-gray-400 text-black"
: index === 2
? "bg-orange-800 text-white"
: "bg-gray-800 text-gray-400"
}
`}
>
{index + 1}
</div>

<div className="flex-1 min-w-0">
<div className="flex justify-between items-center">
<span className={`text-xs font-bold truncate ${theme.text}`}>{user.userName}</span>
<span className="text-[10px] text-gray-500 font-mono">
${user.totalCost.toFixed(2)}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<div className="w-16 h-1 bg-gray-700 rounded-full overflow-hidden">
<motion.div
className="h-full bg-purple-500"
initial={{ width: 0 }}
animate={{ width: `${(user.totalCost / 100) * 100}%` }}
/>
</div>
<span className="text-[9px] text-gray-500">{user.totalRequests} reqs</span>
</div>
</div>
</motion.div>
))}
</div>
</div>
);
};

// 5. 供应商排行
const ProviderRanking = ({
providers,
theme,
t,
}: {
providers: Array<{
providerId: number;
providerName: string;
totalTokens: number;
}>;
theme: (typeof THEMES)[keyof typeof THEMES];
t: (key: string) => string;
}) => {
return (
<div className="h-full flex flex-col">
<div
className={`text-xs font-bold mb-3 flex items-center gap-2 ${theme.text} uppercase tracking-wider`}
>
<Shield size={12} className="text-green-400" />
{t("sections.providerRank")}
</div>
<div className="flex-1 space-y-2">
{providers.slice(0, 5).map((p, i) => (
<div
key={p.providerId}
className="flex items-center justify-between p-2 rounded bg-white/5 border border-white/5"
>
<div className="flex items-center gap-2">
<span className="text-[10px] text-gray-500 font-mono w-3">0{i + 1}</span>
<span className={`text-xs font-semibold ${theme.text}`}>{p.providerName}</span>
</div>
<div className="text-right">
<div className={`text-xs font-mono ${theme.accent}`}>
{p.totalTokens.toLocaleString()}
</div>
<div className="text-[9px] text-gray-500">Tokens</div>
</div>
</div>
))}
</div>
</div>
);
};

// 6. 模型分布
const ModelDistribution = ({
data,
theme,
t,
}: {
data: Array<{
model: string;
totalRequests: number;
}>;
theme: (typeof THEMES)[keyof typeof THEMES];
t: (key: string) => string;
}) => {
const chartData = data.map((item) => ({
name: item.model,
value: item.totalRequests,
}));

return (
<div className="h-full flex flex-col">
<div
className={`text-xs font-bold mb-1 flex items-center gap-2 ${theme.text} uppercase tracking-wider`}
>
<PieIcon size={12} className="text-indigo-400" />
{t("sections.modelDist")}
</div>
<div className="flex-1 min-h-0 flex items-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
innerRadius={40}
outerRadius={60}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS.models[index % COLORS.models.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ backgroundColor: "#0a0a0f", borderColor: "#333", fontSize: "12px" }}
itemStyle={{ color: "#fff" }}
/>
<Legend
layout="vertical"
verticalAlign="middle"
align="right"
iconSize={8}
wrapperStyle={{ fontSize: "10px", color: "#999" }}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
);
};

/**
* ============================================================================
* 主应用
* ============================================================================
*/
export default function BigScreenPage() {
const t = useTranslations("bigScreen");
const locale = useLocale();
const [themeMode, setThemeMode] = useState("dark");
const [currentTime, setCurrentTime] = useState(new Date());

const theme = THEMES[themeMode as keyof typeof THEMES];

// 时钟
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);

// 使用 SWR 获取数据,2秒刷新
const { data, error, mutate } = useSWR(
"dashboard-realtime",
async () => {
const result = await getDashboardRealtimeData();
if (!result.ok) {
throw new Error(result.error || "Failed to fetch data");
}
return result.data;
},
{
refreshInterval: 2000, // 2秒刷新
revalidateOnFocus: false,
}
);

// 处理数据
const metrics = data?.metrics || {
concurrentSessions: 0,
todayRequests: 0,
todayCost: 0,
avgResponseTime: 0,
todayErrorRate: 0,
};

const activities = (data?.activityStream || []).map((item) => ({
id: item.id,
user: item.user,
model: item.model,
provider: item.provider,
latency: item.latency,
status: item.status,
}));

const users = data?.userRankings || [];
const providers = data?.providerSlots || [];
const providerRankings = data?.providerRankings || [];
const modelDist = data?.modelDistribution || [];
const trendData = data?.trendData || [];

return (
<div
className={`relative w-full h-screen overflow-hidden transition-colors duration-500 font-sans selection:bg-orange-500/30 ${theme.bg}`}
>
<ParticleBackground themeMode={themeMode} />

<div className="relative z-10 flex flex-col h-full p-4 gap-4 max-w-[1920px] mx-auto">
{/* Header */}
<header className="flex justify-between items-center pb-2 border-b border-white/5">
<div className="flex flex-col">
<h1 className={`text-2xl font-bold tracking-widest font-space ${theme.text}`}>
{t("title")}
</h1>
<p className={`text-[10px] tracking-[0.5em] uppercase opacity-50 ${theme.text} mt-1`}>
{t("subtitle")}
</p>
</div>

<div className="flex items-center gap-6">
<div className={`text-right hidden md:block ${theme.text}`}>
<div className="text-xl font-mono font-bold tabular-nums">
{currentTime.toLocaleTimeString()}
</div>
</div>
<div className="h-6 w-[1px] bg-white/10" />
<div className="flex gap-2">
<button className={`p-1.5 rounded hover:bg-white/5 ${theme.text}`}>
<Globe size={18} />
</button>
<button
onClick={() => setThemeMode(themeMode === "dark" ? "light" : "dark")}
className={`p-1.5 rounded hover:bg-white/5 ${theme.text}`}
>
{themeMode === "dark" ? <Moon size={18} /> : <Sun size={18} />}
</button>
<button
onClick={() => mutate()}
className={`p-1.5 rounded hover:bg-white/5 ${theme.text}`}
>
<RefreshCw size={18} />
</button>
</div>
</div>
</header>

{/* Top Metrics Row */}
<div className="grid grid-cols-6 gap-3 h-[120px]">
<MetricCard
title={t("metrics.concurrent")}
value={metrics.concurrentSessions}
subValue="Live"
type="pulse"
icon={Wifi}
theme={theme}
/>
<MetricCard
title={t("metrics.activeSessions")}
value={activities.length}
subValue="Stable"
type="neutral"
icon={Layers}
theme={theme}
/>
<MetricCard
title={t("metrics.requests")}
value={<CountUp value={metrics.todayRequests} />}
subValue="Today"
type="positive"
icon={Activity}
theme={theme}
/>
<MetricCard
title={t("metrics.cost")}
value={<CountUp value={metrics.todayCost} prefix="$" decimals={2} />}
subValue="Budget"
type="neutral"
icon={DollarSign}
theme={theme}
/>
<MetricCard
title={t("metrics.latency")}
value={`${metrics.avgResponseTime}ms`}
subValue="Avg"
type="positive"
icon={Clock}
theme={theme}
/>
<MetricCard
title={t("metrics.errorRate")}
value={`${metrics.todayErrorRate.toFixed(2)}%`}
subValue={metrics.todayErrorRate > 2 ? "High" : "Normal"}
type={metrics.todayErrorRate > 2 ? "negative" : "neutral"}
icon={AlertTriangle}
theme={theme}
/>
</div>

{/* Main Content Grid */}
<div className="flex-1 grid grid-cols-12 gap-4 min-h-0">
{/* LEFT COL */}
<div className="col-span-3 flex flex-col gap-4 h-full">
<div className={`flex-[3] ${theme.card} rounded-lg p-4 overflow-hidden`}>
<UserRankings users={users} theme={theme} t={t} />
</div>
<div className={`flex-[2] ${theme.card} rounded-lg p-4 overflow-hidden`}>
<ProviderRanking providers={providerRankings} theme={theme} t={t} />
</div>
</div>

{/* MIDDLE COL */}
<div className="col-span-5 flex flex-col gap-4 h-full">
<div className={`flex-[2] ${theme.card} rounded-lg p-4`}>
<ProviderQuotas providers={providers} theme={theme} t={t} />
</div>
<div className={`flex-[2] ${theme.card} rounded-lg p-4`}>
<ModelDistribution data={modelDist} theme={theme} t={t} />
</div>
<div
className={`flex-[1] ${theme.card} rounded-lg p-4 relative flex flex-col justify-end`}
>
<div
className={`absolute top-3 left-3 text-[10px] font-bold uppercase tracking-wider ${theme.text} opacity-50`}
>
{t("sections.requestTrend")}
</div>
<div className="h-16 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={trendData}>
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ff6b35" stopOpacity={0.2} />
<stop offset="100%" stopColor="#ff6b35" stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke="#ff6b35"
fill="url(#grad1)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>

{/* RIGHT COL */}
<div className="col-span-4 h-full">
<div className={`h-full ${theme.card} rounded-lg p-0 overflow-hidden flex flex-col`}>
<div className="p-3 pb-0">
<ActivityStream activities={activities} theme={theme} t={t} />
</div>
</div>
</div>
</div>

{/* Footer */}
<footer
className={`h-6 flex items-center justify-between text-[9px] uppercase tracking-wider opacity-40 ${theme.text}`}
>
<span>{t("status.normal")}</span>
<span>
{t("status.lastUpdate")}: {error ? "Error" : "2s ago"}
</span>
</footer>
</div>

<style jsx global>{`
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Space+Grotesk:wght@400;600;700&display=swap");
.font-mono {
font-family: "JetBrains Mono", monospace;
}
.font-space {
font-family: "Space Grotesk", sans-serif;
}
`}</style>
</div>
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This file is over 800 lines long and contains multiple distinct React components (ParticleBackground, CountUp, MetricCard, ActivityStream, ProviderQuotas, UserRankings, ProviderRanking, ModelDistribution) in addition to the main page component. This makes the file difficult to read, navigate, and maintain.

For better code organization and maintainability, it is highly recommended to extract each of these components into its own file within a components directory (e.g., src/app/[locale]/internal/dashboard/big-screen/components/).

This will make the main page component much cleaner and easier to understand, as it will focus on layout and data flow, while the individual components will encapsulate their own logic and presentation.

<motion.div
className="h-full bg-purple-500"
initial={{ width: 0 }}
animate={{ width: `${(user.totalCost / 100) * 100}%` }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The progress bar width in the UserRankings component is calculated as width: ${(user.totalCost / 100) * 100}%. This simplifies to `width: `${user.totalCost}%. Since user.totalCost is an absolute dollar value, this will cause the UI to break for any user with a cost over $100. The width should be normalized relative to the top user's cost to represent a proper progress bar.

Suggested change
animate={{ width: `${(user.totalCost / 100) * 100}%` }}
animate={{ width: `${(user.totalCost / (users[0]?.totalCost || 1)) * 100}%` }}

@ding113
Copy link
Owner Author

ding113 commented Nov 22, 2025

🔍 Detailed Code Review Findings

I've identified 6 significant issues that should be addressed before merging this PR.


🔴 CRITICAL ISSUE

src/actions/dashboard-realtime.ts, Line 122

Missing function causes runtime error

Why this is a problem: The code imports and calls findDailyModelLeaderboard() but this function doesn't exist in src/repository/leaderboard.ts. The PR diff shows it adds ModelLeaderboardEntry interface but the actual function implementation appears to be missing from the commit. This will cause a runtime crash when the dashboard loads.

Suggested fix: Add the complete function to src/repository/leaderboard.ts:

export async function findDailyModelLeaderboard(): Promise<ModelLeaderboardEntry[]> {
  const timezone = getEnvConfig().TZ;
  
  const rankings = await db
    .select({
      model: messageRequest.model,
      totalRequests: sql<number>\`count(*)::double precision\`,
      totalCost: sql<string>\`COALESCE(sum(\${messageRequest.costUsd}), 0)\`,
      totalTokens: sql<number>\`COALESCE(
        sum(
          \${messageRequest.inputTokens} +
          \${messageRequest.outputTokens} +
          COALESCE(\${messageRequest.cacheCreationInputTokens}, 0) +
          COALESCE(\${messageRequest.cacheReadInputTokens}, 0)
        )::double precision,
        0::double precision
      )\`,
      successRate: sql<number>\`COALESCE(
        count(CASE WHEN \${messageRequest.errorMessage} IS NULL OR \${messageRequest.errorMessage} = '' THEN 1 END)::double precision
        / NULLIF(count(*)::double precision, 0),
        0::double precision
      )\`,
    })
    .from(messageRequest)
    .where(
      and(
        isNull(messageRequest.deletedAt),
        sql\`(\${messageRequest.createdAt} AT TIME ZONE \${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE \${timezone})::date\`
      )
    )
    .groupBy(messageRequest.model)
    .orderBy(desc(sql\`count(*)\`));

  return rankings.map((entry) => ({
    model: entry.model ?? 'Unknown',
    totalRequests: entry.totalRequests,
    totalCost: parseFloat(entry.totalCost),
    totalTokens: entry.totalTokens,
    successRate: entry.successRate ?? 0,
  }));
}

🟠 HIGH PRIORITY ISSUES

1. src/repository/overview.ts, Lines 25-28

Timezone inconsistency causes incorrect "today" boundary

Why this is a problem: This function uses JavaScript's new Date() to calculate "today" boundaries, which uses the server's local timezone, not the configured TZ environment variable (Asia/Shanghai). This is inconsistent with findDailyLeaderboard() which correctly uses AT TIME ZONE SQL. Result:

  • Overview metrics will show wrong data if server timezone ≠ Asia/Shanghai
  • Different dashboard sections show data for different "today" periods

Suggested fix:

export async function getOverviewMetrics(): Promise<OverviewMetrics> {
  const timezone = getEnvConfig().TZ;

  const [result] = await db
    .select({
      requestCount: count(),
      totalCost: sum(messageRequest.costUsd),
      avgDuration: avg(messageRequest.durationMs),
      errorCount: sum(sql<number>\`CASE WHEN \${messageRequest.statusCode} >= 400 THEN 1 ELSE 0 END\`),
    })
    .from(messageRequest)
    .where(
      and(
        isNull(messageRequest.deletedAt),
        sql\`(\${messageRequest.createdAt} AT TIME ZONE \${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE \${timezone})::date\`
      )
    );

  // ... rest of the function remains the same
}

2. src/actions/dashboard-realtime.ts, Lines 118-126

Insufficient error handling for parallel data fetching

Why this is a problem: The code uses Promise.all() to fetch 7 different data sources in parallel. If any query fails (database connection issue, timeout, etc.), the entire function rejects and returns nothing. Current error handling only catches the overall error but doesn't provide partial results or identify which specific query failed.

Suggested fix: Use Promise.allSettled for resilient parallel fetching:

const [
  overviewResult,
  activeSessionsResult,
  userRankingsResult,
  providerRankingsResult,
  providerSlotsResult,
  modelRankingsResult,
  statisticsResult,
] = await Promise.allSettled([
  getOverviewData(),
  getActiveSessions(),
  findDailyLeaderboard(),
  findDailyProviderLeaderboard(),
  getProviderSlots(),
  findDailyModelLeaderboard(),
  getUserStatistics("today"),
]);

// Extract data with fallbacks
const overviewData = overviewResult.status === 'fulfilled' && overviewResult.value.ok
  ? overviewResult.value.data
  : null;

if (\!overviewData) {
  logger.error("Failed to get overview data", { 
    reason: overviewResult.status === 'rejected' ? overviewResult.reason : overviewResult.value.error 
  });
  return { ok: false, error: "获取概览数据失败" };
}

const userRankings = userRankingsResult.status === 'fulfilled' ? userRankingsResult.value : [];
const providerRankings = providerRankingsResult.status === 'fulfilled' ? providerRankingsResult.value : [];
// ... etc for other data sources with fallbacks

🟡 MEDIUM PRIORITY ISSUES

3. src/actions/dashboard-realtime.ts, Line 147

Magic number for activity stream limit should be a constant

Why this is a problem: The hardcoded .slice(0, 20) limit is unclear and not reusable. To change the activity stream size, you'd need to search for magic numbers.

Suggested fix:

// At the top of the file
const ACTIVITY_STREAM_LIMIT = 20;
const MODEL_DISTRIBUTION_LIMIT = 10;

// Line 147
const activityStream: ActivityStreamEntry[] = activeSessionsResult.ok
  ? activeSessionsResult.data.slice(0, ACTIVITY_STREAM_LIMIT).map((session) => ({
    // ...
  }))
  : [];

4. src/actions/dashboard-realtime.ts, Lines 158-164

Provider rankings lookup lacks explicit null handling

Why this is a problem: The code uses .find() to locate ranking data but doesn't explicitly handle the case where rankingData is undefined. While rankingData?.totalTokens ?? 0 works correctly, the fallback behavior isn't documented, making it harder to understand the code's intent.

Suggested fix: Add logging for better observability:

const providerSlots: ProviderSlotInfo[] = providerSlotsResult.ok
  ? providerSlotsResult.data.map((slot) => {
      const rankingData = providerRankings.find((p) => p.providerId === slot.providerId);
      
      if (\!rankingData) {
        logger.debug("Provider has slots but no traffic", { 
          providerId: slot.providerId, 
          providerName: slot.name 
        });
      }
      
      return {
        ...slot,
        totalVolume: rankingData?.totalTokens ?? 0,
      };
    })
  : [];

5. src/actions/dashboard-realtime.ts, Lines 167-175

Inefficient data transformation with redundant type conversions

Why this is a problem: The code uses typeof item.hour === 'number' ? item.hour : parseInt(String(item.hour), 10) pattern. This suggests either:

  1. Missing type definitions upstream
  2. Unnecessary defensive programming

The parseInt(String(...), 10) pattern is inefficient and obscures type safety issues.

Suggested fix: Simplify using Number():

const trendData =
  statisticsResult.ok && statisticsResult.data?.chartData
    ? statisticsResult.data.chartData.map((item) => ({
        hour: Number(item.hour) || 0,
        value: Number(item.requests) || 0,
      }))
    : Array.from({ length: 24 }, (_, i) => ({ hour: i, value: 0 }));

6. src/repository/overview.ts, Line 37

SQL aggregation for error count can be simplified

Recommendation: The current CASE-SUM pattern works but PostgreSQL offers a cleaner syntax:

errorCount: sql<number>\`count(*) FILTER (WHERE \${messageRequest.statusCode} >= 400)\`,

This is more readable and aligns with SQL:2003 standard.

CRITICAL fixes:
- findDailyModelLeaderboard() already exists in leaderboard.ts (verified)

HIGH priority fixes:
- Fix timezone inconsistency in getOverviewMetrics() - use SQL AT TIME ZONE
- Add Promise.allSettled for robust parallel data fetching with fallbacks
- Add comprehensive error logging for partial failures

MEDIUM priority fixes:
- Replace magic numbers with constants (ACTIVITY_STREAM_LIMIT, MODEL_DISTRIBUTION_LIMIT)
- Add logging for provider rankings lookup with null checks
- Correct trend data calculation from ChartDataItem structure
- Simplify error count SQL using FILTER syntax

Code quality improvements:
- Translate Chinese comment to English in provider-slots.ts
- Fix user ranking progress bar normalization (relative to top user)
- Remove unused imports (XAxis, YAxis, locale, DashboardRealtimeData)
- Fix TypeScript type errors in error handling paths

All type checks pass ✅
@ding113 ding113 added the enhancement New feature or request label Nov 22, 2025
Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Code Review Summary

This PR adds a real-time data dashboard ("big screen") feature with comprehensive monitoring capabilities. The implementation includes parallel data aggregation, real-time updates via SWR, and a polished UI with animations. However, there are several medium-to-critical issues that should be addressed before merge.

🔍 Issues Found

  • Critical (🔴): 1 issue
  • High (🟠): 4 issues
  • Medium (🟡): 5 issues
  • Low (🟢): 1 issue

🎯 Priority Actions

  1. Fix division by zero in UserRankings component (line 471) - Could cause UI corruption with NaN/Infinity width values
  2. Add Error Boundary protection - Critical for production monitoring dashboards to prevent white-screen crashes
  3. Improve error handling for SWR failures - Users need prominent warnings when data fetching fails
  4. Optimize N+1 Redis queries in provider-slots.ts - Use batch operations for better performance
  5. Fix external font loading - Use Next.js font optimization instead of blocking @import

💡 General Observations

Strengths:

  • Good use of Promise.allSettled for partial failure tolerance
  • Proper permission checks across all actions
  • Comprehensive logging and debug statements

Concerns:

  • Missing error boundaries for production resilience
  • Some performance optimization opportunities (N+1 queries, redundant slicing)
  • TypeScript type safety could be improved in dynamic property access
  • External resource loading not optimized for Next.js

🤖 Automated review by Claude AI - focused on identifying issues for improvement

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/app/[locale]/internal/dashboard/big-screen/page.tsx:471

🔴 Critical: Potential Division by Zero

Why this is a problem: This line calculates progress bar width using users[0]?.totalCost as the denominator. If the first user has totalCost: 0, this causes division by zero resulting in Infinity or NaN. The fallback to || 1 only works when users[0] is undefined/null, not when totalCost === 0.

Suggested fix:

// Current (buggy):
animate={{ width: \`\${(user.totalCost / (users[0]?.totalCost || 1)) * 100}%\` }}

// Fixed:
animate={{ width: \`\${users[0]?.totalCost ? (user.totalCost / users[0].totalCost) * 100 : 0}%\` }}

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/app/[locale]/internal/dashboard/big-screen/page.tsx:1-825

🟠 High: No Error Boundary Protection

Why this is a problem: The big-screen dashboard is a complex component with Canvas rendering, animations, and real-time data fetching. If any component throws an error (e.g., Canvas API failure, malformed data), the entire page crashes with a white screen. For a production monitoring dashboard, this is unacceptable - users need clear error messages, not browser confusion.

Suggested fix: Wrap the component with an Error Boundary:

// Create: src/app/[locale]/internal/dashboard/big-screen/error-boundary.tsx
'use client';
import React from 'react';

export class DashboardErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean; error?: Error }
> {
  constructor(props: { children: React.ReactNode }) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="flex items-center justify-center h-screen bg-[#0a0a0f] text-white">
          <div className="text-center">
            <h1 className="text-2xl font-bold mb-4">Dashboard Error</h1>
            <p className="text-gray-400">{this.state.error?.message}</p>
            <button onClick={() => window.location.reload()} className="mt-4 px-4 py-2 bg-orange-500 rounded">
              Reload
            </button>
          </div>
        </div>
      );
    }
    return this.props.children;
  }
}

// In layout.tsx: wrap {children} with <DashboardErrorBoundary>

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/app/[locale]/internal/dashboard/big-screen/page.tsx:607-620

🟠 High: Silent Data Fetching Failures

Why this is a problem: When getDashboardRealtimeData() throws an error, SWR sets the error state, but the only indication is tiny footer text (error ? "Error" : "2s ago"). Users won't notice data is stale or failing. The dashboard continues showing old cached data without any prominent warning.

Suggested fix:

// Add prominent error banner
{error && (
  <div className="fixed top-4 right-4 bg-red-500/90 text-white px-4 py-2 rounded-lg shadow-lg z-50 flex items-center gap-2">
    <AlertTriangle size={16} />
    <span className="text-sm font-semibold">Data fetch failed. Showing cached data.</span>
  </div>
)}

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/app/[locale]/internal/dashboard/big-screen/page.tsx:815

🟡 Medium: Blocking External Font Resource

Why this is a problem: Loading Google Fonts via @import in inline styles blocks rendering and is an anti-pattern in Next.js. This causes Flash of Unstyled Text (FOUT) and slower initial page load. For a real-time dashboard needing instant rendering, this is a performance issue.

Suggested fix: Use Next.js font optimization:

// Create fonts.ts:
import { JetBrains_Mono, Space_Grotesk } from 'next/font/google';

export const jetbrainsMono = JetBrains_Mono({
  subsets: ['latin'],
  weight: ['400', '700'],
  variable: '--font-jetbrains',
  display: 'swap',
});

export const spaceGrotesk = Space_Grotesk({
  subsets: ['latin'],
  weight: ['400', '600', '700'],
  variable: '--font-space',
  display: 'swap',
});

// In page.tsx:
import { jetbrainsMono, spaceGrotesk } from './fonts';
// Add to className: \${jetbrainsMono.variable} \${spaceGrotesk.variable}
// Update CSS: .font-mono -> font-[var(--font-jetbrains)]
// Remove the <style jsx global> block entirely

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/app/[locale]/internal/dashboard/big-screen/page.tsx:736-737

🟡 Medium: Hardcoded Error Rate Threshold

Why this is a problem: The error rate threshold 2 is hardcoded in two places. This is a business logic constant that should be extracted to make it easier to modify and maintain. If the acceptable error rate changes, developers need to update multiple locations, risking inconsistency.

Suggested fix:

// At top of file with other constants (after line 45):
const ERROR_RATE_THRESHOLD = 2; // percentage

// Replace lines 736-737:
subValue={metrics.todayErrorRate > ERROR_RATE_THRESHOLD ? "High" : "Normal"}
type={metrics.todayErrorRate > ERROR_RATE_THRESHOLD ? "negative" : "neutral"}

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/actions/provider-slots.ts:71-85

🟡 Medium: N+1 Query Pattern

Why this is a problem: Lines 71-85 execute SessionTracker.getProviderSessionCount() in a loop via Promise.all. If there are 50 providers, this makes 50 Redis calls. While parallel execution helps, this is a classic N+1 query pattern that could be optimized with a single batch operation.

Suggested fix: Add batch method to SessionTracker:

// In session-tracker.ts, add batch method:
static async getMultipleProviderSessionCounts(providerIds: number[]): Promise<Map<number, number>> {
  const redis = getRedisClient();
  if (!redis) return new Map();

  const pipeline = redis.pipeline();
  providerIds.forEach(id => {
    pipeline.zcard(\`provider:\${id}:active_sessions\`);
  });

  const results = await pipeline.exec();
  const counts = new Map<number, number>();
  providerIds.forEach((id, index) => {
    counts.set(id, Number(results?.[index]?.[1] || 0));
  });
  return counts;
}

// In provider-slots.ts:
const providerIds = providerList.map(p => p.id);
const sessionCounts = await SessionTracker.getMultipleProviderSessionCounts(providerIds);

const slotInfoList = providerList.map(provider => ({
  providerId: provider.id,
  name: provider.name,
  usedSlots: sessionCounts.get(provider.id) ?? 0,
  totalSlots: provider.limitConcurrentSessions ?? 0,
  totalVolume: 0,
}));

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/actions/dashboard-realtime.ts:242-244

🟡 Medium: Unsafe Dynamic Property Access

Why this is a problem: Line 244 uses item[key] where item is from statisticsData.chartData, but the code assumes all keys ending in _calls are numbers. If the data structure changes or contains unexpected keys, this could silently return undefined or cause runtime errors. TypeScript doesn't catch this due to dynamic property access.

Suggested fix:

// Add type guard and null check:
const value = Object.keys(item)
  .filter((key) => key.endsWith("_calls"))
  .reduce((sum, key) => {
    const val = item[key as keyof typeof item];
    return sum + (typeof val === 'number' ? val : 0);
  }, 0);

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/actions/dashboard-realtime.ts:207-208, 262-265

🟡 Medium: Inefficient Data Slicing

Why this is a problem: The code slices activeSessions to ACTIVITY_STREAM_LIMIT (20 items) on line 208, then maps over all sliced items. If getActiveSessions() returns 1000+ items, you're loading and processing all items only to keep 20. This wastes memory and CPU cycles.

Suggested fix: Add limit parameter to getActiveSessions:

// In active-sessions.ts, modify signature:
export async function getActiveSessions(limit?: number): Promise<ActionResult<ActiveSessionInfo[]>> {
  // ... existing code ...
  const sessions = await getSessionsFromRedis();
  return {
    ok: true,
    data: limit ? sessions.slice(0, limit) : sessions,
  };
}

// In dashboard-realtime.ts line 125:
getActiveSessions(ACTIVITY_STREAM_LIMIT),

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/actions/dashboard-realtime.ts:212-217

🟢 Low: Inconsistent Null Handling Style

Why this is a problem: The code uses different patterns for null handling: || "Unknown" (lines 212-213) vs || 0 (line 214) vs || 200 (line 215) vs parseFloat(...|| "0") (line 216). While functionally correct, this inconsistency makes code harder to maintain and review.

Suggested fix: Use nullish coalescing operator ?? consistently:

model: session.model ?? "Unknown",
provider: session.providerName ?? "Unknown",
latency: session.durationMs ?? 0,
status: session.statusCode ?? 200,
cost: parseFloat(session.costUsd ?? "0"),

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File: src/actions/dashboard-realtime.ts:174, 186

🟠 High: Inconsistent Empty Data Handling

Why this is a problem: Line 174 logs a warning when activeSessions is empty (\!activeSessions.length), but line 186 does NOT log a warning when providerSlots is empty, only when the action failed. This inconsistency means one data source triggers warnings for legitimate empty states while another doesn't. Empty data is not necessarily an error - it could be valid (e.g., no active sessions at 3 AM).

Suggested fix: Be consistent - either warn for all empty states or none:

// Option 1: Remove empty data warnings (recommended - empty is often valid):
if (activeSessionsResult.status === "rejected") {
  logger.warn("Failed to get active sessions", { reason: activeSessionsResult.reason });
}

// Option 2: Add empty warnings for all data sources consistently:
if (activeSessionsResult.status === "rejected" || \!activeSessions.length) {
  logger.debug("Active sessions empty or failed", { ... }); // Use debug, not warn
}
if (providerSlotsResult.status === "rejected" || \!providerSlots.length) {
  logger.debug("Provider slots empty or failed", { ... });
}

✨ 新增功能
- 实现 Live Stream 混合数据源 (Redis + Database)
- 优化 Traffic Trend 图表展示和实时截断
- 添加语言循环切换功能

🐛 Bug 修复
- 修复 Model Distribution 数据为空问题 (COALESCE originalModel)
- 修复进行中请求显示 0ms 耗时问题
- 修复语言切换器无响应问题

🎨 UI/UX 优化
- Traffic Trend 提升至中间位置,突出重要性
- 添加坐标轴标签 (XAxis: 小时, YAxis: 请求数)
- 实时截断未来时间数据点 (避免误导性 0 值)
- Provider Slot 按占用率排序,最多显示 3 个
- 供应商排行改为按金额降序排序
- 合并 CONCURRENCY 和 ACTIVE SESSIONS 指标
- 过滤未设置并发限额的供应商

📦 数据层改进
- 新增 activity-stream.ts repository
- 使用窗口函数优化 session 查询
- 修复 Model Distribution 查询 NULL 处理
- 实时耗时计算 (durationMs ?? now - startTime)

🔧 技术优化
- Live Stream 最多 20 条 (活跃 + 历史混合)
- Traffic Trend 2 秒自动刷新
- 语言切换支持 5 种语言循环
- 所有修改通过 TypeScript 类型检查
@ding113 ding113 merged commit 8c26cac into dev Nov 22, 2025
7 checks passed
ding113 pushed a commit that referenced this pull request Nov 22, 2025
@ding113
Copy link
Owner Author

ding113 commented Nov 22, 2025

🔒 Security Scan Results

No security vulnerabilities detected

This PR has been scanned against OWASP Top 10, CWE Top 25, and common security anti-patterns. No security issues were identified in the code changes.

Scanned Categories

  • ✅ Injection attacks (SQL, NoSQL, Command, LDAP, etc.)
  • ✅ Authentication and session management
  • ✅ Sensitive data exposure
  • ✅ Access control and authorization
  • ✅ Security misconfiguration
  • ✅ Cross-site scripting (XSS)
  • ✅ Insecure deserialization
  • ✅ SSRF and path traversal
  • ✅ Cryptographic weaknesses

Key Security Features Verified

SQL Injection Prevention

  • All database queries use parameterized queries via Drizzle ORM
  • SQL fragments use proper bindings (e.g., sql\${timezone}``)
  • No raw string concatenation in SQL queries
  • Timezone parameter safely passed through ORM layer

Authorization Controls

  • Proper authentication checks using getSession()
  • Role-based access control (admin + allowGlobalUsageView permission)
  • Consistent authorization pattern across all new endpoints:
    • getDashboardRealtimeData()
    • getProviderSlots()

Data Sanitization

  • User inputs properly validated and sanitized
  • Type coercion with null checks (e.g., parseFloat(), Number())
  • Fallback values for null/undefined cases

Client-Side Security

  • No use of dangerouslySetInnerHTML
  • React auto-escaping for all user-generated content
  • Third-party chart library (Recharts) used correctly without XSS risks

Error Handling

  • Graceful error handling with try-catch blocks
  • No sensitive data in error messages (only generic "获取数据失败")
  • Detailed errors logged server-side only (not exposed to client)

Data Integrity

  • Window functions (ROW_NUMBER) prevent duplicate data issues
  • Proper deduplication logic in findRecentActivityStream
  • Transaction-safe aggregation queries

Code Quality Observations (Non-Security)

The following are not security vulnerabilities but good coding practices observed:

  1. Defensive Programming: Use of Promise.allSettled for fault tolerance
  2. Type Safety: Proper TypeScript interfaces and null checks
  3. Performance: Efficient SQL with window functions and proper indexing
  4. Logging: Structured logging for debugging (no PII exposure)

🤖 Automated security scan by Claude AI - OWASP Top 10 & CWE coverage
📅 Scan completed: 2025-11-22 11:20:56 UTC
Recommendation: Safe to merge from security perspective

.orderBy(providers.priority, providers.id);

// 并行获取每个供应商的并发数
const slotInfoList = await Promise.all(
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High: Potential N+1 query in provider slots retrieval

Why this is a problem: Line 71-85 uses Promise.all to query Redis session counts for each provider individually. This creates N Redis queries (one per provider) instead of a single batch operation, causing performance degradation when there are many providers.

Suggested fix:

// Add a batch method to SessionTracker
// In SessionTracker class:
static async getMultipleProviderSessionCounts(providerIds: number[]): Promise<Map<number, number>> {
  const pipeline = redis.pipeline();
  providerIds.forEach(id => pipeline.scard(`provider:${id}:sessions`));
  const results = await pipeline.exec();
  const countMap = new Map<number, number>();
  providerIds.forEach((id, i) => {
    countMap.set(id, (results?.[i]?.[1] as number) ?? 0);
  });
  return countMap;
}

// Then use it:
const providerIds = providerList.map(p => p.id);
const sessionCounts = await SessionTracker.getMultipleProviderSessionCounts(providerIds);
const slotInfoList = providerList.map(provider => ({
  providerId: provider.id,
  name: provider.name,
  usedSlots: sessionCounts.get(provider.id) ?? 0,
  totalSlots: provider.limitConcurrentSessions ?? 0,
  totalVolume: 0,
}));

*
* @param limit 最大返回条数(默认 20)
*/
export async function findRecentActivityStream(limit = 20): Promise<ActivityStreamItem[]> {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High: Missing input validation for limit parameter

Why this is a problem: The limit parameter has no validation. A malicious or buggy caller could pass negative numbers, zero, or extremely large values, causing database performance issues or unexpected behavior.

Suggested fix:

export async function findRecentActivityStream(limit = 20): Promise<ActivityStreamItem[]> {
  // Validate and sanitize limit
  const sanitizedLimit = Math.max(1, Math.min(limit, 100)); // Clamp between 1 and 100
  
  try {
    // ... rest of code using sanitizedLimit instead of limit
    const activeSessionRequests = await db
      .select({
        // ...
      })
      // ...
      .limit(sanitizedLimit * 2);
    // ...
  }
}

}

// 4. 按创建时间降序排序并去重
const uniqueItems = new Map<number, ActivityStreamItem>();
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium: Inefficient deduplication using Map

Why this is a problem: Lines 208-213 use a Map for deduplication, but the code already fetches distinct data (active sessions + non-overlapping recent requests). The deduplication adds unnecessary overhead and could mask logic bugs where duplicates shouldn't exist.

Suggested fix:

// Remove unnecessary deduplication since queries already ensure uniqueness
// Active sessions query: one record per session (filtered by rowNum = 1)
// Recent requests query: explicitly excludes active session IDs
// These two sets are mutually exclusive, so no duplicates possible

const sortedItems = activityItems
  .sort((a, b) => b.startTime - a.startTime)
  .slice(0, limit);

// Optional: Add assertion to catch logic bugs in development
if (process.env.NODE_ENV === 'development') {
  const ids = new Set(activityItems.map(item => item.id));
  if (ids.size !== activityItems.length) {
    logger.warn('[ActivityStream] Unexpected duplicates detected', {
      totalItems: activityItems.length,
      uniqueIds: ids.size,
    });
  }
}

* ============================================================================
* 实用组件:粒子背景 (Canvas)
* ============================================================================
*/
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium: Potential memory leak in client-side component

Why this is a problem: The ParticleBackground component creates animation frames but the cleanup in useEffect may not cancel all pending frames if multiple resize events occur rapidly. This could cause memory leaks and performance degradation over time.

Suggested fix:

useEffect(() => {
  const canvas = canvasRef.current;
  if (\!canvas) return;

  const ctx = canvas.getContext('2d');
  if (\!ctx) return;

  let animationFrameId: number | null = null;
  let isCleanedUp = false;

  const resize = () => {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
  };
  
  const render = () => {
    if (isCleanedUp) return; // Guard against cleanup race conditions
    
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // ... particle rendering logic
    
    animationFrameId = requestAnimationFrame(render);
  };
  
  window.addEventListener('resize', resize);
  resize();
  createParticles();
  render();

  return () => {
    isCleanedUp = true;
    window.removeEventListener('resize', resize);
    if (animationFrameId \!== null) {
      cancelAnimationFrame(animationFrameId);
    }
  };
}, [themeMode]);

});

// 处理供应商插槽数据(合并流量数据 + 过滤未设置限额 + 按占用率排序 + 限制最多3个)
const providerSlotsWithVolume: ProviderSlotInfo[] = providerSlots
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High: Database query inside map iterator

Why this is a problem: Line 229 performs a .find() operation for each provider slot in the map. This is an O(n*m) operation where n is the number of provider slots and m is the number of provider rankings. For large datasets, this becomes a performance bottleneck.

Suggested fix:

// Pre-build a lookup map for O(1) access
const providerRankingMap = new Map(
  providerRankings.map(p => [p.providerId, p])
);

const providerSlotsWithVolume: ProviderSlotInfo[] = providerSlots
  .filter((slot) => slot.totalSlots > 0)
  .map((slot) => {
    const rankingData = providerRankingMap.get(slot.providerId);

    if (!rankingData) {
      logger.debug("Provider has slots but no traffic", {
        providerId: slot.providerId,
        providerName: slot.name,
      });
    }

    return {
      ...slot,
      totalVolume: rankingData?.totalTokens ?? 0,
    };
  })
  // ... rest of the chain

? statisticsResult.value.data
: null;

// 记录部分失败的数据源
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium: Inconsistent error handling for PromiseSettledResult

Why this is a problem: The code logs warnings for partial failures but doesn't handle the root causes. For example, activityStreamResult could fail silently and return an empty array, masking Redis/database connection issues that should be escalated.

Suggested fix:

// Add severity levels and escalation for critical failures
const failures = {
  critical: [] as string[],
  warning: [] as string[],
};

if (activityStreamResult.status === "rejected") {
  failures.critical.push(`activity_stream: ${activityStreamResult.reason}`);
  logger.error("Critical: Failed to get activity stream", { 
    reason: activityStreamResult.reason,
    stack: activityStreamResult.reason?.stack,
  });
}

// ... similar for other critical data sources

// If we have critical failures, include them in the response
if (failures.critical.length > 0) {
  logger.error("Dashboard has critical data source failures", {
    userId: session.user.id,
    failures: failures.critical,
  });
  // Optional: Return partial failure status
  // return { ok: false, error: 'Partial data unavailable', details: failures };
}

and(isNull(messageRequest.deletedAt), inArray(messageRequest.sessionId, activeSessionIds))
)
.orderBy(desc(messageRequest.createdAt))
.limit(limit * 2); // 获取足够的数据,后面会过滤
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium: Hardcoded magic numbers without constants

Why this is a problem: Lines 103 and 176 use hardcoded numbers (limit * 2 and remaining) without explanation. The * 2 multiplier is particularly problematic - why 2? This makes the code harder to maintain and could lead to over-fetching.

Suggested fix:

// Add explanatory constants at the top of the file
const WINDOW_FUNCTION_OVERFETCH_MULTIPLIER = 2; // Fetch 2x limit to account for filtering by rowNum
const MAX_ACTIVITY_STREAM_OVERFETCH = 100; // Cap to prevent excessive DB load

export async function findRecentActivityStream(limit = 20): Promise<ActivityStreamItem[]> {
  const sanitizedLimit = Math.max(1, Math.min(limit, 100));
  
  try {
    // ...
    
    if (activeSessionIds.length > 0) {
      const fetchLimit = Math.min(
        sanitizedLimit * WINDOW_FUNCTION_OVERFETCH_MULTIPLIER,
        MAX_ACTIVITY_STREAM_OVERFETCH
      );
      
      const activeSessionRequests = await db
        .select({ /* ... */ })
        .limit(fetchLimit); // More explicit than `limit * 2`
        
      // ...
    }
  }
}

Copy link
Owner Author

@ding113 ding113 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Code Review Summary

This PR adds a comprehensive realtime data dashboard feature with multi-source data aggregation, internationalization support, and visual monitoring capabilities. The implementation is largely well-structured with good use of Promise.allSettled for fault tolerance, but contains several performance and reliability issues that should be addressed.

🔍 Issues Found

  • Critical (🔴): 0 issues
  • High (🟠): 3 issues
  • Medium (🟡): 4 issues
  • Low (🟢): 0 issues

🎯 Priority Actions

  1. Fix N+1 Redis queries in provider slots - Implement batch Redis operations using pipelining to avoid sequential queries for each provider (src/actions/provider-slots.ts:71)

  2. Add input validation for limit parameter - Prevent negative/excessive values that could cause database performance degradation (src/repository/activity-stream.ts:62)

  3. Optimize nested loops with Map lookups - Replace O(n*m) .find() operations with O(1) Map.get() for provider ranking lookups (src/actions/dashboard-realtime.ts:226)

  4. Improve error handling escalation - Add severity levels to distinguish critical failures from warnings, preventing silent data loss (src/actions/dashboard-realtime.ts:172)

  5. Fix potential memory leak in canvas animation - Add proper cleanup guards and animation frame cancellation (src/app/[locale]/internal/dashboard/big-screen/page.tsx:73)

💡 General Observations

Strengths:

  • Good use of Promise.allSettled for graceful degradation
  • Comprehensive internationalization coverage (5 locales)
  • Well-documented TypeScript interfaces
  • Proper permission checks before data access

Patterns to improve:

  • Several instances of hardcoded magic numbers without explanatory constants
  • Inconsistent error handling between critical and non-critical failures
  • Performance optimizations needed for database/Redis query patterns
  • Some unnecessary deduplication logic that could mask bugs

Testing recommendations:

  • Load test with 50+ providers to verify Redis pipeline optimization
  • Test dashboard behavior when Redis/DB connections fail
  • Verify memory stability of canvas animations over extended periods
  • Test with malicious limit values (negative, zero, MAX_SAFE_INTEGER)

🤖 Automated review by Claude AI - focused on identifying issues for improvement

@ding113 ding113 mentioned this pull request Nov 22, 2025
9 tasks
@ding113 ding113 deleted the feat/data-dashboard branch November 25, 2025 17:43
@ding113 ding113 mentioned this pull request Nov 28, 2025
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size/XL Extra Large PR (> 1000 lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant