From bd0bf0bf63c61a71112205106dfd5902c182dd7e Mon Sep 17 00:00:00 2001 From: michelia Date: Wed, 10 Jun 2026 10:00:57 +0800 Subject: [PATCH] fix(usage): render resource timestamps in their rollup tz, not browser-local --- .../usage/components/gpu-instances-tab.tsx | 9 ++++++--- src/pages/usage/components/resource-events.tsx | 5 ++++- src/pages/usage/components/storage-tab.tsx | 9 ++++++--- src/pages/usage/utils/time-buckets.ts | 18 +++++++++++++++++- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/pages/usage/components/gpu-instances-tab.tsx b/src/pages/usage/components/gpu-instances-tab.tsx index 754d45b4..d9994fd5 100644 --- a/src/pages/usage/components/gpu-instances-tab.tsx +++ b/src/pages/usage/components/gpu-instances-tab.tsx @@ -32,7 +32,8 @@ import { import { bucketKey, generateBucketRange, - Granularity + Granularity, + parseRollup } from '../utils/time-buckets'; import { buildTrendSeries } from '../utils/trend-series'; import MetricChartCard from './metric-chart-card'; @@ -328,12 +329,14 @@ const GpuInstancesTab: React.FC = () => { /> ) }; - // Last Active is a UTC instant (max bucket hour) → show formatted local time. + // Last Active = the last active day. The backend sends a rollup-tz instant + // with its offset; parseRollup keeps that wall clock (no browser-tz convert), + // consistent with the trend chart buckets. Shown date-only. const lastActiveCol = { title: intl.formatMessage({ id: 'usage.table.lastActive' }), dataIndex: 'last_active', key: 'last_active', - render: (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-') + render: (v?: string) => (v ? parseRollup(v).format('YYYY-MM-DD') : '-') }; if (activeTableTab === 'gpu_type') { return [ diff --git a/src/pages/usage/components/resource-events.tsx b/src/pages/usage/components/resource-events.tsx index 9d6f18cf..6c55ed10 100644 --- a/src/pages/usage/components/resource-events.tsx +++ b/src/pages/usage/components/resource-events.tsx @@ -24,6 +24,7 @@ import { ResourceEventItem, ResourceEventsResponse } from '../apis/resource'; +import { parseRollup } from '../utils/time-buckets'; import ResourceFilterBar from './resource-filter-bar'; // Only these four are ever emitted (see resource_event_logger): create/delete @@ -177,8 +178,10 @@ const ResourceEvents: React.FC = () => { title: intl.formatMessage({ id: 'usage.events.col.time' }), dataIndex: 'occurred_at', key: 'occurred_at', + // Backend sends the rollup-tz instant with its offset; parseRollup keeps + // it (no conversion to the browser tz). render: (v: string) => - v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-', + v ? parseRollup(v).format('YYYY-MM-DD HH:mm:ss') : '-', width: 200 }, { diff --git a/src/pages/usage/components/storage-tab.tsx b/src/pages/usage/components/storage-tab.tsx index 2376498b..4a75d89f 100644 --- a/src/pages/usage/components/storage-tab.tsx +++ b/src/pages/usage/components/storage-tab.tsx @@ -28,7 +28,8 @@ import useResourceMeta from '../hooks/use-resource-meta'; import { bucketKey, generateBucketRange, - Granularity + Granularity, + parseRollup } from '../utils/time-buckets'; import { buildTrendSeries } from '../utils/trend-series'; import MetricChartCard from './metric-chart-card'; @@ -293,12 +294,14 @@ const StorageTab: React.FC = () => { render: (v: number) => (v ?? 0).toFixed(2) } ]; - // Last Active is a UTC instant (max bucket hour) → show formatted local time. + // Last Active = the last active day. The backend sends a rollup-tz instant + // with its offset; parseRollup keeps that wall clock (no browser-tz convert), + // consistent with the trend chart buckets. Shown date-only. const lastActiveCol = { title: intl.formatMessage({ id: 'usage.table.lastActive' }), dataIndex: 'last_active', key: 'last_active', - render: (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-') + render: (v?: string) => (v ? parseRollup(v).format('YYYY-MM-DD') : '-') }; if (activeTableTab === 'volume') { return [ diff --git a/src/pages/usage/utils/time-buckets.ts b/src/pages/usage/utils/time-buckets.ts index 9493e424..d5950194 100644 --- a/src/pages/usage/utils/time-buckets.ts +++ b/src/pages/usage/utils/time-buckets.ts @@ -13,8 +13,24 @@ export type Granularity = 'hour' | 'day' | 'week' | 'month'; // Cap the hourly axis so a wide date range doesn't render hundreds of bars. const HOUR_MAX_DAYS = 7; +/** + * Parse a usage timestamp keeping the offset the backend embedded (the rollup + * tz, e.g. ``+08:00``) — i.e. render that wall clock verbatim, NOT converted to + * the browser's timezone. The typed equivalent of ``dayjs.parseZone``: + * ``.utcOffset(offset)`` re-displays the same instant at the embedded offset + * (``Z`` → UTC). A dayjs object (an axis cursor) or an offset-less date string + * passes through. + */ +export const parseRollup = (value: any): dayjs.Dayjs => { + if (dayjs.isDayjs(value)) return value; + const m = typeof value === 'string' && value.match(/([+-]\d{2}:?\d{2}|Z)$/i); + return m + ? dayjs(value).utcOffset(m[1].toUpperCase() === 'Z' ? 0 : m[1]) + : dayjs(value); +}; + export const bucketKey = (value: any, granularity: Granularity): string => { - const d = dayjs(value); + const d = parseRollup(value); if (granularity === 'hour') return d.format('YYYY-MM-DD HH:00'); if (granularity === 'month') return d.format('YYYY-MM'); return d.format('YYYY-MM-DD'); // day / week (week-start date as returned)