Skip to content

Commit fd43fcb

Browse files
committed
fix(gui): show dashboard log timestamps in server timezone
Return server timeZone from /api/logs, validate client ICU support, cache the timezone with log rows, and tolerate legacy array responses.
1 parent c777c8e commit fd43fcb

4 files changed

Lines changed: 71 additions & 14 deletions

File tree

gui/src/pages/Logs.tsx

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,44 @@ function logsCacheKey(apiBase: string): string {
2222
return `ocx.logs.list.v1:${apiBase}`;
2323
}
2424

25+
interface LogsCachePayload {
26+
logs: LogEntry[];
27+
timeZone?: string;
28+
}
29+
30+
function acceptClientTimeZone(timeZone: string | undefined): string | undefined {
31+
const trimmed = timeZone?.trim();
32+
if (!trimmed) return undefined;
33+
try {
34+
new Intl.DateTimeFormat(undefined, { timeZone: trimmed }).format(0);
35+
return trimmed;
36+
} catch {
37+
return undefined;
38+
}
39+
}
40+
41+
function readLogsCache(apiBase: string): LogsCachePayload | null {
42+
const raw = readSessionListCache<LogEntry[] | LogsCachePayload>(logsCacheKey(apiBase));
43+
if (!raw) return null;
44+
if (Array.isArray(raw)) return { logs: raw };
45+
if (Array.isArray(raw.logs)) {
46+
return { logs: raw.logs, timeZone: acceptClientTimeZone(raw.timeZone) };
47+
}
48+
return null;
49+
}
50+
51+
function parseLogsApiResponse(body: unknown): LogsCachePayload {
52+
if (Array.isArray(body)) return { logs: body };
53+
if (body && typeof body === "object") {
54+
const record = body as { logs?: unknown; timeZone?: unknown };
55+
return {
56+
logs: Array.isArray(record.logs) ? record.logs as LogEntry[] : [],
57+
timeZone: acceptClientTimeZone(typeof record.timeZone === "string" ? record.timeZone : undefined),
58+
};
59+
}
60+
return { logs: [] };
61+
}
62+
2563
interface UsageBreakdown {
2664
inputTokens: number;
2765
outputTokens: number;
@@ -285,12 +323,22 @@ function statusColor(status: number): string {
285323
return "var(--amber)";
286324
}
287325

288-
function formatLogTimestamp(ts: number, localeTag?: string): string {
289-
return new Date(ts).toLocaleTimeString(localeTag);
326+
function formatLogTimestamp(ts: number, localeTag?: string, timeZone?: string): string {
327+
const zone = acceptClientTimeZone(timeZone);
328+
try {
329+
return new Date(ts).toLocaleTimeString(localeTag, zone ? { timeZone: zone } : undefined);
330+
} catch {
331+
return new Date(ts).toLocaleTimeString(localeTag);
332+
}
290333
}
291334

292-
function formatLogDateTime(ts: number, localeTag?: string): string {
293-
return new Date(ts).toLocaleString(localeTag);
335+
function formatLogDateTime(ts: number, localeTag?: string, timeZone?: string): string {
336+
const zone = acceptClientTimeZone(timeZone);
337+
try {
338+
return new Date(ts).toLocaleString(localeTag, zone ? { timeZone: zone } : undefined);
339+
} catch {
340+
return new Date(ts).toLocaleString(localeTag);
341+
}
294342
}
295343

296344
function modelTitle(log: LogEntry): string {
@@ -336,7 +384,8 @@ function summarizeFilteredLogs(entries: LogEntry[]): {
336384

337385
export default function Logs({ apiBase }: { apiBase: string }) {
338386
const { t, locale } = useI18n();
339-
const cachedLogs = readSessionListCache<LogEntry[]>(logsCacheKey(apiBase));
387+
const cachedLogsPayload = readLogsCache(apiBase);
388+
const [serverTimeZone, setServerTimeZone] = useState<string | undefined>(() => cachedLogsPayload?.timeZone);
340389
const [autoRefresh, setAutoRefresh] = useState(true);
341390
const [detail, setDetail] = useState<LogEntry | null>(null);
342391
const [surfaceFilter, setSurfaceFilter] = useState<LogSurfaceFilter>("all");
@@ -366,9 +415,10 @@ export default function Logs({ apiBase }: { apiBase: string }) {
366415
const loadLogs = useCallback(async (signal: AbortSignal): Promise<LogEntry[]> => {
367416
const res = await fetch(`${apiBase}/api/logs`, { signal });
368417
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim());
369-
const next = await res.json() as LogEntry[];
370-
writeSessionListCache(logsCacheKey(apiBase), next);
371-
return next;
418+
const parsed = parseLogsApiResponse(await res.json());
419+
if (parsed.timeZone) setServerTimeZone(parsed.timeZone);
420+
writeSessionListCache(logsCacheKey(apiBase), parsed);
421+
return parsed.logs;
372422
}, [apiBase]);
373423

374424
// The resource layer owns the request and the 2s poll. It keeps held rows through a quiet
@@ -385,7 +435,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {
385435
},
386436
);
387437
const logsState = logsResource.state;
388-
const logs = logsState.data ?? cachedLogs ?? [];
438+
const logs = logsState.data ?? cachedLogsPayload?.logs ?? [];
389439
const fetchLogs = logsResource.refresh;
390440

391441
// A single failed tick on a two-second poll is noise, but an outage that never recovers must not
@@ -628,7 +678,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {
628678
data-index={virtualRow.index}
629679
ref={rowVirtualizer.measureElement}
630680
>
631-
<td className="muted mono">{formatLogTimestamp(log.timestamp, localeTag)}</td>
681+
<td className="muted mono">{formatLogTimestamp(log.timestamp, localeTag, serverTimeZone)}</td>
632682
<td className="num mono log-col-tokens" title={tokensTitle(log, t)}>
633683
{(() => {
634684
const tokenTotal = displayContextTokenTotal(log);
@@ -715,6 +765,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {
715765
detailInfo={detailInfo}
716766
localeCode={locale}
717767
localeTag={localeTag}
768+
serverTimeZone={serverTimeZone}
718769
t={t}
719770
onClose={() => setDetail(null)}
720771
onFilterConversation={id => {
@@ -740,12 +791,13 @@ function useModalDialog(open: boolean) {
740791
}
741792

742793
function LogDetailDialog({
743-
detail, detailInfo, localeCode, localeTag, t, onClose, onFilterConversation,
794+
detail, detailInfo, localeCode, localeTag, serverTimeZone, t, onClose, onFilterConversation,
744795
}: {
745796
detail: LogEntry;
746797
detailInfo: ReturnType<typeof statusCodeInfo> | null;
747798
localeCode: string;
748799
localeTag?: string;
800+
serverTimeZone?: string;
749801
t: TFn;
750802
onClose: () => void;
751803
onFilterConversation?: (conversationId: string) => void;
@@ -787,7 +839,7 @@ function LogDetailDialog({
787839
<section className="log-detail-section" aria-labelledby="log-detail-basic">
788840
<h4 id="log-detail-basic" className="log-detail-section-title">{t("logs.detail.section.basic")}</h4>
789841
<div className="log-detail-grid">
790-
<span className="muted">{t("logs.col.time")}</span><span className="mono">{formatLogDateTime(detail.timestamp, localeTag)}</span>
842+
<span className="muted">{t("logs.col.time")}</span><span className="mono">{formatLogDateTime(detail.timestamp, localeTag, serverTimeZone)}</span>
791843
<span className="muted">{t("logs.col.request")}</span>
792844
<span className="log-detail-request-row">
793845
<span className="mono log-detail-break">{detail.requestId ?? "\u2014"}</span>

src/server/management/config-routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
110110
);
111111
}
112112
return jsonResponse({
113+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
113114
codexAutoStart: codexAutoStartEnabled(config),
114115
port: config.port,
115116
hostname: config.hostname ?? "127.0.0.1",

src/server/management/logs-usage-routes.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
125125

126126
if (url.pathname === "/api/logs" && req.method === "GET") {
127127
const logs = filterRequestLogs(getRequestLogEntries(), url.searchParams);
128-
return jsonResponse(logs.map(requestLogDto));
128+
return jsonResponse({
129+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
130+
logs: logs.map(requestLogDto),
131+
});
129132
}
130133

131134
if (url.pathname === "/api/debug" && req.method === "GET") {

tests/management-api-logs-metrics.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ async function readLogs(): Promise<Array<Record<string, any>>> {
1616
const url = new URL("http://localhost/api/logs");
1717
const response = await handleManagementAPI(new Request(url), url, config);
1818
expect(response?.status).toBe(200);
19-
return await response!.json() as Array<Record<string, any>>;
19+
const body = await response!.json() as { logs?: Array<Record<string, any>> } | Array<Record<string, any>>;
20+
return Array.isArray(body) ? body : (body.logs ?? []);
2021
}
2122

2223
function baseEntry(overrides: Partial<RequestLogEntry>): RequestLogEntry {

0 commit comments

Comments
 (0)