Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions gui/src/pages/Logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
sanitizeLogEntryRouteDecision,
validCachedRouteDecision,
} from "./log-route-decision";
import { mergeLogDelta, parseLogPollResponse } from "./log-poll";

function logsCacheKey(apiBase: string): string {
return `ocx.logs.list.v1:${apiBase}`;
Expand Down Expand Up @@ -379,6 +380,11 @@ export default function Logs({ apiBase }: { apiBase: string }) {
const logRetryRef = useRef<{ key: string; failures: number; nextAttemptAt: number; error: unknown }>(
{ key: resourceKey, failures: 0, nextAttemptAt: 0, error: null },
);
const logPollRef = useRef<{
key: string;
cursor: string | null;
rows: LogEntry[];
}>({ key: resourceKey, cursor: null, rows: cachedLogs ?? [] });
const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang;
// The proxy's own zone, so timestamps read the same as the server's logs rather than being
// silently shifted into the viewer's zone (#725). Fetched once: it cannot change while the
Expand Down Expand Up @@ -433,15 +439,40 @@ export default function Logs({ apiBase }: { apiBase: string }) {
logRetryRef.current = retry;
}
if (retry.failures > 0 && Date.now() < retry.nextAttemptAt) throw retry.error;

let pollState = logPollRef.current;
if (pollState.key !== resourceKey) {
pollState = { key: resourceKey, cursor: null, rows: readSessionListCache(resourceKey) ?? [] };
logPollRef.current = pollState;
}

const currentCursor = pollState.cursor;
const url = currentCursor
? `${apiBase}/api/logs?limit=2000&cursor=${encodeURIComponent(currentCursor)}`
: `${apiBase}/api/logs?limit=2000`;

try {
const res = await fetch(`${apiBase}/api/logs?limit=2000`, { signal });
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim());
const body = await res.json() as LogEntry[] | { logs?: LogEntry[] };
const raw = Array.isArray(body) ? body : (body.logs ?? []);
const next = raw.map(sanitizeLogEntryRouteDecision);
const body = await res.json();
const parsed = parseLogPollResponse<LogEntry>(body);
const sanitizedIncoming = parsed.rows.map(sanitizeLogEntryRouteDecision);

let nextRows: LogEntry[];
if (currentCursor && parsed.cursorCapable && !parsed.reset) {
nextRows = mergeLogDelta(pollState.rows, sanitizedIncoming, 2000);
} else {
nextRows = sanitizedIncoming;
}

logPollRef.current = {
key: resourceKey,
cursor: parsed.cursor,
rows: nextRows,
};
logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null };
writeSessionListCache(resourceKey, next);
return next;
writeSessionListCache(resourceKey, nextRows);
return nextRows;
} catch (error) {
if (signal.aborted) throw error;
const normalized = error ?? new Error("log request failed");
Expand Down Expand Up @@ -473,6 +504,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {
const fetchLogs = logsResource.refresh;
const retryLogs = useCallback(() => {
logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null };
logPollRef.current = { key: resourceKey, cursor: null, rows: logPollRef.current.rows };
fetchLogs({ forceLoading: true });
}, [fetchLogs, resourceKey]);

Expand Down
54 changes: 54 additions & 0 deletions gui/src/pages/log-poll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export interface ParsedLogPollResponse<T> {
rows: T[];
cursor: string | null;
reset: boolean;
cursorCapable: boolean;
}

export function parseLogPollResponse<T>(body: unknown): ParsedLogPollResponse<T> {
if (Array.isArray(body)) {
return {
rows: body as T[],
cursor: null,
reset: false,
cursorCapable: false,
};
}
if (typeof body === "object" && body !== null) {
const candidate = body as { logs?: unknown; cursor?: unknown; reset?: unknown };
const rows = Array.isArray(candidate.logs) ? (candidate.logs as T[]) : [];
const cursor = typeof candidate.cursor === "string" && candidate.cursor.trim() ? candidate.cursor.trim() : null;
const reset = candidate.reset === true;
const cursorCapable = cursor !== null || reset || Object.hasOwn(candidate, "cursor") || Object.hasOwn(candidate, "reset");
return {
rows,
cursor,
reset,
cursorCapable,
};
}
return {
rows: [],
cursor: null,
reset: false,
cursorCapable: false,
};
}

export function mergeLogDelta<T extends { requestId?: string }>(
previous: readonly T[],
incoming: readonly T[],
cap = 2000,
): T[] {
if (incoming.length === 0) {
return previous.length > cap ? previous.slice(previous.length - cap) : [...previous];
}
const incomingIds = new Set(
incoming
.map(row => row.requestId)
.filter((id): id is string => typeof id === "string" && id.length > 0),
);
const filteredPrev = previous.filter(row => !row.requestId || !incomingIds.has(row.requestId));
const merged = [...filteredPrev, ...incoming];
return merged.length > cap ? merged.slice(merged.length - cap) : merged;
}
73 changes: 73 additions & 0 deletions gui/tests/log-poll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import { mergeLogDelta, parseLogPollResponse } from "../src/pages/log-poll";

describe("log-poll helpers", () => {
test("merge appends delta rows, replaces duplicate ids, and keeps newest cap", () => {
expect(mergeLogDelta(
[{ requestId: "a", value: 1 }, { requestId: "b", value: 1 }],
[{ requestId: "b", value: 2 }, { requestId: "c", value: 1 }],
2,
)).toEqual([{ requestId: "b", value: 2 }, { requestId: "c", value: 1 }]);
});

test("merge preserves all rows when under cap and no duplicates", () => {
expect(mergeLogDelta(
[{ requestId: "a", value: 1 }],
[{ requestId: "b", value: 2 }],
10,
)).toEqual([{ requestId: "a", value: 1 }, { requestId: "b", value: 2 }]);
});

test("merge returns previous capped rows when incoming is empty", () => {
expect(mergeLogDelta(
[{ requestId: "a", value: 1 }, { requestId: "b", value: 2 }],
[],
2,
)).toEqual([{ requestId: "a", value: 1 }, { requestId: "b", value: 2 }]);
});

test("legacy array and object bodies are full snapshots", () => {
expect(parseLogPollResponse([{ requestId: "a" }])).toEqual({
rows: [{ requestId: "a" }],
cursor: null,
reset: false,
cursorCapable: false,
});
expect(parseLogPollResponse({ logs: [{ requestId: "a" }] })).toEqual({
rows: [{ requestId: "a" }],
cursor: null,
reset: false,
cursorCapable: false,
});
});

test("cursor response preserves reset metadata and cursorCapable flag", () => {
expect(parseLogPollResponse({ logs: [], cursor: "opaque", reset: true })).toEqual({
rows: [],
cursor: "opaque",
reset: true,
cursorCapable: true,
});
expect(parseLogPollResponse({ logs: [{ requestId: "x" }], cursor: "c-1", reset: false })).toEqual({
rows: [{ requestId: "x" }],
cursor: "c-1",
reset: false,
cursorCapable: true,
});
});

test("malformed response returns empty rows and cursorCapable false", () => {
expect(parseLogPollResponse(null)).toEqual({
rows: [],
cursor: null,
reset: false,
cursorCapable: false,
});
expect(parseLogPollResponse("string")).toEqual({
rows: [],
cursor: null,
reset: false,
cursorCapable: false,
});
});
});
77 changes: 77 additions & 0 deletions gui/tests/logs-auto-refresh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,3 +540,80 @@ test("Logs: an intercepted helper row is badged and filterable", async () => {

await act(async () => { root.unmount(); });
});

test("Logs: incremental cursor delta polling, delta append, empty delta, reset, and legacy fallback", async () => {
const calls: string[] = [];
let pollStep = 0;

globalThis.fetch = (async (input) => {
const url = String(input);
calls.push(url);
if (!url.includes("/api/logs")) return new Response(null, { status: 404 });

if (pollStep === 0) {
// Step 0: Initial fetch -> returns initial log and cursor
return jsonResponse({ logs: [sampleLog], cursor: "cursor-0", reset: false });
}
if (pollStep === 1) {
// Step 1: Empty delta -> cursor unchanged, keep existing rows
return jsonResponse({ logs: [], cursor: "cursor-0", reset: false });
}
if (pollStep === 2) {
// Step 2: Delta with new row -> append updatedLog
return jsonResponse({ logs: [updatedLog], cursor: "cursor-1", reset: false });
}
if (pollStep === 3) {
// Step 3: Eviction / reset: true -> replaces list
const resetLog = { ...sampleLog, requestId: "req-reset", model: "gpt-reset" };
return jsonResponse({ logs: [resetLog], cursor: "cursor-reset", reset: true });
}
if (pollStep === 4) {
// Step 4: Legacy server returns array without cursor
const legacyLog = { ...sampleLog, requestId: "req-legacy", model: "gpt-legacy" };
return jsonResponse([legacyLog]);
}
// Step 5: Next poll after legacy response must be full request without cursor
return jsonResponse([sampleLog]);
}) as typeof fetch;

const { root, container } = await mountLogs();

await flushMicrotasks();
expectTableLoaded(container, "gpt-test");
const logCalls = () => calls.filter(u => u.includes("/api/logs"));
expect(logCalls()[0]).toBe("http://localhost/api/logs?limit=2000");

// Advance to Step 1 (Empty delta)
pollStep = 1;
await advanceSilentRefresh(2000);
expect(logCalls().at(-1)).toContain("cursor=cursor-0");
expectTableLoaded(container, "gpt-test");

// Advance to Step 2 (Delta with updatedLog)
pollStep = 2;
await advanceSilentRefresh(2000);
expect(logCalls().at(-1)).toContain("cursor=cursor-0");
expectTableLoaded(container, "gpt-test");
expect(container.textContent).toContain("gpt-updated");

// Advance to Step 3 (Reset replaces whole list)
pollStep = 3;
await advanceSilentRefresh(2000);
expect(logCalls().at(-1)).toContain("cursor=cursor-1");
expectTableLoaded(container, "gpt-reset");
expect(container.textContent).not.toContain("gpt-updated");

// Advance to Step 4 (Legacy server response)
pollStep = 4;
await advanceSilentRefresh(2000);
expect(logCalls().at(-1)).toContain("cursor=cursor-reset");
expectTableLoaded(container, "gpt-legacy");

// Advance to Step 5 (Next poll after legacy response has no cursor)
pollStep = 5;
await advanceSilentRefresh(2000);
expect(logCalls().at(-1)).toBe("http://localhost/api/logs?limit=2000");
expectTableLoaded(container, "gpt-test");

await act(async () => { root.unmount(); });
});
14 changes: 13 additions & 1 deletion src/server/management/logs-usage-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
import { drainAndShutdown } from "../lifecycle";
import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries, type RequestLogEntry } from "../request-log";
import { decodeRequestLogCursor, encodeRequestLogCursor, sliceRequestLogsAfterCursor } from "../request-log-cursor";
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
import { userCostOverlayVersion } from "../../usage/user-cost-overlays";
import type { PersistedUsageAttempt } from "../../usage/log";
Expand Down Expand Up @@ -133,12 +134,23 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res

if (url.pathname === "/api/logs" && req.method === "GET") {
const all = getRequestLogEntries();
const rawCursor = url.searchParams.get("cursor");
const decoded = rawCursor === null ? null : decodeRequestLogCursor(rawCursor);
if (rawCursor !== null && decoded === null) {
return jsonResponse({ error: { code: "invalid_cursor", message: "invalid cursor" } }, 400, req, config);
}
const delta = decoded
? sliceRequestLogsAfterCursor(all, decoded)
: { entries: all, reset: false };
const total = filteredRequestLogCount(all, url.searchParams);
const logs = filterRequestLogs(all, url.searchParams);
const logs = filterRequestLogs(delta.entries, url.searchParams);
const newest = all.at(-1);
return jsonResponse({
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
total,
logs: logs.map(requestLogDto),
...(newest ? { cursor: encodeRequestLogCursor(newest) } : {}),
reset: delta.reset,
});
}

Expand Down
58 changes: 58 additions & 0 deletions src/server/request-log-cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { RequestLogEntry } from "./request-log";

export interface RequestLogCursor {
timestamp: number;
requestId: string;
}

interface SerializedCursorV1 {
v: 1;
t: number;
id: string;
}

export function encodeRequestLogCursor(
entry: Pick<RequestLogEntry, "timestamp" | "requestId">,
): string {
const payload: SerializedCursorV1 = {
v: 1,
t: entry.timestamp,
id: entry.requestId,
};
return Buffer.from(JSON.stringify(payload)).toString("base64url");
}

export function decodeRequestLogCursor(raw: string): RequestLogCursor | null {
if (typeof raw !== "string" || !raw || raw.length > 512) return null;
try {
const json = Buffer.from(raw, "base64url").toString("utf8");
const parsed = JSON.parse(json);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
if (parsed.v !== 1) return null;
if (typeof parsed.t !== "number" || !Number.isFinite(parsed.t) || parsed.t < 0) return null;
if (typeof parsed.id !== "string" || parsed.id.length === 0 || parsed.id.length > 256) return null;
return { timestamp: parsed.t, requestId: parsed.id };
} catch {
return null;
}
}

export function sliceRequestLogsAfterCursor(
logs: readonly RequestLogEntry[],
cursor: RequestLogCursor,
): { entries: RequestLogEntry[]; reset: boolean } {
if (logs.length === 0) return { entries: [], reset: true };
// Search from the end towards the beginning since recent cursors are near the end
let matchIndex = -1;
for (let i = logs.length - 1; i >= 0; i--) {
const entry = logs[i];
if (entry && entry.timestamp === cursor.timestamp && entry.requestId === cursor.requestId) {
matchIndex = i;
break;
}
}
if (matchIndex === -1) {
return { entries: [...logs], reset: true };
}
return { entries: logs.slice(matchIndex + 1), reset: false };
}
Loading
Loading