diff --git a/packages/server/src/lib/trends.ts b/packages/server/src/lib/trends.ts
index ec905bb..f7858e8 100644
--- a/packages/server/src/lib/trends.ts
+++ b/packages/server/src/lib/trends.ts
@@ -8,10 +8,15 @@ export type TrendRow = {
tokens: bigint;
};
+/**
+ * UTC midnight `days - 1` days ago, so a window of `days` buckets ends on
+ * (and includes) today. UTC is used because `calledAt` is stored as naive
+ * UTC and the SQL groups by its date part.
+ */
export function daysWindowStart(days: number): Date {
- const since = new Date();
- since.setDate(since.getDate() - days);
- since.setHours(0, 0, 0, 0);
+ const now = new Date();
+ const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
+ since.setUTCDate(since.getUTCDate() - (days - 1));
return since;
}
@@ -43,7 +48,7 @@ export async function getDailyTrends(opts: { since: Date; days: number; projectI
const filled = [];
for (let i = 0; i < days; i++) {
const d = new Date(since);
- d.setDate(d.getDate() + i);
+ d.setUTCDate(d.getUTCDate() + i);
const key = d.toISOString().slice(0, 10);
const row = map.get(key);
const total = row ? Number(row.total) : 0;
diff --git a/packages/web/nginx.conf b/packages/web/nginx.conf
index 53f5f28..1758628 100644
--- a/packages/web/nginx.conf
+++ b/packages/web/nginx.conf
@@ -28,7 +28,16 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
+ # Hashed build artifacts are safe to cache forever
+ location /assets/ {
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ try_files $uri =404;
+ }
+
location / {
+ # index.html must always revalidate, or browsers keep a stale
+ # bundle reference after each deploy (heuristic caching)
+ add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}
diff --git a/packages/web/src/components/BarChart.tsx b/packages/web/src/components/BarChart.tsx
deleted file mode 100644
index 246172b..0000000
--- a/packages/web/src/components/BarChart.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import type { ReactNode } from 'react';
-
-export type BarChartPoint = {
- key: string;
- label: string;
- value: number;
- /** Optional secondary value rendered as an overlay (e.g., errors stacked on calls). */
- secondary?: number;
- tooltip: ReactNode;
-};
-
-export default function BarChart({ points, height = 180, emptyLabel }: { points: BarChartPoint[]; height?: number; emptyLabel: string }) {
- if (points.length === 0) {
- return
{emptyLabel}
;
- }
-
- const max = Math.max(...points.map((p) => p.value), 1);
-
- return (
-
- {points.map((point) => {
- const barHeight = (point.value / max) * 100;
- const overlayHeight = point.secondary !== undefined && point.value > 0
- ? (point.secondary / point.value) * barHeight
- : 0;
- return (
-
-
-
-
- {overlayHeight > 0 && (
-
- )}
-
-
{point.label}
-
- );
- })}
-
- );
-}
diff --git a/packages/web/src/components/LineChart.tsx b/packages/web/src/components/LineChart.tsx
new file mode 100644
index 0000000..be4a89c
--- /dev/null
+++ b/packages/web/src/components/LineChart.tsx
@@ -0,0 +1,134 @@
+import { useState, type ReactNode } from 'react';
+
+export type LineChartPoint = {
+ key: string;
+ label: string;
+ value: number;
+ /** Optional error count; points with a value > 0 get a danger marker. */
+ secondary?: number;
+ tooltip: ReactNode;
+};
+
+const TOP_PADDING_PCT = 8;
+
+type Pt = { x: number; y: number };
+
+/** Monotone cubic spline (Fritsch–Carlson) — smooth but never overshoots the data. */
+function smoothPath(pts: Pt[]): string {
+ if (pts.length < 2) return pts.length ? `M ${pts[0].x} ${pts[0].y}` : '';
+ const n = pts.length;
+ const dx: number[] = [];
+ const slope: number[] = [];
+ for (let i = 0; i < n - 1; i++) {
+ dx.push(pts[i + 1].x - pts[i].x);
+ slope.push((pts[i + 1].y - pts[i].y) / dx[i]);
+ }
+ const m: number[] = [slope[0]];
+ for (let i = 1; i < n - 1; i++) {
+ if (slope[i - 1] * slope[i] <= 0) {
+ m.push(0);
+ } else {
+ const w1 = 2 * dx[i] + dx[i - 1];
+ const w2 = dx[i] + 2 * dx[i - 1];
+ m.push((w1 + w2) / (w1 / slope[i - 1] + w2 / slope[i]));
+ }
+ }
+ m.push(slope[n - 2]);
+
+ const f = (v: number) => +v.toFixed(2);
+ let d = `M ${f(pts[0].x)} ${f(pts[0].y)}`;
+ for (let i = 0; i < n - 1; i++) {
+ const t = dx[i] / 3;
+ d += ` C ${f(pts[i].x + t)} ${f(pts[i].y + m[i] * t)}, ${f(pts[i + 1].x - t)} ${f(pts[i + 1].y - m[i + 1] * t)}, ${f(pts[i + 1].x)} ${f(pts[i + 1].y)}`;
+ }
+ return d;
+}
+
+export default function LineChart({ points, height = 180, emptyLabel }: { points: LineChartPoint[]; height?: number; emptyLabel: string }) {
+ const [active, setActive] = useState(null);
+
+ if (points.length === 0) {
+ return {emptyLabel}
;
+ }
+
+ const n = points.length;
+ const max = Math.max(...points.map((p) => p.value), 1);
+ const x = (i: number) => (n === 1 ? 50 : (i / (n - 1)) * 100);
+ const y = (v: number) => 100 - (v / max) * (100 - TOP_PADDING_PCT);
+
+ const linePath = smoothPath(points.map((p, i) => ({ x: x(i), y: y(p.value) })));
+ const areaPath = `${linePath} L ${x(n - 1)} 100 L ${x(0)} 100 Z`;
+ const labelStep = Math.max(1, Math.ceil(n / 8));
+
+ const snapTo = (clientX: number, el: HTMLElement) => {
+ const rect = el.getBoundingClientRect();
+ const ratio = (clientX - rect.left) / rect.width;
+ setActive(Math.min(n - 1, Math.max(0, Math.round(ratio * (n - 1)))));
+ };
+
+ return (
+
+
snapTo(e.clientX, e.currentTarget)}
+ onPointerLeave={() => setActive(null)}
+ onFocus={() => setActive((a) => a ?? n - 1)}
+ onBlur={() => setActive(null)}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowLeft') { e.preventDefault(); setActive((a) => Math.max(0, (a ?? n) - 1)); }
+ if (e.key === 'ArrowRight') { e.preventDefault(); setActive((a) => Math.min(n - 1, (a ?? -1) + 1)); }
+ }}
+ >
+
+
+ {/* Persistent markers: series endpoint + days with errors */}
+ {points.map((p, i) => {
+ const hasError = (p.secondary ?? 0) > 0;
+ if (!hasError && i !== n - 1) return null;
+ return (
+
+ );
+ })}
+
+ {active !== null && (
+ <>
+
+
+
+
+ {points[active].tooltip}
+
+
+ >
+ )}
+
+
+ {/* X-axis labels: sparse to avoid collisions on long ranges */}
+
+ {points.map((p, i) => {
+ if (i % labelStep !== 0) return null;
+ const align = i === 0 ? 'translate-x-0' : i === n - 1 ? '-translate-x-full' : '-translate-x-1/2';
+ return (
+
+ {p.label}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/web/src/pages/admin/Dashboard.tsx b/packages/web/src/pages/admin/Dashboard.tsx
index 0911036..b9673fd 100644
--- a/packages/web/src/pages/admin/Dashboard.tsx
+++ b/packages/web/src/pages/admin/Dashboard.tsx
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
-import BarChart, { type BarChartPoint } from '../../components/BarChart';
+import LineChart, { type LineChartPoint } from '../../components/LineChart';
type Stats = {
totalUsers: number;
@@ -138,7 +138,7 @@ export default function Dashboard() {
},
];
- const trendPoints: BarChartPoint[] = (trends ?? []).map((point) => ({
+ const trendPoints: LineChartPoint[] = (trends ?? []).map((point) => ({
key: point.date,
label: point.date.slice(5),
value: point.calls,
@@ -176,7 +176,7 @@ export default function Dashboard() {
{/* Trend Chart */}
7 天调用趋势
-
+
{/* Recent Calls */}
diff --git a/packages/web/src/pages/tabs/Usage.tsx b/packages/web/src/pages/tabs/Usage.tsx
index 41c3bae..1cfd2f8 100644
--- a/packages/web/src/pages/tabs/Usage.tsx
+++ b/packages/web/src/pages/tabs/Usage.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import { useI18n } from '../../lib/i18n';
-import BarChart, { type BarChartPoint } from '../../components/BarChart';
+import LineChart, { type LineChartPoint } from '../../components/LineChart';
type DailyPoint = { date: string; calls: number; errors: number; tokens: number };
type ToolStat = { toolName: string; calls: number; tokens: number };
@@ -24,7 +24,7 @@ export default function Usage({ projectId }: { projectId: string }) {
const maxToolCalls = Math.max(...(data?.byTool.map((b) => b.calls) ?? [1]), 1);
- const dailyPoints: BarChartPoint[] = (data?.daily ?? []).map((point) => ({
+ const dailyPoints: LineChartPoint[] = (data?.daily ?? []).map((point) => ({
key: point.date,
label: point.date.slice(5),
value: point.calls,
@@ -78,7 +78,7 @@ export default function Usage({ projectId }: { projectId: string }) {
{isLoading ? (
) : (
-
+
)}