diff --git a/.env.example b/.env.example index 13b7cee1..72717e39 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ DATA_DIR=/var/lib/9router # Recommended runtime variables PORT=20128 NODE_ENV=production +# Dashboard, logs, and daily usage reporting use Vietnam time (UTC+7). +TZ=Asia/Ho_Chi_Minh # Recommended security and ops variables API_KEY_SECRET=endpoint-proxy-api-key-secret diff --git a/Dockerfile b/Dockerfile index 5abe1f24..9aea2dd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ LABEL org.opencontainers.image.title="9router" ENV NODE_ENV=production ENV PORT=20128 ENV HOSTNAME=0.0.0.0 +ENV TZ=Asia/Ho_Chi_Minh ENV NEXT_TELEMETRY_DISABLED=1 ENV DATA_DIR=/app/data diff --git a/cli/src/cli/utils/format.js b/cli/src/cli/utils/format.js index 5bf2ea4b..8baf71be 100644 --- a/cli/src/cli/utils/format.js +++ b/cli/src/cli/utils/format.js @@ -36,14 +36,18 @@ function formatDate(date) { return "Invalid Date"; } - const year = d.getFullYear(); - const month = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - const hours = String(d.getHours()).padStart(2, "0"); - const minutes = String(d.getMinutes()).padStart(2, "0"); - const seconds = String(d.getSeconds()).padStart(2, "0"); - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Ho_Chi_Minh", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(d); + const value = Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value])); + return `${value.year}-${value.month}-${value.day} ${value.hour}:${value.minute}:${value.second}`; } /** diff --git a/docker-compose.yml b/docker-compose.yml index 0b9a2186..86badd9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: PORT: "20128" HOSTNAME: "0.0.0.0" NODE_ENV: production + TZ: Asia/Ho_Chi_Minh JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy environment variables} INITIAL_PASSWORD: ${INITIAL_PASSWORD:?Set INITIAL_PASSWORD in Dokploy environment variables} API_KEY_SECRET: ${API_KEY_SECRET:?Set API_KEY_SECRET in Dokploy environment variables} diff --git a/open-sse/config/time.js b/open-sse/config/time.js new file mode 100644 index 00000000..e320047a --- /dev/null +++ b/open-sse/config/time.js @@ -0,0 +1,3 @@ +// Application display and reporting timezone. Timestamps remain stored as UTC +// ISO strings; this value is only used when presenting or grouping instants. +export const VIETNAM_TIME_ZONE = "Asia/Ho_Chi_Minh"; diff --git a/open-sse/utils/debugLog.js b/open-sse/utils/debugLog.js index 67cdc31f..b1185eb1 100644 --- a/open-sse/utils/debugLog.js +++ b/open-sse/utils/debugLog.js @@ -1,9 +1,11 @@ +import { VIETNAM_TIME_ZONE } from "../config/time.js"; + // Debug logging utility — only active in dev mode (NODE_ENV !== "production") // Outputs are tagged with [DBG:tag] for easy grep/filter const isDev = process.env.NODE_ENV !== "production"; function ts() { - return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + return new Date().toLocaleTimeString("en-US", { timeZone: VIETNAM_TIME_ZONE, hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); } export function dbg(tag, msg) { diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index 7f04427d..2f5ed4fb 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -1,10 +1,11 @@ // Stream handler with disconnect detection - shared for all providers import { STREAM_STALL_TIMEOUT_MS } from "../config/runtimeConfig.js"; +import { VIETNAM_TIME_ZONE } from "../config/time.js"; import { dbg, isDebugEnabled } from "./debugLog.js"; // Get HH:MM:SS timestamp function getTimeString() { - return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + return new Date().toLocaleTimeString("en-US", { timeZone: VIETNAM_TIME_ZONE, hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); } /** diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 1e16a40a..e0f36f12 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -17,6 +17,7 @@ import EndpointRow from "./components/EndpointRow"; import StatusAlert from "./components/StatusAlert"; import Tooltip from "./components/Tooltip"; import SecurityWarning from "./components/SecurityWarning"; +import { formatVietnamDateTime } from "@/shared/utils/dateTime"; export default function APIPageClient({ machineId, isAdmin }) { const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(true); @@ -1034,7 +1035,7 @@ export default function APIPageClient({ machineId, isAdmin }) {

- Created
{new Date(key.createdAt).toLocaleDateString()} + Created
{formatVietnamDateTime(key.createdAt, { dateStyle: "medium" }) || "—"}

{ if (n >= 1000000) return `${(n / 1000000).toFixed(2)}M`; @@ -222,7 +223,7 @@ export default function PxpipeClient() { {(stats?.recent || []).slice(0, 50).map((ev, i) => ( - {new Date(ev.ts).toLocaleString()} + {formatVietnamDateTime(ev.ts, { dateStyle: "medium", timeStyle: "medium" }) || "—"} {ev.provider ? `${ev.provider}/${ev.model}` : ev.model || "—"} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js index 0078ccf3..e60638ee 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js @@ -2,6 +2,7 @@ import { cn } from "@/shared/utils/cn"; import { formatResetTime } from "./utils"; +import { formatVietnamDateTime, formatVietnamTime, isSameVietnamDay } from "@/shared/utils/dateTime"; // Calculate color based on remaining percentage const getColorClasses = (remainingPercentage) => { @@ -38,25 +39,25 @@ const formatResetTimeDisplay = (resetTime) => { try { const resetDate = new Date(resetTime); - const now = new Date(); - const isToday = resetDate.toDateString() === now.toDateString(); - const isTomorrow = resetDate.toDateString() === new Date(now.getTime() + 86400000).toDateString(); + if (!Number.isFinite(resetDate.getTime())) return null; + const isToday = isSameVietnamDay(resetDate); + const isTomorrow = isSameVietnamDay(resetDate, new Date(Date.now() + 86400000)); - const timeStr = resetDate.toLocaleTimeString(undefined, { + const timeStr = formatVietnamTime(resetDate, { hour: "2-digit", minute: "2-digit", - hour12: true, + hourCycle: "h23", }); if (isToday) return `Today, ${timeStr}`; if (isTomorrow) return `Tomorrow, ${timeStr}`; - return resetDate.toLocaleString(undefined, { + return formatVietnamDateTime(resetDate, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", - hour12: true, + hourCycle: "h23", }); } catch { return null; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 8f2a1bc3..cbbd6ce1 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { formatResetTime, getRemainingPercentage } from "./utils"; +import { formatVietnamDateTime, formatVietnamTime, isSameVietnamDay } from "@/shared/utils/dateTime"; const PAGE_SIZE = 10; @@ -13,24 +14,21 @@ function formatResetTimeDisplay(resetTime) { try { const date = new Date(resetTime); - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); + if (!Number.isFinite(date.getTime())) return null; let dayStr = ""; - if (date >= today && date < tomorrow) { + if (isSameVietnamDay(date)) { dayStr = "Today"; - } else if (date >= tomorrow && date < new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000)) { + } else if (isSameVietnamDay(date, new Date(Date.now() + 86400000))) { dayStr = "Tomorrow"; } else { - dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + dayStr = formatVietnamDateTime(date, { month: "short", day: "numeric" }); } - const timeStr = date.toLocaleTimeString("en-US", { - hour: "numeric", + const timeStr = formatVietnamTime(date, { + hour: "2-digit", minute: "2-digit", - hour12: true, + hourCycle: "h23", }); return `${dayStr}, ${timeStr}`; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 95300c92..ee0a03cb 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -39,6 +39,7 @@ import Card from "@/shared/components/Card"; import { ConfirmModal, EditConnectionModal } from "@/shared/components"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { formatVietnamDateTime } from "@/shared/utils/dateTime"; // Maps the stored providerSpecificData.authMethod to a human label for Kiro. // Values come from the Kiro connect flows: builder-id/idc (device code), @@ -99,15 +100,13 @@ function getCodexResetCreditCount(quota) { function formatCreditDate(value) { if (!value) return "N/A"; - const date = new Date(value); - if (!Number.isFinite(date.getTime())) return "N/A"; - return date.toLocaleString(undefined, { + return formatVietnamDateTime(value, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", - }); + }) || "N/A"; } function formatTimeRemaining(value) { diff --git a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js index af8eb736..87bf05ab 100644 --- a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js +++ b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js @@ -7,6 +7,7 @@ import Drawer from "@/shared/components/Drawer"; import Pagination from "@/shared/components/Pagination"; import { cn } from "@/shared/utils/cn"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; +import { formatVietnamDateTime } from "@/shared/utils/dateTime"; let providerNameCache = null; let providerNodesCache = null; @@ -286,7 +287,7 @@ export default function RequestDetailsTab() { className="border-b border-black/5 dark:border-white/5 last:border-b-0 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors" > - {new Date(detail.timestamp).toLocaleString()} + {formatVietnamDateTime(detail.timestamp, { dateStyle: "medium", timeStyle: "medium" }) || "—"} {detail.model} @@ -358,7 +359,7 @@ export default function RequestDetailsTab() {
Timestamp:{" "} - {new Date(selectedDetail.timestamp).toLocaleString()} + {formatVietnamDateTime(selectedDetail.timestamp, { dateStyle: "medium", timeStyle: "medium" }) || "—"}
Provider:{" "} diff --git a/src/app/(dashboard)/dashboard/usage/components/SystemQuotaOverview.js b/src/app/(dashboard)/dashboard/usage/components/SystemQuotaOverview.js index c541ff0c..c0b9b9ea 100644 --- a/src/app/(dashboard)/dashboard/usage/components/SystemQuotaOverview.js +++ b/src/app/(dashboard)/dashboard/usage/components/SystemQuotaOverview.js @@ -5,6 +5,7 @@ import { AI_PROVIDERS } from "@/shared/constants/providers"; import { Button, Card, CardSkeleton } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { formatResetTime, REFRESH_INTERVAL_MS } from "./ProviderLimits/utils"; +import { formatVietnamDateTime, formatVietnamTime, isSameVietnamDay } from "@/shared/utils/dateTime"; function getProviderInfo(providerId) { return AI_PROVIDERS[providerId] || { @@ -16,13 +17,11 @@ function getProviderInfo(providerId) { function formatUpdatedAt(value) { if (!value) return "Not updated yet"; - const date = new Date(value); - if (!Number.isFinite(date.getTime())) return "Not updated yet"; - - return `Updated ${date.toLocaleTimeString(undefined, { + const formattedTime = formatVietnamTime(value, { hour: "2-digit", minute: "2-digit", - })}`; + }); + return formattedTime ? `Updated ${formattedTime}` : "Not updated yet"; } function formatResetAt(value) { @@ -31,14 +30,13 @@ function formatResetAt(value) { const date = new Date(value); if (!Number.isFinite(date.getTime())) return null; - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString(); - const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + const isToday = isSameVietnamDay(date); + const isTomorrow = isSameVietnamDay(date, new Date(Date.now() + 86400000)); + const time = formatVietnamTime(date, { hour: "2-digit", minute: "2-digit" }); if (isToday) return `today at ${time}`; if (isTomorrow) return `tomorrow at ${time}`; - return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + return formatVietnamDateTime(date, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } function getQuotaTone(percentage) { diff --git a/src/app/(dashboard)/dashboard/usage/components/UsageTable.js b/src/app/(dashboard)/dashboard/usage/components/UsageTable.js index 9da3c3e6..d7e9e31e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/UsageTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/UsageTable.js @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo, Fragment } from "react"; import PropTypes from "prop-types"; import Card from "@/shared/components/Card"; import Badge from "@/shared/components/Badge"; +import { formatVietnamDateTime } from "@/shared/utils/dateTime"; const fmt = (n) => new Intl.NumberFormat().format(n || 0); const fmtCost = (n) => `$${(n || 0).toFixed(2)}`; @@ -14,7 +15,7 @@ function fmtTime(iso) { if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; - return new Date(iso).toLocaleDateString(); + return formatVietnamDateTime(iso, { dateStyle: "medium" }) || "Never"; } function SortIcon({ field, currentSort, currentOrder }) { diff --git a/src/app/(dashboard)/dashboard/users/page.js b/src/app/(dashboard)/dashboard/users/page.js index 341ded68..da038cdc 100644 --- a/src/app/(dashboard)/dashboard/users/page.js +++ b/src/app/(dashboard)/dashboard/users/page.js @@ -5,12 +5,13 @@ import { useRouter } from "next/navigation"; import { Button, Card, Input } from "@/shared/components"; import Modal, { ConfirmModal } from "@/shared/components/Modal"; import useUserStore from "@/store/userStore"; +import { formatVietnamDateTime } from "@/shared/utils/dateTime"; const EMPTY_FORM = { username: "", password: "", role: "user", isActive: true }; function formatDate(value) { if (!value) return "—"; - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); + return formatVietnamDateTime(value, { dateStyle: "medium", timeStyle: "short" }) || "—"; } export default function UsersPage() { diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 51980e80..fe5bbb51 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -3,6 +3,13 @@ import { getAdapter } from "../driver.js"; import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; import { getMeta, setMeta } from "../helpers/metaStore.js"; import { appendUsageAccessClause, getUsageAccessScope } from "./usageAccessScope.js"; +import { + formatVietnamDateTime, + formatVietnamTime, + getVietnamDateKey, + getVietnamStartOfDay, + shiftVietnamDateKey, +} from "../../../shared/utils/dateTime.js"; function maskApiKey(key) { if (!key || typeof key !== "string") return null; @@ -47,8 +54,7 @@ function scheduleStatsEvent(event, delayMs = 150) { } function getLocalDateKey(timestamp) { - const d = timestamp ? new Date(timestamp) : new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + return getVietnamDateKey(timestamp || new Date()); } function addToCounter(target, key, values) { @@ -361,9 +367,7 @@ function loadDaysInRange(adapter, maxDays) { if (maxDays == null) { return adapter.all(`SELECT dateKey, data FROM usageDaily`); } - const today = new Date(); - const cutoff = new Date(today.getFullYear(), today.getMonth(), today.getDate() - maxDays + 1); - const cutoffKey = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-${String(cutoff.getDate()).padStart(2, "0")}`; + const cutoffKey = shiftVietnamDateKey(getVietnamDateKey(), -(maxDays - 1)); return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ?`, [cutoffKey]); } @@ -449,17 +453,12 @@ function loadUserBreakdownRows(db, period) { function getUsagePeriodCutoff(period) { if (period === "today") { - const startOfDay = new Date(); - startOfDay.setHours(0, 0, 0, 0); - return startOfDay.toISOString(); + return getVietnamStartOfDay().toISOString(); } if (period === "24h") return new Date(Date.now() - PERIOD_MS["24h"]).toISOString(); const days = { "7d": 7, "30d": 30, "60d": 60 }[period]; if (!days) return null; - const startOfRange = new Date(); - startOfRange.setHours(0, 0, 0, 0); - startOfRange.setDate(startOfRange.getDate() - days + 1); - return startOfRange.toISOString(); + return new Date(`${shiftVietnamDateKey(getVietnamDateKey(), -(days - 1))}T00:00:00+07:00`).toISOString(); } async function getScopedUsageStats(period, user, scope) { @@ -796,9 +795,7 @@ export async function getUsageStats(period = "all", user = null) { // 24h / today: live history let cutoff; if (period === "today") { - const startOfDay = new Date(); - startOfDay.setHours(0, 0, 0, 0); - cutoff = startOfDay.toISOString(); + cutoff = getVietnamStartOfDay().toISOString(); } else { cutoff = new Date(Date.now() - PERIOD_MS["24h"]).toISOString(); } @@ -896,11 +893,11 @@ function buildChartDataFromRows(rows, period) { const bucketCount = isHourly ? 24 : period === "7d" ? 7 : period === "30d" ? 30 : 60; const bucketMs = isHourly ? 3600000 : 86400000; const startTime = period === "today" - ? new Date(new Date().setHours(0, 0, 0, 0)).getTime() - : isHourly ? now - bucketCount * bucketMs : new Date(new Date().setHours(0, 0, 0, 0) - (bucketCount - 1) * bucketMs).getTime(); + ? getVietnamStartOfDay().getTime() + : isHourly ? now - bucketCount * bucketMs : new Date(`${shiftVietnamDateKey(getVietnamDateKey(), -(bucketCount - 1))}T00:00:00+07:00`).getTime(); const labelFn = isHourly - ? (timestamp) => new Date(timestamp).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) - : (timestamp) => new Date(timestamp).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + ? (timestamp) => formatVietnamTime(timestamp, { hour: "2-digit", minute: "2-digit" }) + : (timestamp) => formatVietnamDateTime(timestamp, { month: "short", day: "numeric" }); const buckets = Array.from({ length: bucketCount }, (_, index) => ({ label: labelFn(startTime + index * bucketMs), tokens: 0, cost: 0 })); for (const row of rows) { @@ -936,11 +933,9 @@ export async function getChartData(period = "7d", user = null) { if (period === "today") { const bucketCount = 24; const bucketMs = 3600000; - const startOfDay = new Date(); - startOfDay.setHours(0, 0, 0, 0); - const startTime = startOfDay.getTime(); + const startTime = getVietnamStartOfDay().getTime(); const endTime = startTime + bucketCount * bucketMs; - const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + const labelFn = (ts) => formatVietnamTime(ts, { hour: "2-digit", minute: "2-digit" }); const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 })); const rows = db.all( @@ -962,7 +957,7 @@ export async function getChartData(period = "7d", user = null) { if (period === "24h") { const bucketCount = 24; const bucketMs = 3600000; - const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + const labelFn = (ts) => formatVietnamTime(ts, { hour: "2-digit", minute: "2-digit" }); const startTime = now - bucketCount * bucketMs; const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 })); @@ -981,8 +976,8 @@ export async function getChartData(period = "7d", user = null) { } const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60; - const today = new Date(); - const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const todayKey = getVietnamDateKey(); + const labelFn = (dateKey) => formatVietnamDateTime(`${dateKey}T00:00:00+07:00`, { month: "short", day: "numeric" }); // Build map of dateKey → day data const dayRows = loadDaysInRange(db, bucketCount); @@ -990,12 +985,10 @@ export async function getChartData(period = "7d", user = null) { for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {}); return Array.from({ length: bucketCount }, (_, i) => { - const d = new Date(today); - d.setDate(d.getDate() - (bucketCount - 1 - i)); - const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const dateKey = shiftVietnamDateKey(todayKey, -(bucketCount - 1 - i)); const dayData = dayMap[dateKey]; return { - label: labelFn(d), + label: labelFn(dateKey), tokens: dayData ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0, cost: dayData ? (dayData.cost || 0) : 0, }; @@ -1003,8 +996,15 @@ export async function getChartData(period = "7d", user = null) { } function formatLogDate(date = new Date()) { - const pad = (n) => String(n).padStart(2, "0"); - return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + return formatVietnamDateTime(date, { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }); } // No-op: request log is now derived from usageHistory table on read. diff --git a/src/lib/pxpipe/events.js b/src/lib/pxpipe/events.js index b1bb880d..16a8b13c 100644 --- a/src/lib/pxpipe/events.js +++ b/src/lib/pxpipe/events.js @@ -1,6 +1,7 @@ import fs from "fs"; import path from "path"; import { PXPIPE_DIR } from "./install.js"; +import { getVietnamDateKey, getVietnamStartOfDay, shiftVietnamDateKey } from "@/shared/utils/dateTime"; const EVENTS_FILE = path.join(PXPIPE_DIR, "events.jsonl"); const ROTATED_FILE = path.join(PXPIPE_DIR, "events.jsonl.1"); @@ -81,7 +82,7 @@ function finalize(totals) { export function getPxpipeStats({ timelineDays = 30, recentLimit = 100 } = {}) { const events = readPxpipeEvents(); const now = Date.now(); - const startOfToday = new Date(new Date(now).setHours(0, 0, 0, 0)).getTime(); + const startOfToday = getVietnamStartOfDay(now).getTime(); const windows = { all: emptyTotals(), @@ -92,9 +93,10 @@ export function getPxpipeStats({ timelineDays = 30, recentLimit = 100 } = {}) { }; const timeline = new Map(); + const todayKey = getVietnamDateKey(now); for (let i = timelineDays - 1; i >= 0; i--) { - const day = new Date(startOfToday - i * DAY_MS); - timeline.set(day.toISOString().slice(0, 10), { date: day.toISOString().slice(0, 10), tokensSavedEst: 0, compressed: 0, requests: 0 }); + const dateKey = shiftVietnamDateKey(todayKey, -i); + timeline.set(dateKey, { date: dateKey, tokensSavedEst: 0, compressed: 0, requests: 0 }); } for (const ev of events) { @@ -104,7 +106,7 @@ export function getPxpipeStats({ timelineDays = 30, recentLimit = 100 } = {}) { if (ev.ts >= now - 7 * DAY_MS) accumulate(windows.last7d, ev); if (ev.ts >= now - 30 * DAY_MS) accumulate(windows.last30d, ev); - const key = new Date(ev.ts).toISOString().slice(0, 10); + const key = getVietnamDateKey(ev.ts); const bucket = timeline.get(key); if (bucket) { bucket.requests++; diff --git a/src/mitm/logger.js b/src/mitm/logger.js index 5e53210e..30d6d460 100644 --- a/src/mitm/logger.js +++ b/src/mitm/logger.js @@ -4,8 +4,10 @@ const zlib = require("zlib"); const { DATA_DIR } = require("./paths"); const { LOG_BLACKLIST_URL_PARTS } = require("./config"); +const VIETNAM_TIME_ZONE = "Asia/Ho_Chi_Minh"; + function time() { - return new Date().toLocaleTimeString("en-US", { hour12: false }); + return new Date().toLocaleTimeString("en-US", { timeZone: VIETNAM_TIME_ZONE, hour12: false }); } const log = (msg) => console.log(`[${time()}] [MITM] ${msg}`); diff --git a/src/shared/utils/dateTime.js b/src/shared/utils/dateTime.js new file mode 100644 index 00000000..5d699b86 --- /dev/null +++ b/src/shared/utils/dateTime.js @@ -0,0 +1,62 @@ +import { VIETNAM_TIME_ZONE } from "../../../open-sse/config/time.js"; + +export { VIETNAM_TIME_ZONE }; + +export const VIETNAM_LOCALE = "vi-VN"; + +function toValidDate(value) { + const date = value instanceof Date ? value : new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function getPartMap(value) { + const date = toValidDate(value); + if (!date) return null; + + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: VIETNAM_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date); + + return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value])); +} + +export function getVietnamDateKey(value = new Date()) { + const parts = getPartMap(value); + return parts ? `${parts.year}-${parts.month}-${parts.day}` : null; +} + +export function getVietnamStartOfDay(value = new Date()) { + const dateKey = getVietnamDateKey(value); + return dateKey ? new Date(`${dateKey}T00:00:00+07:00`) : null; +} + +export function shiftVietnamDateKey(dateKey, days) { + const date = new Date(`${dateKey}T00:00:00+07:00`); + if (!Number.isFinite(date.getTime())) return null; + date.setUTCDate(date.getUTCDate() + days); + return getVietnamDateKey(date); +} + +export function isSameVietnamDay(first, second = new Date()) { + const firstKey = getVietnamDateKey(first); + return firstKey !== null && firstKey === getVietnamDateKey(second); +} + +export function formatVietnamDateTime(value, options = {}) { + const date = toValidDate(value); + if (!date) return null; + return new Intl.DateTimeFormat(VIETNAM_LOCALE, { timeZone: VIETNAM_TIME_ZONE, ...options }).format(date); +} + +export function formatVietnamTime(value, options = {}) { + return formatVietnamDateTime(value, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + ...options, + }); +} \ No newline at end of file diff --git a/src/sse/utils/logger.js b/src/sse/utils/logger.js index a9c631ed..2b2767b0 100644 --- a/src/sse/utils/logger.js +++ b/src/sse/utils/logger.js @@ -1,3 +1,5 @@ +import { VIETNAM_TIME_ZONE } from "open-sse/config/time.js"; + // Logger utility for cloud const LOG_LEVELS = { @@ -10,7 +12,7 @@ const LOG_LEVELS = { const LEVEL = LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase?.()] ?? LOG_LEVELS.INFO; function formatTime() { - return new Date().toLocaleTimeString("en-US", { hour12: false }); + return new Date().toLocaleTimeString("en-US", { timeZone: VIETNAM_TIME_ZONE, hour12: false }); } // Colored-dot tags to correlate request lines by session (same session → same color)