Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ DATABASE_URL=

# Security
PASSWORD=
CRON_SECRET=<any-long-secret-string>
CRON_SECRET=<any-long-secret-string>

# Display timezone for date grouping in charts (IANA timezone name)
# Examples: UTC, Europe/London, America/New_York, Asia/Tokyo
# Defaults to Asia/Shanghai if not set. Set to your local timezone for correct day/hour grouping.
TIMEZONE=
10 changes: 6 additions & 4 deletions app/api/overview/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { assertEnv } from "@/lib/config";
import { assertEnv, config } from "@/lib/config";
import { getOverview } from "@/lib/queries/overview";

export const runtime = "nodejs";
Expand All @@ -10,6 +10,7 @@ type CachedOverview = {
overview: Awaited<ReturnType<typeof getOverview>>["overview"] | null;
empty: boolean;
days: number;
timezone: string;
meta?: Awaited<ReturnType<typeof getOverview>>["meta"];
filters?: Awaited<ReturnType<typeof getOverview>>["filters"];
};
Expand Down Expand Up @@ -79,16 +80,17 @@ export async function GET(request: Request) {
}
}

const { overview, empty, days: appliedDays, meta, filters } = await getOverview(days, {
const { overview, empty, days: appliedDays, meta, filters, timezone } = await getOverview(days, {
model: model || undefined,
route: route || undefined,
page,
pageSize,
start,
end
end,
timezone: config.timezone
Comment on lines +89 to +90
Copy link

Choose a reason for hiding this comment

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

question (bug_risk): 概览聚合现在使用固定的服务器时区,而 UI 日期格式化使用浏览器本地时区,可能会让用户感到困惑。

随着 config.timezone 传入 getOverview,聚合和分桶逻辑会在该服务器时区下运行,而更新后的页面(logsrecordsexplore、根路径)会按浏览器本地时区格式化日期。这种不匹配会导致位于 config.timezone 之外的用户看到的分桶时间看起来偏移了好几个小时。

如果这不是有意为之,建议:

  • 客户端格式化也使用 config.timezone(或用户选择的时区),或者
  • 让 API 返回 UTC 时间戳以及用于分桶的时区,以便客户端能保持一致地格式化。
Original comment in English

question (bug_risk): Overview aggregation now uses a fixed server timezone while UI date formatting uses the browser’s local timezone, which may confuse users.

With config.timezone now passed into getOverview, aggregation and bucketing run in that server timezone, while the updated pages (logs, records, explore, root) format dates in the browser’s local timezone. This mismatch means users outside config.timezone may see buckets that appear shifted by several hours.

If this is unintentional, either:

  • Use config.timezone (or a user-selected timezone) for client-side formatting as well, or
  • Have the API return UTC timestamps plus the bucketing timezone so the client can format consistently.

});

const payload = { overview, empty, days: appliedDays, meta, filters };
const payload = { overview, empty, days: appliedDays, meta, filters, timezone };
setCached(cacheKey, payload);
return NextResponse.json(payload, { status: 200 });
} catch (error) {
Expand Down
1 change: 0 additions & 1 deletion app/explore/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ function computeTimeTicks([min, max]: [number, number], maxTickCount = 8): numbe
}

const timeFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
Expand Down
3 changes: 1 addition & 2 deletions app/logs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ function formatTimestamp(ts: number | undefined): string {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZone: "Asia/Shanghai"
hour12: false
});
}

Expand Down
23 changes: 15 additions & 8 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const PIE_COLORS = [
];

type OverviewMeta = { page: number; pageSize: number; totalModels: number; totalPages: number };
type OverviewAPIResponse = { overview: UsageOverview | null; empty: boolean; days: number; meta?: OverviewMeta; filters?: { models: string[]; routes: string[] } };
type OverviewAPIResponse = { overview: UsageOverview | null; empty: boolean; days: number; timezone?: string; meta?: OverviewMeta; filters?: { models: string[]; routes: string[] } };

type PriceForm = {
model: string;
Expand All @@ -33,7 +33,6 @@ type PriceForm = {
};

const hourFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
Expand Down Expand Up @@ -66,15 +65,21 @@ const numericTooltipFormatter: TooltipProps<number, string>["formatter"] = (valu
return [formatNumberWithCommas(numericValue), name];
};

function formatHourKeyFromTs(ts: number) {
const parts = hourFormatter.formatToParts(new Date(ts));
function formatHourKeyFromTs(ts: number, formatter: Intl.DateTimeFormat) {
const parts = formatter.formatToParts(new Date(ts));
const month = parts.find((p) => p.type === "month")?.value ?? "00";
const day = parts.find((p) => p.type === "day")?.value ?? "00";
const hour = parts.find((p) => p.type === "hour")?.value ?? "00";
return `${month}-${day} ${hour}`;
}

function buildHourlySeries(series: UsageSeriesPoint[], rangeHours?: number) {
function buildHourlySeries(series: UsageSeriesPoint[], rangeHours?: number, timezone?: string) {
// Use the server's bucketing timezone for gap-fill labels so they match the
// labels returned for real data points. Falls back to the module-level formatter
// (browser timezone) when no timezone is provided.
const gapFormatter = timezone
? new Intl.DateTimeFormat("en-CA", { timeZone: timezone, month: "2-digit", day: "2-digit", hour: "2-digit", hour12: false })
: hourFormatter;
if (!series.length) return [] as UsageSeriesPoint[];

const withTs = series
Expand All @@ -98,7 +103,7 @@ function buildHourlySeries(series: UsageSeriesPoint[], rangeHours?: number) {
filled.push(rest);
} else {
filled.push({
label: formatHourKeyFromTs(ts),
label: formatHourKeyFromTs(ts, gapFormatter),
timestamp: new Date(ts).toISOString(),
requests: 0,
tokens: 0,
Expand Down Expand Up @@ -167,6 +172,7 @@ export default function DashboardPage() {
}
}, []);
const [overview, setOverview] = useState<UsageOverview | null>(null);
const [bucketTimezone, setBucketTimezone] = useState<string | undefined>(undefined);
const [overviewError, setOverviewError] = useState<string | null>(null);
const [overviewEmpty, setOverviewEmpty] = useState(false);
const [loadingOverview, setLoadingOverview] = useState(true);
Expand Down Expand Up @@ -730,6 +736,7 @@ export default function DashboardPage() {
const data: OverviewAPIResponse = await res.json();
if (!active) return;
setOverview(data.overview ?? null);
setBucketTimezone(data.timezone);
setOverviewEmpty(Boolean(data.empty));
setOverviewError(null);
setPage(data.meta?.page ?? 1);
Expand Down Expand Up @@ -760,8 +767,8 @@ export default function DashboardPage() {
if (!overviewData?.byHour) return [] as UsageSeriesPoint[];
if (hourRange === "all") return overviewData.byHour;
const hours = hourRange === "24h" ? 24 : 72;
return buildHourlySeries(overviewData.byHour, hours);
}, [hourRange, overviewData?.byHour]);
return buildHourlySeries(overviewData.byHour, hours, bucketTimezone);
}, [hourRange, overviewData?.byHour, bucketTimezone]);

const hourlyLineStyle = useMemo(
() => buildHourlyLineStyle(hourlySeries.length, 3),
Expand Down
3 changes: 1 addition & 2 deletions app/records/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ function formatTimestamp(ts: string) {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZone: "Asia/Shanghai"
hour12: false
});
}

Expand Down
17 changes: 16 additions & 1 deletion lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,29 @@ const baseUrl = normalizeBaseUrl(process.env.CLIPROXY_API_BASE_URL);
const password = process.env.PASSWORD || process.env.CLIPROXY_SECRET_KEY || "";
const cronSecret = process.env.CRON_SECRET || "";

function normalizeTimezone(raw: string | undefined): string {
const value = (raw || "").trim();
if (!value) return "Asia/Shanghai";
try {
Intl.DateTimeFormat(undefined, { timeZone: value });
return value;
} catch {
console.warn(`TIMEZONE env var "${value}" is not a valid IANA timezone. Falling back to Asia/Shanghai.`);
return "Asia/Shanghai";
}
}

const timezone = normalizeTimezone(process.env.TIMEZONE);

export const config = {
cliproxy: {
baseUrl,
apiKey: process.env.CLIPROXY_SECRET_KEY || ""
},
postgresUrl: process.env.DATABASE_URL || "",
password,
cronSecret
cronSecret,
timezone
};

export function assertEnv() {
Expand Down
19 changes: 13 additions & 6 deletions lib/queries/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ function normalizePageSize(value?: number | null) {

export async function getOverview(
daysInput?: number,
opts?: { model?: string | null; route?: string | null; page?: number | null; pageSize?: number | null; start?: string | Date | null; end?: string | Date | null }
): Promise<{ overview: UsageOverview; empty: boolean; days: number; meta: OverviewMeta; filters: { models: string[]; routes: string[] } }> {
opts?: { model?: string | null; route?: string | null; page?: number | null; pageSize?: number | null; start?: string | Date | null; end?: string | Date | null; timezone?: string | null }
): Promise<{ overview: UsageOverview; empty: boolean; days: number; meta: OverviewMeta; filters: { models: string[]; routes: string[] }; timezone: string }> {
const startDate = parseDateInput(opts?.start);
const endDate = parseDateInput(opts?.end);
const hasCustomRange = startDate && endDate && endDate >= startDate;
Expand All @@ -106,8 +106,14 @@ export async function getOverview(
if (opts?.route) filterWhereParts.push(eq(usageRecords.route, opts.route));
const filterWhere = filterWhereParts.length ? and(...filterWhereParts) : undefined;

const dayExpr = sql`date_trunc('day', ${usageRecords.occurredAt} at time zone 'Asia/Shanghai')`;
const hourExpr = sql`date_trunc('hour', ${usageRecords.occurredAt} at time zone 'Asia/Shanghai')`;
const tz = opts?.timezone || "Asia/Shanghai";
// Use sql.raw() so the timezone is embedded as a SQL literal rather than a query
// parameter. PostgreSQL requires the GROUP BY expression to be textually identical
// to the SELECT expression; different parameter indices ($1 vs $3) would cause a
// "must appear in GROUP BY" error even when the values are equal.
const tzLiteral = sql.raw(`'${tz}'`);
const dayExpr = sql`date_trunc('day', ${usageRecords.occurredAt} at time zone ${tzLiteral})`;
const hourExpr = sql`date_trunc('hour', ${usageRecords.occurredAt} at time zone ${tzLiteral})`;

const totalsPromise: Promise<TotalsRow[]> = db
.select({
Expand Down Expand Up @@ -177,7 +183,7 @@ export async function getOverview(
const byHourPromise: Promise<HourAggRow[]> = db
.select({
label: sql<string>`to_char(${hourExpr}, 'MM-DD HH24')`,
hourStart: sql<Date>`(${hourExpr}) at time zone 'Asia/Shanghai'`,
hourStart: sql<Date>`(${hourExpr}) at time zone ${tzLiteral}`,
requests: sql<number>`count(*)`,
tokens: sql<number>`sum(${usageRecords.totalTokens})`,
inputTokens: sql<number>`sum(${usageRecords.inputTokens})`,
Expand Down Expand Up @@ -332,6 +338,7 @@ export async function getOverview(
empty: totalRequests === 0,
days,
meta: { page, pageSize, totalModels, totalPages },
filters
filters,
timezone: tz
};
}