diff --git a/.env.example b/.env.example index 114c9b4..d470202 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,9 @@ DATABASE_URL= # Security PASSWORD= -CRON_SECRET= \ No newline at end of file +CRON_SECRET= + +# 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= \ No newline at end of file diff --git a/app/api/overview/route.ts b/app/api/overview/route.ts index f3e0403..f3839f5 100644 --- a/app/api/overview/route.ts +++ b/app/api/overview/route.ts @@ -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"; @@ -10,6 +10,7 @@ type CachedOverview = { overview: Awaited>["overview"] | null; empty: boolean; days: number; + timezone: string; meta?: Awaited>["meta"]; filters?: Awaited>["filters"]; }; @@ -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 }); - 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) { diff --git a/app/explore/page.tsx b/app/explore/page.tsx index 6efc634..cedd164 100644 --- a/app/explore/page.tsx +++ b/app/explore/page.tsx @@ -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", diff --git a/app/logs/page.tsx b/app/logs/page.tsx index 5379b4d..ce72296 100644 --- a/app/logs/page.tsx +++ b/app/logs/page.tsx @@ -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 }); } diff --git a/app/page.tsx b/app/page.tsx index 0d561f5..45fae8a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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; @@ -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", @@ -66,15 +65,21 @@ const numericTooltipFormatter: TooltipProps["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 @@ -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, @@ -167,6 +172,7 @@ export default function DashboardPage() { } }, []); const [overview, setOverview] = useState(null); + const [bucketTimezone, setBucketTimezone] = useState(undefined); const [overviewError, setOverviewError] = useState(null); const [overviewEmpty, setOverviewEmpty] = useState(false); const [loadingOverview, setLoadingOverview] = useState(true); @@ -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); @@ -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), diff --git a/app/records/page.tsx b/app/records/page.tsx index bd91fe5..47c9947 100644 --- a/app/records/page.tsx +++ b/app/records/page.tsx @@ -60,8 +60,7 @@ function formatTimestamp(ts: string) { hour: "2-digit", minute: "2-digit", second: "2-digit", - hour12: false, - timeZone: "Asia/Shanghai" + hour12: false }); } diff --git a/lib/config.ts b/lib/config.ts index a847ff5..df245e4 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -10,6 +10,20 @@ 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, @@ -17,7 +31,8 @@ export const config = { }, postgresUrl: process.env.DATABASE_URL || "", password, - cronSecret + cronSecret, + timezone }; export function assertEnv() { diff --git a/lib/queries/overview.ts b/lib/queries/overview.ts index ae85240..bc5406e 100644 --- a/lib/queries/overview.ts +++ b/lib/queries/overview.ts @@ -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; @@ -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 = db .select({ @@ -177,7 +183,7 @@ export async function getOverview( const byHourPromise: Promise = db .select({ label: sql`to_char(${hourExpr}, 'MM-DD HH24')`, - hourStart: sql`(${hourExpr}) at time zone 'Asia/Shanghai'`, + hourStart: sql`(${hourExpr}) at time zone ${tzLiteral}`, requests: sql`count(*)`, tokens: sql`sum(${usageRecords.totalTokens})`, inputTokens: sql`sum(${usageRecords.inputTokens})`, @@ -332,6 +338,7 @@ export async function getOverview( empty: totalRequests === 0, days, meta: { page, pageSize, totalModels, totalPages }, - filters + filters, + timezone: tz }; }