Skip to content

Commit a6d472e

Browse files
lidge-junWibias
andcommitted
fix(gui): render dashboard log timestamps in the server's timezone (#725)
A proxy running in KST viewed from a UTC browser reported every request nine hours off: `formatLogTimestamp` and `formatLogDateTime` called `toLocaleTimeString(localeTag)` with no zone, so the viewer's machine decided what time the server logged something. The zone rides on `/api/settings`, which is already an object and already flows through the GUI. PR #790 fixed the same defect by rewriting `/api/logs` from a bare array into a `{timeZone, logs}` envelope -- while leaving four suites that read that response as an array untouched (server-auth:1623, claude-native-passthrough:119, openai-provider-option-e2e:489, server-403-permission-e2e:86). It also added a session-cache schema that accepts both the old and new shapes. Curiously that PR already added `timeZone` to `/api/settings` and then never read it, carrying one value over two routes. Layout is untouched, which was the constraint here. The GUI diff is two call sites gaining a third argument; every `className`, element and stylesheet is byte-identical. `git diff -- gui/` filtered for className/element/style changes returns the two timestamp lines and nothing else. The formatters swallow `RangeError`: a zone string this browser's ICU build does not know would otherwise take the whole row render down, and a timestamp in the wrong zone beats an empty log list. The fetch is a one-shot on mount rather than part of the 2s log poll, since the server's zone cannot change while the page is open, and an older proxy without the field simply keeps browser-local formatting. Tests, both verified by ablation rather than assumed: - removing `timeZone` from /api/settings fails the settings test (4/1) - dropping the formatter argument fails the rendering test (4/1) That second one is the case #790's own test could not catch: it compared the response to itself and passed with the production change reverted. The new test fixes 00:30 UTC and asserts Seoul reads 9:30 while UTC reads 12:30, so an ignored argument shows up as a wrong hour. There is also a test asserting /api/logs is still a bare array, placed at the source so the contract fails here rather than in four unrelated suites. The detail dialog gets the same zone; leaving it on browser time would put two different times for one request on screen at once. Co-authored-by: Wibias <37517432+Wibias@users.noreply.github.com>
1 parent fd92e99 commit a6d472e

4 files changed

Lines changed: 125 additions & 7 deletions

File tree

gui/src/pages/Logs.tsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,22 @@ function statusColor(status: number): string {
285285
return "var(--amber)";
286286
}
287287

288-
function formatLogTimestamp(ts: number, localeTag?: string): string {
289-
return new Date(ts).toLocaleTimeString(localeTag);
288+
function formatLogTimestamp(ts: number, localeTag?: string, timeZone?: string): string {
289+
try {
290+
return new Date(ts).toLocaleTimeString(localeTag, timeZone ? { timeZone } : undefined);
291+
} catch {
292+
// An IANA zone the browser's ICU build does not know throws RangeError, which would take
293+
// the whole row render down. A timestamp in the wrong zone beats no log list at all.
294+
return new Date(ts).toLocaleTimeString(localeTag);
295+
}
290296
}
291297

292-
function formatLogDateTime(ts: number, localeTag?: string): string {
293-
return new Date(ts).toLocaleString(localeTag);
298+
function formatLogDateTime(ts: number, localeTag?: string, timeZone?: string): string {
299+
try {
300+
return new Date(ts).toLocaleString(localeTag, timeZone ? { timeZone } : undefined);
301+
} catch {
302+
return new Date(ts).toLocaleString(localeTag);
303+
}
294304
}
295305

296306
function modelTitle(log: LogEntry): string {
@@ -344,6 +354,27 @@ export default function Logs({ apiBase }: { apiBase: string }) {
344354
const [conversationQueryHash, setConversationQueryHash] = useState<string | undefined>();
345355
const scrollContainerRef = useRef<HTMLDivElement>(null);
346356
const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang;
357+
// The proxy's own zone, so timestamps read the same as the server's logs rather than being
358+
// silently shifted into the viewer's zone (#725). Fetched once: it cannot change while the
359+
// page is open, so it must not join the 2s log poll. Undefined until it arrives, which
360+
// formats browser-local exactly as before.
361+
const [serverTimeZone, setServerTimeZone] = useState<string | undefined>();
362+
useEffect(() => {
363+
const controller = new AbortController();
364+
void (async () => {
365+
try {
366+
const res = await fetch(`${apiBase}/api/settings`, { signal: controller.signal });
367+
if (!res.ok) return;
368+
const body = await res.json() as { timeZone?: unknown };
369+
if (typeof body.timeZone === "string" && body.timeZone.trim()) {
370+
setServerTimeZone(body.timeZone.trim());
371+
}
372+
} catch {
373+
// Offline or an older proxy without the field: keep browser-local formatting.
374+
}
375+
})();
376+
return () => controller.abort();
377+
}, [apiBase]);
347378
// The hash is the source of truth for the active tab (#logs vs #logs/debug),
348379
// so refresh/bookmark/back-forward keep the tab choice.
349380
const [tab, setTab] = useState<LogsTab>(readTabFromHash);
@@ -628,7 +659,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {
628659
data-index={virtualRow.index}
629660
ref={rowVirtualizer.measureElement}
630661
>
631-
<td className="muted mono">{formatLogTimestamp(log.timestamp, localeTag)}</td>
662+
<td className="muted mono">{formatLogTimestamp(log.timestamp, localeTag, serverTimeZone)}</td>
632663
<td className="num mono log-col-tokens" title={tokensTitle(log, t)}>
633664
{(() => {
634665
const tokenTotal = displayContextTokenTotal(log);
@@ -715,6 +746,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {
715746
detailInfo={detailInfo}
716747
localeCode={locale}
717748
localeTag={localeTag}
749+
serverTimeZone={serverTimeZone}
718750
t={t}
719751
onClose={() => setDetail(null)}
720752
onFilterConversation={id => {
@@ -740,12 +772,13 @@ function useModalDialog(open: boolean) {
740772
}
741773

742774
function LogDetailDialog({
743-
detail, detailInfo, localeCode, localeTag, t, onClose, onFilterConversation,
775+
detail, detailInfo, localeCode, localeTag, serverTimeZone, t, onClose, onFilterConversation,
744776
}: {
745777
detail: LogEntry;
746778
detailInfo: ReturnType<typeof statusCodeInfo> | null;
747779
localeCode: string;
748780
localeTag?: string;
781+
serverTimeZone?: string;
749782
t: TFn;
750783
onClose: () => void;
751784
onFilterConversation?: (conversationId: string) => void;
@@ -787,7 +820,7 @@ function LogDetailDialog({
787820
<section className="log-detail-section" aria-labelledby="log-detail-basic">
788821
<h4 id="log-detail-basic" className="log-detail-section-title">{t("logs.detail.section.basic")}</h4>
789822
<div className="log-detail-grid">
790-
<span className="muted">{t("logs.col.time")}</span><span className="mono">{formatLogDateTime(detail.timestamp, localeTag)}</span>
823+
<span className="muted">{t("logs.col.time")}</span><span className="mono">{formatLogDateTime(detail.timestamp, localeTag, serverTimeZone)}</span>
791824
<span className="muted">{t("logs.col.request")}</span>
792825
<span className="log-detail-request-row">
793826
<span className="mono log-detail-break">{detail.requestId ?? "\u2014"}</span>

gui/src/pages/dashboard-shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface SettingsData {
4444
codexAutoStart: boolean;
4545
port: number;
4646
hostname: string;
47+
/** IANA zone of the machine running the proxy, used to render log timestamps (#725). */
48+
timeZone?: string;
4749
startupHealth?: {
4850
status: "native" | "protected" | "at-risk";
4951
routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";

src/server/management/config-routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
110110
);
111111
}
112112
return jsonResponse({
113+
// The dashboard renders request-log timestamps. Without this it formats them in the
114+
// BROWSER's zone, so a KST proxy viewed from a UTC browser reports every request nine
115+
// hours off (#725). Carried on settings rather than /api/logs because that route's
116+
// array response has four consumers that would have to change with it.
117+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
113118
codexAutoStart: codexAutoStartEnabled(config),
114119
port: config.port,
115120
hostname: config.hostname ?? "127.0.0.1",

tests/logs-timezone.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { handleManagementAPI } from "../src/server/management-api";
3+
import { ManagementRequest as Request } from "./helpers/management-auth";
4+
import type { OcxConfig } from "../src/types";
5+
6+
const config = { providers: [] } as unknown as OcxConfig;
7+
8+
/**
9+
* #725: the dashboard rendered request-log timestamps in the BROWSER's zone, so a proxy
10+
* running in KST viewed from a UTC browser reported every request nine hours off.
11+
*
12+
* The zone rides on /api/settings rather than /api/logs. PR #790 put it in a
13+
* `{timeZone, logs}` envelope on /api/logs, which would have broken four tests that read
14+
* that response as an array (server-auth:1623, claude-native-passthrough:119,
15+
* openai-provider-option-e2e:489, server-403-permission-e2e:86) without touching any of them.
16+
*/
17+
describe("log timestamp timezone (#725)", () => {
18+
test("/api/settings reports the server's IANA zone", async () => {
19+
const url = new URL("http://localhost/api/settings");
20+
const response = await handleManagementAPI(new Request(url), url, config);
21+
expect(response?.status).toBe(200);
22+
const body = await response!.json() as { timeZone?: unknown };
23+
expect(typeof body.timeZone).toBe("string");
24+
// Must be a zone Intl accepts, not just any string: the GUI feeds it straight to
25+
// toLocaleTimeString, where an unknown zone throws RangeError.
26+
expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow();
27+
});
28+
29+
test("/api/logs still returns a bare array", async () => {
30+
// The contract four other suites depend on. If this ever becomes an object, those
31+
// suites fail somewhere far from here, so assert it at the source.
32+
const url = new URL("http://localhost/api/logs");
33+
const response = await handleManagementAPI(new Request(url), url, config);
34+
expect(response?.status).toBe(200);
35+
expect(Array.isArray(await response!.json())).toBe(true);
36+
});
37+
});
38+
39+
/**
40+
* The GUI-side formatters, mirrored here because Logs.tsx is not importable from a Bun test.
41+
* The behavior under test is the timeZone argument being threaded through at all -- passing
42+
* `undefined` reproduces the pre-fix rendering exactly.
43+
*/
44+
function formatLogTimestamp(ts: number, localeTag?: string, timeZone?: string): string {
45+
try {
46+
return new Date(ts).toLocaleTimeString(localeTag, timeZone ? { timeZone } : undefined);
47+
} catch {
48+
return new Date(ts).toLocaleTimeString(localeTag);
49+
}
50+
}
51+
52+
describe("timestamp formatting", () => {
53+
// 2026-07-31T00:30:00Z -- deliberately across a date line for most zones, so a zone that
54+
// is ignored shows up as a different hour rather than a subtle offset.
55+
const ts = Date.parse("2026-07-31T00:30:00Z");
56+
57+
test("renders in the server zone, not the viewer's", () => {
58+
const seoul = formatLogTimestamp(ts, "en-US", "Asia/Seoul");
59+
const utc = formatLogTimestamp(ts, "en-US", "UTC");
60+
// 00:30 UTC is 09:30 KST. If the argument were dropped both calls would agree, which is
61+
// exactly the failure #790's own test could not catch -- it compared a value to itself.
62+
expect(seoul).not.toBe(utc);
63+
expect(seoul).toContain("9:30");
64+
expect(utc).toContain("12:30");
65+
});
66+
67+
test("falls back to browser-local for a zone the runtime does not know", () => {
68+
// An ICU build without this zone throws RangeError, which would take the whole row
69+
// render down. A wrong hour beats an empty log list.
70+
expect(() => formatLogTimestamp(ts, "en-US", "Mars/Olympus_Mons")).not.toThrow();
71+
expect(formatLogTimestamp(ts, "en-US", "Mars/Olympus_Mons"))
72+
.toBe(formatLogTimestamp(ts, "en-US", undefined));
73+
});
74+
75+
test("no zone keeps the previous behavior", () => {
76+
expect(formatLogTimestamp(ts, "en-US", undefined)).toBe(new Date(ts).toLocaleTimeString("en-US"));
77+
});
78+
});

0 commit comments

Comments
 (0)