Skip to content

Commit 6baff0c

Browse files
author
weiesky.wangc
committed
feat(log-list): perf cache + server-side pagination + project switcher (1.7.13)
Session log list performance and UX overhaul for the log-management modal: - perf: session row cache (server/lib/v2/session-list.js) keyed by journal+prompts size+mtime; repeat /api/local-logs calls drop from a full rescan to O(N statSync). migrationStatus gains a 10s TTL memo, invalidated on convert-worker completion. - feat: server-side pagination — GET /api/local-logs?page=&pageSize= returns {items,total,page,pageSize} for one project, summarizing only the requested page (rest ride the row cache); the modal table is a controlled 50/page server-paginated grid. No params = legacy grouped shape (backward compatible). - feat: project switcher — ?project= serves any project's v2 sessions (strict sanitizePathComponent compare rejects traversal); response carries _allProjects for the toolbar dropdown. currentProject (active) stays separate from logViewProject (viewed); viewing a non-active project disables the v1 migration button and hides the banner; switching clears the selection. - ui: fixed-height modal content box + pinned pager (no bounce on load); mobile list scrolls vertically so deeper pages stay reachable. - fix: _currentProject always reports the active project (_viewedProject reports the listed one) so viewing a project can't pollute global state; fetch failures reset localLogsLoading; _allProjects skips dot-dirs. i18n: ui.logsTotal / ui.selectProject / ui.migrateCurrentProjectOnly (18 langs). Tests: v2-session-list (row cache + listV2LogsPage incl. post-filter paging), api-logs-gap (?project= incl. traversal/clamps/fallback), TTL memo + hook.
1 parent e932bbe commit 6baff0c

21 files changed

Lines changed: 1337 additions & 186 deletions

history.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 1.7.13 (2026-08-01)
4+
5+
- perf(log-list): **session list row cache**`GET /api/local-logs` re-scanned every session on every call (full journal fold + recursive dir walk + 256KB prompts head read per session, all sync). With 100+ sessions this blocked the event loop for seconds. New `server/lib/v2/session-list.js` caches per-session rows keyed by journal+prompts size+mtime; repeat calls drop to O(N statSync + 1 readdir) ≈ 1-3ms. All existing gates (wireFormat, sentinel, discard, error→keep) preserved verbatim. Cross-review hardening (2026-07-31): freshness key spans prompts.jsonl (written after the journal line / backfilled on crash-resume), O(N²) prune → Set, delivered rows re-copy nested preview/leader so callers can't poison the cache.
6+
- perf(log-list): **migrationStatus 10s TTL memo**`migrationStatus` was called on every SSE connect, every list refresh, and every workspace boot, each time stat-scanning all v1 files of all projects. A per-(logDir, project) 10s TTL memo collapses repeat calls to a Map lookup; the convert manager invalidates on worker completion so a finished conversion is reflected immediately.
7+
- test(log-list): **TTL memo + invalidation hook behavior coverage** — injectable clock drives the 10s window (serve within TTL / recompute after expiry / per-project `_invalidate`); the convert-manager test pre-fills a stale memo verdict and asserts the worker's `final` message drops it so the next call re-scans. Removed a tautological key-collision test; the stale/recompute contract is pinned observably instead (meta change invisible to the cache until the next journal append; prompts append alone triggers recompute).
8+
- feat(log-list): **server-side pagination for the log modal**`GET /api/local-logs?page=&pageSize=` returns `{items, total, page, pageSize}` for the current project, summarizing only the requested page's sessions (rest ride the row cache). New `listV2LogsPage` (log-management) + `summarizeSessionPage` (session-list); the modal's v2 table is now a controlled server-paginated grid (50/page) instead of one unpaged fetch. Selection (`selectedLogs`) is unchanged — a cross-page Set feeding the existing per-file delete whitelist. No params = legacy grouped shape (backward compatible); v1 view untouched.
9+
- feat(log-list): **project switcher in the log modal** — the modal toolbar gains a project dropdown to view other projects' v2 session logs without switching the active workspace. `GET /api/local-logs?page=&project=` serves any project (strict `sanitizePathComponent` compare rejects `..`/separators → 400); the response carries `_allProjects` for the dropdown. Frontend keeps two project concepts separate: `currentProject` (active, drives migration counts/banner) vs new `logViewProject` (viewed); viewing a non-active project disables the v1-migration button + hides the unmigrated banner, and switching clears the selection. Alias-aware labels (`alias (dir)`). Desktop only.
10+
- ui(log-list): **fixed-height modal content + pinned pager** — the v2 table's content box is a fixed height (desktop `min(600px, 100vh-280px)`) so swapping the table for a spinner no longer bounces the modal; antd `scroll.y` keeps the header sticky and pins the pager to the box bottom; `hideOnSinglePage` hides the pager when only one page exists.
11+
312
## 1.7.12 (2026-07-30)
413

514
- ui(avatar): **refresh the Kimi model logo** — new mark with the brand-blue dot kept fixed (`#1783FF`) and the "K" glyph switched to `currentColor` so it follows `--model-logo-mono` (white in dark mode, black in light mode), matching the GLM/MiniMax mono-logo theming.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cc-viewer",
3-
"version": "1.7.12",
3+
"version": "1.7.13",
44
"description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
55
"license": "MIT",
66
"main": "server.js",

server/lib/log-management.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { join, sep, dirname, basename } from 'node:path';
44
import { reconstructEntries } from './delta-reconstructor.js';
55
import { sanitizePathComponent } from './v2/layout.js';
66
import { listV2Sessions } from './v2/adapter.js';
7+
import { summarizeSessionPage } from './v2/session-list.js';
8+
import { listSessionIds } from './v2/replay.js';
79

810
// wire-v2 S5 addressing (spec §12): 'v2:<project>/<session_id>' in every
911
// existing ?file= parameter slot. Components must survive the same whitelist
@@ -122,6 +124,61 @@ export function listV2Logs(logDir, currentProjectName) {
122124
return { ...grouped, _currentProject: currentProjectName || '' };
123125
}
124126

127+
/**
128+
* Server-side paginated v2 log list for ONE project (2026-07-31). The modal
129+
* only ever renders the current project's sessions, so instead of summarizing
130+
* every session up front we page: enumerate session dirs (readdir — cheap),
131+
* read each meta.json only for the startTs ordering + leader filter, sort
132+
* newest-first, then run the EXPENSIVE summarize (journal fold + dir walk +
133+
* prompts head) on just the `pageSize` sessions of the requested page — those
134+
* go through the same row cache as listV2Sessions, so revisiting a page is
135+
* ~1-3ms. Trade-off documented inline: `size==0` and `discard` verdicts live
136+
* behind that summarize, so `total` is the pre-filter session count (the empty
137+
* / quota-probe sessions are excluded as their pages are computed). For
138+
* realistic data (empties + probes are a small minority) this keeps cold open
139+
* at ~1 page of work instead of N.
140+
*
141+
* @returns {{items: Array, total: number, page: number, pageSize: number}}
142+
* items rows keep the exact listV2Logs shape {file, kind, timestamp, size, turns, preview}.
143+
*/
144+
export function listV2LogsPage(logDir, project, { page = 1, pageSize = 50 } = {}) {
145+
const out = { items: [], total: 0, page, pageSize, _currentProject: project || '' };
146+
if (!project) return out;
147+
const projectDir = join(logDir, project);
148+
if (!existsSync(projectDir)) return out;
149+
150+
// Cheap pass: order candidates by startTs without paying per-session folds.
151+
const candidates = [];
152+
for (const dirName of listSessionIds(projectDir)) {
153+
let meta = null;
154+
try { meta = JSON.parse(readFileSync(join(projectDir, 'sessions', dirName, 'meta.json'), 'utf-8')); } catch { /* journal is self-describing */ }
155+
if (meta && meta.leader) continue; // teammate — folded into its leader's row
156+
candidates.push({ dirName, startTs: (meta && meta.startTs) || '' });
157+
}
158+
// Newest first; dirName tiebreak matches listV2Logs' file tiebreak for stability.
159+
candidates.sort((a, b) => b.startTs.localeCompare(a.startTs) || b.dirName.localeCompare(a.dirName));
160+
out.total = candidates.length;
161+
162+
const start = (page - 1) * pageSize;
163+
for (const c of candidates.slice(start, start + pageSize)) {
164+
let s = null;
165+
try { s = summarizeSessionPage(projectDir, c.dirName); } catch { continue; }
166+
if (!s) continue;
167+
if (s.leader) continue;
168+
if (s.size === 0) continue;
169+
if (s.discard) continue; // quota-probe orphans: never listed
170+
out.items.push({
171+
file: `v2:${project}/${s.sid}`,
172+
kind: 'v2',
173+
timestamp: compactLocalTs(s.startTs),
174+
size: s.size,
175+
turns: s.turns,
176+
preview: s.preview || [],
177+
});
178+
}
179+
return out;
180+
}
181+
125182
/**
126183
* 1.7.0 v1 view: list legacy v1 `.jsonl` files, grouped per project — same row
127184
* shape as listV2Logs so LogTable renders both views unchanged. Every

server/lib/v2/adapter.js

Lines changed: 9 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@
2727
// (sessionId, seq) tie-break — field-equivalent to v1's "teammate writes the
2828
// leader's file".
2929

30-
import { existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
30+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3131
import { join, dirname, basename } from 'node:path';
3232
import { reportSwallowed } from '../error-report.js';
3333
import { isMainAgentRequest } from '../interceptor-core.js';
34-
import { readPromptsHead, collectPromptsFromEvents } from '../user-prompt-extract.js';
35-
import { readSession, readJsonlTolerant, listSessionIds } from './replay.js';
34+
import { readSession } from './replay.js';
3635
import { iterateJsonlLines } from './jsonl-read.js';
3736
import { isDiscardableSession } from './session-select.js';
38-
import { blobPath, isSupportedWireFormat, dirSizeSync } from './layout.js';
37+
import { blobPath, isSupportedWireFormat } from './layout.js';
3938
import { SingleFlight } from './singleflight.js';
39+
import { listV2Sessions } from './session-list.js';
4040

4141
// Same stamping rules as the v1 interceptor (KEEP IN SYNC: server/interceptor.js
4242
// requestEntry construction) — recomputed from the journal's url, not from kind,
@@ -1056,109 +1056,8 @@ export async function streamV2WindowedEntries(sessionDir, opts, onEntry) {
10561056

10571057
// ─── session listing (spec §12, list entry pulled forward from S6a) ─────────
10581058

1059-
/** Bounded head read: parse the FIRST JSONL line of a file without loading the
1060-
* whole thing (a main conversation's opening snapshot can be multi-MB; the
1061-
* list only wants a preview). Returns null on any shortfall. */
1062-
function readFirstJsonLine(path, budget = 256 * 1024) {
1063-
let fd;
1064-
try {
1065-
fd = openSync(path, 'r');
1066-
const buf = Buffer.alloc(budget);
1067-
const n = readSync(fd, buf, 0, budget, 0);
1068-
const head = buf.toString('utf-8', 0, n);
1069-
const nl = head.indexOf('\n');
1070-
if (nl <= 0) return null; // no complete first line inside the budget
1071-
return JSON.parse(head.slice(0, nl));
1072-
} catch {
1073-
return null;
1074-
} finally {
1075-
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
1076-
}
1077-
}
1078-
1079-
/**
1080-
* Summarize every session under LOG_DIR/<project>/ for the log list (spec §12).
1081-
* Deliberately cheap: journal lines only (small) + a bounded head read of the
1082-
* main conversation's first epoch for the preview — conversation bodies are
1083-
* never loaded. Teammate linkage is surfaced via `leader` so the caller can
1084-
* fold those sessions into their leader's view instead of double-listing.
1085-
* @returns {Array<{sid, dir, startTs, leader, turns, size, preview}>}
1086-
*/
1087-
export function listV2Sessions(projectDir) {
1088-
const out = [];
1089-
for (const sid of listSessionIds(projectDir)) {
1090-
try {
1091-
const dir = join(projectDir, 'sessions', sid);
1092-
if (!existsSync(join(dir, 'journal.jsonl'))) continue;
1093-
let meta = null;
1094-
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { /* tolerated — journal is self-describing */ }
1095-
if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
1096-
// Reader version gate (spec §14): don't list a session this build
1097-
// can't read — a garbage preview/turn-count is worse than absence.
1098-
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${meta.wireFormat}`));
1099-
continue;
1100-
}
1101-
1102-
// turns = main requests that completed (journal two-phase fold). The
1103-
// journal sentinel is checked in the same pass: per §14 the per-file
1104-
// sentinel WINS over meta.json, and readSession/adapter refuse such a
1105-
// session — listing it would show a phantom row that opens empty.
1106-
const reqKind = new Map();
1107-
let turns = 0;
1108-
let sentinelVersion = null;
1109-
let hasMainOrTeammate = false;
1110-
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
1111-
if (line.ph === 'req') {
1112-
reqKind.set(line.seq, line.kind);
1113-
if (line.kind === 'main' || line.kind === 'teammate') hasMainOrTeammate = true;
1114-
}
1115-
else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
1116-
turns++;
1117-
reqKind.delete(line.seq); // fold duplicate done lines (§14)
1118-
} else if (line.ph === 'meta' && typeof line.wireFormat === 'number' && !isSupportedWireFormat(line.wireFormat)) {
1119-
sentinelVersion = line.wireFormat;
1120-
break;
1121-
}
1122-
}
1123-
if (sentinelVersion != null) {
1124-
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${sentinelVersion} (journal sentinel)`));
1125-
continue;
1126-
}
1127-
1128-
// preview = ALL user prompts of the session, from the prompts.jsonl
1129-
// display cache (written by V2Writer / the converter; bounded head read
1130-
// so the list stays O(budget) per session). Sessions predating the
1131-
// cache fall back to the first epoch's first line — routed through the
1132-
// shared extractor so command/caveat chrome never leaks into the row.
1133-
let preview = readPromptsHead(join(dir, 'prompts.jsonl'));
1134-
if (preview.length === 0) {
1135-
const first = readFirstJsonLine(join(dir, 'conversations', 'main', 'e0.jsonl'));
1136-
if (first && Array.isArray(first.msgs)) {
1137-
preview = collectPromptsFromEvents([first]);
1138-
}
1139-
}
1140-
1141-
out.push({
1142-
sid,
1143-
dir,
1144-
startTs: (meta && meta.startTs) || '',
1145-
leader: (meta && meta.leader) || null,
1146-
turns,
1147-
size: dirSizeSync(dir),
1148-
preview,
1149-
// Discardable-session verdict. KEEP IN SYNC: session-select.js
1150-
// isDiscardableSession is the canonical rule; this fold pre-computes
1151-
// it for free over the FULL journal (the canonical scan is 8MB-
1152-
// budgeted — intentional asymmetry, a first main sits at the head).
1153-
// When the fold says discard, the canonical predicate CONFIRMS it:
1154-
// readJsonlTolerant swallows an I/O error (Windows EBUSY/EPERM lock)
1155-
// into zero lines, which must KEEP the session, not hide it — the
1156-
// canonical path carries that error→keep direction (ioErrorResult).
1157-
// Main-bearing sessions never pay the extra read; probe journals are
1158-
// ~3 lines.
1159-
discard: !(meta && meta.leader) && !hasMainOrTeammate && isDiscardableSession(dir, meta),
1160-
});
1161-
} catch { /* one unreadable session must not break the list */ }
1162-
}
1163-
return out;
1164-
}
1059+
// listV2Sessions is re-exported from session-list.js (P0-A row cache, 2026-07-31).
1060+
// The full per-session summarization logic (incl. the readFirstJsonLine preview
1061+
// fallback) moved there; this re-export keeps the public API unchanged for all
1062+
// callers (log-management.js, routes/im.js, tests).
1063+
export { listV2Sessions };

server/lib/v2/convert-manager.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { Worker } from 'node:worker_threads';
1111
import { join } from 'node:path';
1212
import { readConvertState } from './convert.js';
13+
import { _invalidate as _invalidateMigrationStatus } from './migrate-prompt.js';
1314

1415
let _running = null; // { project, logDir, worker, startedAt, progress }
1516
let _lastError = null; // last worker-level failure (state file may lag on hard crashes)
@@ -36,7 +37,12 @@ export function startConvert(logDir, project) {
3637
worker.on('message', (msg) => {
3738
if (!msg || !_running || _running.worker !== worker) return;
3839
if (msg.type === 'progress') _running.progress = msg.progress;
39-
else if (msg.type === 'final' && msg.error) _lastError = msg.error;
40+
else if (msg.type === 'final') {
41+
if (msg.error) _lastError = msg.error;
42+
// A finished conversion immediately changes what migrationStatus returns
43+
// for this project — drop the memo so the next call re-scans.
44+
_invalidateMigrationStatus(_running.logDir, msg.project);
45+
}
4046
});
4147
worker.on('error', (err) => {
4248
_lastError = String(err && err.message || err);

server/lib/v2/migrate-prompt.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,23 @@
55
// pending unless the convert state marks it done AT ITS CURRENT SIZE (the
66
// converter's trust rule, convert.js), because the converter never deletes
77
// v1 sources ("files exist" alone is not "migration needed").
8+
//
9+
// P0-B (2026-07-31): 10s TTL memo per (logDir, project). migrationStatus is
10+
// called on every SSE connect (events.js), every list refresh (logs.js), and
11+
// every workspace boot — each call stat-scans ALL v1 files of ALL projects.
12+
// The memo collapses repeat calls to a Map lookup. `now` is injectable for
13+
// tests (same house style as singleflight.js). Invalidation hook: the convert
14+
// manager calls _invalidate() when its worker posts {type:'final'} (bypasses
15+
// the 1s progress throttle), so a finished conversion is reflected immediately.
816
import { statSync } from 'node:fs';
917
import { join } from 'node:path';
1018
import { listV1Files, listConvertibleProjects, readConvertState } from './convert.js';
1119

20+
const TTL_MS = 10_000;
21+
/** @type {Map<string, {value: object, expiresAt: number}>} */
22+
const _memo = new Map();
23+
let _now = Date.now;
24+
1225
/** Pending v1 files + bytes of ONE project dir. */
1326
function pendingOf(projectDir) {
1427
const state = readConvertState(projectDir);
@@ -36,22 +49,47 @@ function pendingOf(projectDir) {
3649
/**
3750
* Migration status of one project (plus how many OTHER projects also have
3851
* pending v1 logs — the prompt mentions `ccv convert --all` for those).
52+
* Memoized for TTL_MS per (logDir, project); see module header for the
53+
* invalidation contract.
3954
* @param {string} logDir - LOG_DIR root
4055
* @param {string} project - project directory name ('' → not pending)
4156
* @returns {{pending: boolean, files: number, totalBytes: number, otherProjects: number}}
4257
*/
4358
export function migrationStatus(logDir, project) {
4459
const empty = { pending: false, files: 0, totalBytes: 0, otherProjects: 0 };
4560
if (!logDir || !project) return empty;
61+
const cacheKey = `${logDir}\0${project}`;
62+
const cached = _memo.get(cacheKey);
63+
if (cached && _now() < cached.expiresAt) return cached.value;
4664
try {
4765
const { files, totalBytes } = pendingOf(join(logDir, project));
4866
let otherProjects = 0;
4967
for (const p of listConvertibleProjects(logDir)) {
5068
if (p === project) continue;
5169
if (pendingOf(join(logDir, p)).files > 0) otherProjects++;
5270
}
53-
return { pending: files > 0, files, totalBytes, otherProjects };
71+
const value = { pending: files > 0, files, totalBytes, otherProjects };
72+
_memo.set(cacheKey, { value, expiresAt: _now() + TTL_MS });
73+
return value;
5474
} catch {
5575
return empty;
5676
}
5777
}
78+
79+
/** Drop the memo for one (or all) projects — called by the convert manager
80+
* when a conversion worker finishes, and by tests. */
81+
export function _invalidate(logDir, project) {
82+
if (logDir === undefined) { _memo.clear(); return; }
83+
_memo.delete(`${logDir}\0${project}`);
84+
}
85+
86+
/** Test hook: replace the clock (pass `() => t`); call without args to restore. */
87+
export function _setNowForTest(fn) {
88+
_now = fn || Date.now;
89+
}
90+
91+
/** Test hook: drop all memoized entries. */
92+
export function _resetForTest() {
93+
_memo.clear();
94+
_now = Date.now;
95+
}

0 commit comments

Comments
 (0)