cc-viewer 1.7.3 → 1.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +88 -17
- package/dist/assets/App-CCDFxj11.css +1 -0
- package/dist/assets/App-CcLQlV-O.js +2 -0
- package/dist/assets/{MdxEditorPanel-D_dewIIc.js → MdxEditorPanel-CAqHHy1X.js} +1 -1
- package/dist/assets/Mobile-BxRd4DkS.js +1 -0
- package/dist/assets/UnifiedProxyRetryPage-CFz4vOH5.css +1 -0
- package/dist/assets/UnifiedProxyRetryPage-DjL3NQXO.js +1 -0
- package/dist/assets/{_baseUniq-D9_E5ZDp.js → _baseUniq-yiNE5Ckw.js} +1 -1
- package/dist/assets/{arc-D4iFrlhb.js → arc-M-eBYYbN.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-f1neo7hg.js → architectureDiagram-Q4EWVU46-CtweeLKi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DWi9e9tG.js → blockDiagram-DXYQGD6D-DCVQ-go_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DAdj49A2.js → c4Diagram-AHTNJAMY-C7JB9PqP.js} +1 -1
- package/dist/assets/{channel-B3VthT0m.js → channel-BnzX9zBS.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BrPcAHV3.js → chunk-4BX2VUAB-dVKiUtqe.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-Bvopc1V9.js → chunk-4TB4RGXK-CQjD9-fD.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Juw-7Z3i.js → chunk-55IACEB6-BZuKI6JQ.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DVYP9tpB.js → chunk-EDXVE4YY-sk9g7szp.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-VQ7VOXl3.js → chunk-FMBD7UC4-CGL9Lo51.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-F8Rj4ZUZ.js → chunk-OYMX7WX6-DaN0AdQD.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Dn7K3yi9.js → chunk-QZHKN3VN-CNEAHnZV.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-UjO-KTsn.js → chunk-YZCP3GAM-BOlts_Dm.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CumOMdO7.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CumOMdO7.js +1 -0
- package/dist/assets/clone-C3SZDzs1.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BDhNj8rq.js → cose-bilkent-S5V4N54A-DH4_YiGi.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BzdcDuyt.js → dagre-KV5264BT-RqusStYI.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DP_SX65N.js → diagram-5BDNPKRD-8haVbBnt.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-B-pBFw8Q.js → diagram-G4DWMVQ6-CsrJNUhT.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-6XSfN1eN.js → diagram-MMDJMWI5-1luPOM38.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-DJvGxvpE.js → diagram-TYMM5635-Dad1XR3W.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CpqdpGYj.js → erDiagram-SMLLAGMA-Br6-kbp9.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C9m7wjwI.js → flowDiagram-DWJPFMVM-DuX1mbA7.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CU0XOfY3.js → ganttDiagram-T4ZO3ILL-BL_XQ958.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-ipyGULnP.js → gitGraphDiagram-UUTBAWPF-DzvW5FEs.js} +1 -1
- package/dist/assets/{graph-BR2KmES9.js → graph-CbUmu0Oe.js} +1 -1
- package/dist/assets/{index-D5tSeMU7.js → index-B3-JUU7J.js} +1 -1
- package/dist/assets/{index--wgRW_OB.js → index-BgW_nP_0.js} +1 -1
- package/dist/assets/{index-OWzSX2T8.js → index-CzPHj3iF.js} +1 -1
- package/dist/assets/{index-DuZQ6FqR.js → index-DDlAMOiV.js} +1 -1
- package/dist/assets/{index-DidP9FCD.js → index-DJ0AQwus.js} +2 -2
- package/dist/assets/{index-D5YruOKD.js → index-Do5xNTOT.js} +1 -1
- package/dist/assets/{index-C-be29ey.js → index-LjVy-Pzg.js} +1 -1
- package/dist/assets/{index-C16fiVhv.js → index-vImWXiMM.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-BQWQjfFX.js → infoDiagram-42DDH7IO-DGsgRji7.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js → ishikawaDiagram-UXIWVN3A-D3odpv9_.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CMbB_4G9.js → journeyDiagram-VCZTEJTY-Dj3NaOWF.js} +1 -1
- package/dist/assets/{jszip.min-B6k2t9Ch.js → jszip.min-GmVScWAV.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-CImdlKN3.js → kanban-definition-6JOO6SKY-GKjJaLwD.js} +1 -1
- package/dist/assets/{layout-DQl4aw09.js → layout-Bhk66H0F.js} +1 -1
- package/dist/assets/{linear-CLD3aPlK.js → linear-CLk9nvqn.js} +1 -1
- package/dist/assets/mermaid.core-DH-GfeNS.js +7 -0
- package/dist/assets/{min-_D86henJ.js → min-DQftOGfo.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-2JVcu1nk.js → mindmap-definition-QFDTVHPH-DgDD6Jbu.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Dik-m5u7.js → pieDiagram-DEJITSTG-jy1yFaxA.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-Bm5l40Jq.js → quadrantDiagram-34T5L4WZ-BdvMwrSD.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Ck9w5rTj.js → requirementDiagram-MS252O5E-CtBOCtbk.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BbGVCFEf.js → sankeyDiagram-XADWPNL6-DEFlQp-h.js} +1 -1
- package/dist/assets/{seqResourceLoaders-DFrHmgnK.css → seqResourceLoaders-0RXZfUKp.css} +1 -1
- package/dist/assets/seqResourceLoaders-D1y8V4z5.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-vjvEdksk.js → sequenceDiagram-FGHM5R23-0F2R8Ky0.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DTqMoiB2.js → stateDiagram-FHFEXIEX-D4xOwcSW.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js → stateDiagram-v2-QKLJ7IA2-DSf_BSF8.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cw8O-52d.js → timeline-definition-GMOUNBTQ-B5cPasy_.js} +1 -1
- package/dist/assets/{vendor-antd-DI7JL-mE.js → vendor-antd-DeqwrDxf.js} +2 -2
- package/dist/assets/{vendor-codemirror-B9c49dtM.js → vendor-codemirror-_NbrtdQc.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-B6cpBtIE.js → vendor-mdxeditor-DG0Nyxw5.js} +2 -2
- package/dist/assets/{vendor-qrcode-Dn90-s-u.js → vendor-qrcode-B3HN--HO.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CpdSKD_6.js → vendor-virtuoso-BvrezPQx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DCuqymb7.js → vennDiagram-DHZGUBPP-a_ybZDST.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BJ20wJUU.js → wardley-RL74JXVD-CKla3KF8.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Vz6US3qt.js → wardleyDiagram-NUSXRM2D-BKke2aih.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CzkLKrmi.js → xychartDiagram-5P7HB3ND-CozWtcdf.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/i18n.js +100 -55
- package/server/interceptor.js +27 -1
- package/server/lib/ccswitch-import.js +251 -0
- package/server/lib/proxy-retry.js +716 -0
- package/server/lib/proxy-stats.js +436 -0
- package/server/lib/stats-worker.js +119 -5
- package/server/proxy.js +159 -12
- package/server/routes/preferences.js +184 -1
- package/server/routes/proxy-stats.js +95 -0
- package/server/server.js +43 -1
- package/src/utils/contentFilter.js +17 -1
- package/src/utils/isProxyMode.js +11 -0
- package/dist/assets/App-DRsuJXEw.js +0 -2
- package/dist/assets/App-dYPa5-df.css +0 -1
- package/dist/assets/Mobile-BIzb7vpA.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +0 -1
- package/dist/assets/clone-BQtvy9Ae.js +0 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +0 -7
- package/dist/assets/seqResourceLoaders-BtGpfGRW.js +0 -2
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// Proxy Stats — pure functions for proxy retry statistics.
|
|
2
|
+
// Detail record schema, per-day sharding, aggregate computation (availability / percentiles / streak / by-model by-path).
|
|
3
|
+
//
|
|
4
|
+
// Design notes:
|
|
5
|
+
// - All pure functions, independently unit-testable; no I/O side effects (except appendRecord, which only writes files and reads no state).
|
|
6
|
+
// - Details are sharded per day as proxy_YYYY-MM-DD.jsonl, synonymous with llm-retry-proxy's retry_YYYY-MM-DD.jsonl.
|
|
7
|
+
// - Aggregation is done by stats-worker (Worker thread) calling aggregateRecords; the main thread only handles appendRecord.
|
|
8
|
+
// - Fully separated from cc-viewer's existing session logs (*.jsonl written by interceptor), no cross-contamination.
|
|
9
|
+
import { mkdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { AsyncWriteQueue } from './async-write-queue.js';
|
|
12
|
+
|
|
13
|
+
// ── Detail record schema ──────────────────────────────────────────────
|
|
14
|
+
// Appends one JSON line after each proxied LLM API request completes. Fields align with llm-retry-proxy's retry records.
|
|
15
|
+
//
|
|
16
|
+
// ts ISO timestamp (millisecond precision, at request completion)
|
|
17
|
+
// method HTTP method
|
|
18
|
+
// path Request path (without host, includes query)
|
|
19
|
+
// model Model name (parsed from request body.model; empty string if absent)
|
|
20
|
+
// upstream_status Last upstream response status code (0 on request error / network failure)
|
|
21
|
+
// final_status Status code returned to the client (the real last upstream failure code when retries are exhausted, e.g. 429/503; no longer hardcodes 503)
|
|
22
|
+
// attempts Total attempt count (including the first)
|
|
23
|
+
// retries Retry count = attempts - 1
|
|
24
|
+
// duration_ms Total duration (milliseconds)
|
|
25
|
+
// succeeded Whether a <400 response was ultimately received
|
|
26
|
+
// retry_codes List of upstream error codes returned during retries, e.g. [503, 503, 429]; [] when no retries
|
|
27
|
+
//
|
|
28
|
+
// Note: duration uses milliseconds (cc-viewer standardizes on ms throughout), unlike llm-retry-proxy which uses seconds.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a standardized proxy detail record. Fills in defaults and derives retries/succeeded.
|
|
32
|
+
* @param {object} r Raw fields
|
|
33
|
+
* @returns {object} Standardized record
|
|
34
|
+
*/
|
|
35
|
+
export function buildRecord(r) {
|
|
36
|
+
const attempts = Math.max(1, Number(r.attempts) || 1);
|
|
37
|
+
const retries = Math.max(0, attempts - 1);
|
|
38
|
+
const finalStatus = Number(r.finalStatus) || 0;
|
|
39
|
+
const upstreamStatus = Number(r.upstreamStatus) || 0;
|
|
40
|
+
return {
|
|
41
|
+
ts: r.ts || new Date().toISOString(),
|
|
42
|
+
method: r.method || 'POST',
|
|
43
|
+
path: r.path || '/',
|
|
44
|
+
model: typeof r.model === 'string' ? r.model : '',
|
|
45
|
+
// Profile identifier (for byProfile aggregation): defaults to 'default', compatible with the built-in max profile
|
|
46
|
+
profile_id: typeof r.profileId === 'string' && r.profileId ? r.profileId : 'default',
|
|
47
|
+
profile_name: typeof r.profileName === 'string' && r.profileName ? r.profileName : 'Default',
|
|
48
|
+
upstream_status: upstreamStatus,
|
|
49
|
+
final_status: finalStatus,
|
|
50
|
+
attempts,
|
|
51
|
+
retries,
|
|
52
|
+
duration_ms: Math.max(0, Number(r.durationMs) || 0),
|
|
53
|
+
succeeded: typeof r.succeeded === 'boolean' ? r.succeeded : finalStatus > 0 && finalStatus < 400,
|
|
54
|
+
retry_codes: Array.isArray(r.retryCodes) ? r.retryCodes.filter(c => Number.isFinite(c)) : [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Determine whether a record is "successful": final_status < 400 (2xx/3xx success, 4xx/5xx failure).
|
|
60
|
+
* Synonymous with llm-retry-proxy's _req_succeeded.
|
|
61
|
+
*/
|
|
62
|
+
export function isRecordSucceeded(r) {
|
|
63
|
+
return Number(r?.final_status) > 0 && Number(r?.final_status) < 400;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Per-day sharded detail file name: proxy_YYYY-MM-DD.jsonl
|
|
68
|
+
* @param {string} dateStr e.g. "2026-07-08"
|
|
69
|
+
*/
|
|
70
|
+
export function dailyFileName(dateStr) {
|
|
71
|
+
return `proxy_${dateStr}.jsonl`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Absolute path of the per-day sharded detail file.
|
|
76
|
+
* @param {string} projectDir Project log directory (LOG_DIR/<project>)
|
|
77
|
+
* @param {string} dateStr e.g. "2026-07-08"
|
|
78
|
+
*/
|
|
79
|
+
export function dailyFilePath(projectDir, dateStr) {
|
|
80
|
+
return join(projectDir, dailyFileName(dateStr));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Today's date string (local timezone, consistent with stats-worker's file naming convention).
|
|
85
|
+
*/
|
|
86
|
+
export function todayStr(d = new Date()) {
|
|
87
|
+
const y = d.getFullYear();
|
|
88
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
89
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
90
|
+
return `${y}-${m}-${day}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse the date string from a proxy_YYYY-MM-DD.jsonl file name. Returns null for non-proxy detail files.
|
|
95
|
+
*/
|
|
96
|
+
export function parseDailyFileName(fileName) {
|
|
97
|
+
const m = /^proxy_(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(fileName);
|
|
98
|
+
return m ? m[1] : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Append a detail record to the per-day sharded file (line-ordered, non-blocking).
|
|
103
|
+
* Creates the directory automatically on first use (mkdirSync recursive, cached).
|
|
104
|
+
*
|
|
105
|
+
* Review P2 fix: this sat on the proxy hot path (before the first response
|
|
106
|
+
* byte) doing a synchronous mkdir + appendFileSync per request. Writes now go
|
|
107
|
+
* through the shared AsyncWriteQueue (single writer, ordered, falls back to
|
|
108
|
+
* sync on process exit so records are never lost) and the mkdir runs once per
|
|
109
|
+
* directory via a process-level cache.
|
|
110
|
+
* @param {string} filePath Absolute path of the detail file
|
|
111
|
+
* @param {object} record Standardized record produced by buildRecord
|
|
112
|
+
* @param {Function} [onDone] Called after the line hits the queue's writer (tests)
|
|
113
|
+
*/
|
|
114
|
+
const _statsWriteQueue = new AsyncWriteQueue(''); // paths are always explicit (appendTo)
|
|
115
|
+
const _dirsEnsured = new Set();
|
|
116
|
+
|
|
117
|
+
export function appendRecord(filePath, record, onDone) {
|
|
118
|
+
const dir = join(filePath, '..');
|
|
119
|
+
if (!_dirsEnsured.has(dir)) {
|
|
120
|
+
try {
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
122
|
+
_dirsEnsured.add(dir);
|
|
123
|
+
} catch { /* No permission — the queued append will surface the failure as a silent no-op */ }
|
|
124
|
+
}
|
|
125
|
+
_statsWriteQueue.appendTo(filePath, JSON.stringify(record) + '\n', onDone);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Await all queued detail writes (tests / graceful shutdown). */
|
|
129
|
+
export function flushRecords() {
|
|
130
|
+
return _statsWriteQueue.flush();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Stats-update notifier (dependency inversion, review P2) ────────────────
|
|
134
|
+
// proxy.js used to dynamically import('./server.js') to reach the statsWorker
|
|
135
|
+
// singleton — if a future proxy-only process did that first, server.js's
|
|
136
|
+
// module-load side effects would boot a second viewer. The owner (server.js)
|
|
137
|
+
// now registers its notify callback here at load time; proxy.js just emits.
|
|
138
|
+
// No listener registered (server.js not loaded) → emit is a no-op.
|
|
139
|
+
let _statsUpdateListener = null;
|
|
140
|
+
|
|
141
|
+
export function setProxyStatsListener(fn) {
|
|
142
|
+
_statsUpdateListener = typeof fn === 'function' ? fn : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function emitProxyStatsUpdate(fileName) {
|
|
146
|
+
if (!_statsUpdateListener) return;
|
|
147
|
+
try {
|
|
148
|
+
_statsUpdateListener(fileName);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (process.env.CCV_DEBUG) {
|
|
151
|
+
console.error('[CC-Viewer Proxy] proxy stats notify failed:', err?.message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Aggregate computation ──────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Compute a percentile. Input must be a numerically ascending-sorted array.
|
|
160
|
+
* Uses the same nearest-rank method as llm-retry-proxy (simple, no interpolation, sufficient for discrete distributions like durations).
|
|
161
|
+
* @param {number[]} sorted Sorted array
|
|
162
|
+
* @param {number} p 0~1, e.g. 0.95
|
|
163
|
+
* @returns {number} Percentile value; returns 0 for empty array
|
|
164
|
+
*/
|
|
165
|
+
export function percentile(sorted, p) {
|
|
166
|
+
if (!Array.isArray(sorted) || sorted.length === 0) return 0;
|
|
167
|
+
if (sorted.length === 1) return sorted[0];
|
|
168
|
+
const idx = Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1);
|
|
169
|
+
return sorted[idx];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Compute the current consecutive success/failure streak and the longest failure streak.
|
|
174
|
+
* Synonymous with llm-retry-proxy's streak logic: iterate records in time order, accumulating same-type consecutive runs.
|
|
175
|
+
* @param {object[]} records Records sorted in ascending time order
|
|
176
|
+
* @returns {{ current: {type:'success'|'failure', count:number}, worstFailure: number }}
|
|
177
|
+
*/
|
|
178
|
+
export function computeStreak(records) {
|
|
179
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
180
|
+
return { current: { type: 'success', count: 0 }, worstFailure: 0 };
|
|
181
|
+
}
|
|
182
|
+
let worstFailure = 0;
|
|
183
|
+
let curType = null;
|
|
184
|
+
let curCount = 0;
|
|
185
|
+
for (const r of records) {
|
|
186
|
+
const ok = isRecordSucceeded(r);
|
|
187
|
+
const type = ok ? 'success' : 'failure';
|
|
188
|
+
if (type === curType) {
|
|
189
|
+
curCount++;
|
|
190
|
+
} else {
|
|
191
|
+
curType = type;
|
|
192
|
+
curCount = 1;
|
|
193
|
+
}
|
|
194
|
+
if (type === 'failure' && curCount > worstFailure) worstFailure = curCount;
|
|
195
|
+
}
|
|
196
|
+
return { current: { type: curType || 'success', count: curCount }, worstFailure };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Aggregate a batch of detail records into a stats structure. Called by stats-worker, also used directly by /api/proxy-stats.
|
|
201
|
+
*
|
|
202
|
+
* Definitions (consistent with llm-retry-proxy):
|
|
203
|
+
* - Upstream availability = proportion of requests that succeeded on the first attempt (retries==0 && succeeded)
|
|
204
|
+
* - Downstream availability = proportion that ultimately succeeded after retries (succeeded)
|
|
205
|
+
* - Difference = number of requests rescued by retries
|
|
206
|
+
*
|
|
207
|
+
* @param {object[]} records Detail record array (order is irrelevant; sorted internally)
|
|
208
|
+
* @param {object} [opts]
|
|
209
|
+
* @param {number} [opts.recentLimit=50] Number of recent detail entries to keep
|
|
210
|
+
* @returns {object} Aggregate result (proxyStats structure)
|
|
211
|
+
*/
|
|
212
|
+
export function aggregateRecords(records, opts = {}) {
|
|
213
|
+
const recentLimit = Number(opts.recentLimit) || 50;
|
|
214
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
215
|
+
return emptyStats();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Sort a copy in ascending time order (streak / recent depend on order)
|
|
219
|
+
const sorted = [...records].sort((a, b) => {
|
|
220
|
+
const ta = a.ts ? Date.parse(a.ts) : 0;
|
|
221
|
+
const tb = b.ts ? Date.parse(b.ts) : 0;
|
|
222
|
+
return ta - tb;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const total = sorted.length;
|
|
226
|
+
let totalRetries = 0;
|
|
227
|
+
let succeeded = 0;
|
|
228
|
+
let failed = 0;
|
|
229
|
+
let firstOk = 0; // Succeeded on the first attempt (retries==0 && succeeded)
|
|
230
|
+
const durations = [];
|
|
231
|
+
const retryDist = new Map(); // retries -> count
|
|
232
|
+
const retryCodeCounts = new Map(); // code -> count
|
|
233
|
+
const byModelMap = new Map();
|
|
234
|
+
const byPathMap = new Map();
|
|
235
|
+
const byProfileMap = new Map();
|
|
236
|
+
let slowest = null;
|
|
237
|
+
let fastest = null;
|
|
238
|
+
|
|
239
|
+
for (const r of sorted) {
|
|
240
|
+
const retries = Number(r.retries) || 0;
|
|
241
|
+
const ok = isRecordSucceeded(r);
|
|
242
|
+
const dur = Number(r.duration_ms) || 0;
|
|
243
|
+
totalRetries += retries;
|
|
244
|
+
if (ok) {
|
|
245
|
+
succeeded++;
|
|
246
|
+
if (retries === 0) firstOk++;
|
|
247
|
+
} else {
|
|
248
|
+
failed++;
|
|
249
|
+
}
|
|
250
|
+
durations.push(dur);
|
|
251
|
+
retryDist.set(retries, (retryDist.get(retries) || 0) + 1);
|
|
252
|
+
for (const c of (r.retry_codes || [])) {
|
|
253
|
+
retryCodeCounts.set(c, (retryCodeCounts.get(c) || 0) + 1);
|
|
254
|
+
}
|
|
255
|
+
// by model
|
|
256
|
+
const model = r.model || '(unknown)';
|
|
257
|
+
if (!byModelMap.has(model)) byModelMap.set(model, newBucket());
|
|
258
|
+
accBucket(byModelMap.get(model), retries, ok, dur);
|
|
259
|
+
// by path
|
|
260
|
+
const path = r.path || '/';
|
|
261
|
+
if (!byPathMap.has(path)) byPathMap.set(path, newBucket());
|
|
262
|
+
accBucket(byPathMap.get(path), retries, ok, dur);
|
|
263
|
+
// by profile
|
|
264
|
+
const profileKey = r.profile_id || 'default';
|
|
265
|
+
if (!byProfileMap.has(profileKey)) byProfileMap.set(profileKey, { ...newBucket(), profile_name: r.profile_name || 'Default' });
|
|
266
|
+
accBucket(byProfileMap.get(profileKey), retries, ok, dur);
|
|
267
|
+
// slowest / fastest (fastest only counts successes with dur>0)
|
|
268
|
+
const candidate = { ts: r.ts, path, model, attempts: r.attempts, retries, duration_ms: dur, final_status: r.final_status, retry_codes: r.retry_codes || [] };
|
|
269
|
+
if (!slowest || dur > slowest.duration_ms) slowest = candidate;
|
|
270
|
+
if (ok && dur > 0 && (!fastest || dur < fastest.duration_ms)) fastest = candidate;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
durations.sort((a, b) => a - b);
|
|
274
|
+
const p50 = percentile(durations, 0.5);
|
|
275
|
+
const p95 = percentile(durations, 0.95);
|
|
276
|
+
const p99 = percentile(durations, 0.99);
|
|
277
|
+
const maxMs = durations.length ? durations[durations.length - 1] : 0;
|
|
278
|
+
const avgMs = durations.length ? durations.reduce((s, x) => s + x, 0) / durations.length : 0;
|
|
279
|
+
|
|
280
|
+
const upstreamAvail = total ? round2(firstOk / total * 100) : 0;
|
|
281
|
+
const downstreamAvail = total ? round2(succeeded / total * 100) : 0;
|
|
282
|
+
const streak = computeStreak(sorted);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
summary: {
|
|
286
|
+
totalRequests: total,
|
|
287
|
+
totalRetries,
|
|
288
|
+
totalSucceeded: succeeded,
|
|
289
|
+
totalFailed: failed,
|
|
290
|
+
totalFirstOk: firstOk,
|
|
291
|
+
upstreamAvailabilityPct: upstreamAvail,
|
|
292
|
+
downstreamAvailabilityPct: downstreamAvail,
|
|
293
|
+
p50Ms: round3(p50),
|
|
294
|
+
p95Ms: round3(p95),
|
|
295
|
+
p99Ms: round3(p99),
|
|
296
|
+
maxMs: round3(maxMs),
|
|
297
|
+
avgMs: round3(avgMs),
|
|
298
|
+
currentStreakType: streak.current.type,
|
|
299
|
+
currentStreakCount: streak.current.count,
|
|
300
|
+
worstFailureStreak: streak.worstFailure,
|
|
301
|
+
},
|
|
302
|
+
byModel: mapToSortedArray(byModelMap, 'model'),
|
|
303
|
+
byPath: mapToSortedArray(byPathMap, 'path').slice(0, 10), // Top 10 path
|
|
304
|
+
byProfile: mapToSortedArrayWithExtra(byProfileMap, 'profile_id', 'profile_name'),
|
|
305
|
+
retryDistribution: [...retryDist.entries()]
|
|
306
|
+
.sort((a, b) => a[0] - b[0])
|
|
307
|
+
.map(([retries, count]) => ({ retries, count })),
|
|
308
|
+
retryCodes: [...retryCodeCounts.entries()]
|
|
309
|
+
.sort((a, b) => b[1] - a[1])
|
|
310
|
+
.map(([code, count]) => ({ code, count })),
|
|
311
|
+
slowest,
|
|
312
|
+
fastest,
|
|
313
|
+
recentRecords: sorted.slice(-recentLimit).reverse(),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function newBucket() {
|
|
318
|
+
return { requests: 0, retries: 0, succeeded: 0, firstOk: 0, failed: 0, durations: [] };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function accBucket(b, retries, ok, dur) {
|
|
322
|
+
b.requests++;
|
|
323
|
+
b.retries += retries;
|
|
324
|
+
if (ok) {
|
|
325
|
+
b.succeeded++;
|
|
326
|
+
if (retries === 0) b.firstOk++;
|
|
327
|
+
} else {
|
|
328
|
+
b.failed++;
|
|
329
|
+
}
|
|
330
|
+
b.durations.push(dur);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function mapToSortedArray(map, keyName) {
|
|
334
|
+
const arr = [];
|
|
335
|
+
for (const [k, b] of map.entries()) {
|
|
336
|
+
b.durations.sort((a, c) => a - c);
|
|
337
|
+
const entry = {
|
|
338
|
+
[keyName]: k,
|
|
339
|
+
requests: b.requests,
|
|
340
|
+
retries: b.retries,
|
|
341
|
+
succeeded: b.succeeded,
|
|
342
|
+
firstOk: b.firstOk,
|
|
343
|
+
failed: b.failed,
|
|
344
|
+
availabilityPct: b.requests ? round2(b.succeeded / b.requests * 100) : 0,
|
|
345
|
+
upstreamAvailabilityPct: b.requests ? round2(b.firstOk / b.requests * 100) : 0,
|
|
346
|
+
p95Ms: round3(percentile(b.durations, 0.95)),
|
|
347
|
+
};
|
|
348
|
+
arr.push(entry);
|
|
349
|
+
}
|
|
350
|
+
arr.sort((a, b) => b.requests - a.requests);
|
|
351
|
+
return arr;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// byProfile-specific: the bucket carries an extra profile_name field, included in the output
|
|
355
|
+
function mapToSortedArrayWithExtra(map, keyName, extraName) {
|
|
356
|
+
const arr = [];
|
|
357
|
+
for (const [k, b] of map.entries()) {
|
|
358
|
+
b.durations.sort((a, c) => a - c);
|
|
359
|
+
const entry = {
|
|
360
|
+
[keyName]: k,
|
|
361
|
+
[extraName]: b[extraName] || k,
|
|
362
|
+
requests: b.requests,
|
|
363
|
+
retries: b.retries,
|
|
364
|
+
succeeded: b.succeeded,
|
|
365
|
+
firstOk: b.firstOk,
|
|
366
|
+
failed: b.failed,
|
|
367
|
+
availabilityPct: b.requests ? round2(b.succeeded / b.requests * 100) : 0,
|
|
368
|
+
upstreamAvailabilityPct: b.requests ? round2(b.firstOk / b.requests * 100) : 0,
|
|
369
|
+
p95Ms: round3(percentile(b.durations, 0.95)),
|
|
370
|
+
};
|
|
371
|
+
arr.push(entry);
|
|
372
|
+
}
|
|
373
|
+
arr.sort((a, b) => b.requests - a.requests);
|
|
374
|
+
return arr;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function emptyStats() {
|
|
378
|
+
return {
|
|
379
|
+
summary: {
|
|
380
|
+
totalRequests: 0, totalRetries: 0, totalSucceeded: 0, totalFailed: 0, totalFirstOk: 0,
|
|
381
|
+
upstreamAvailabilityPct: 0, downstreamAvailabilityPct: 0,
|
|
382
|
+
p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0, avgMs: 0,
|
|
383
|
+
currentStreakType: 'success', currentStreakCount: 0, worstFailureStreak: 0,
|
|
384
|
+
},
|
|
385
|
+
byModel: [],
|
|
386
|
+
byPath: [],
|
|
387
|
+
byProfile: [],
|
|
388
|
+
retryDistribution: [],
|
|
389
|
+
retryCodes: [],
|
|
390
|
+
slowest: null,
|
|
391
|
+
fastest: null,
|
|
392
|
+
recentRecords: [],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Incremental cache merge at the proxy_YYYY-MM-DD.jsonl file granularity (pure function, no I/O).
|
|
398
|
+
*
|
|
399
|
+
* Proxy detail files are sharded per day and append-only: if a file's size+mtime is unchanged, its content is unchanged,
|
|
400
|
+
* so the records parsed last time can be reused, skipping redundant readFileSync+JSON.parse. stats-worker calls this
|
|
401
|
+
* function on each notifyProxyStats trigger, avoiding a full re-scan of all historical shards.
|
|
402
|
+
*
|
|
403
|
+
* @param {object} params
|
|
404
|
+
* @param {object} [params.existingCache] Previous file cache: { [fileName]: { size, lastModified, records } }
|
|
405
|
+
* @param {Array<{name:string,size:number,lastModified:string,records?:object[]}>} params.files
|
|
406
|
+
* All current proxy detail files (worker has already statSync'd; records are only re-parsed by the worker when the file changes;
|
|
407
|
+
* unchanged files leave records empty for cache reuse)
|
|
408
|
+
* @returns {{records:object[], cache:object}} Merged full records + new cache (for writing the next stats JSON)
|
|
409
|
+
*/
|
|
410
|
+
export function mergeProxyFileCache({ existingCache, files }) {
|
|
411
|
+
const cache = {};
|
|
412
|
+
const allRecords = [];
|
|
413
|
+
const existing = existingCache && typeof existingCache === 'object' ? existingCache : {};
|
|
414
|
+
for (const f of files) {
|
|
415
|
+
if (!f || !f.name) continue;
|
|
416
|
+
const cached = existing[f.name];
|
|
417
|
+
const unchanged = cached
|
|
418
|
+
&& cached.size === f.size
|
|
419
|
+
&& cached.lastModified === f.lastModified
|
|
420
|
+
&& Array.isArray(cached.records);
|
|
421
|
+
if (unchanged) {
|
|
422
|
+
// File unchanged: reuse cached records, skip re-parsing
|
|
423
|
+
cache[f.name] = { size: f.size, lastModified: f.lastModified, records: cached.records };
|
|
424
|
+
for (const r of cached.records) allRecords.push(r);
|
|
425
|
+
} else if (Array.isArray(f.records)) {
|
|
426
|
+
// File changed (or new): use the worker's re-parsed records, update cache
|
|
427
|
+
cache[f.name] = { size: f.size, lastModified: f.lastModified, records: f.records };
|
|
428
|
+
for (const r of f.records) allRecords.push(r);
|
|
429
|
+
}
|
|
430
|
+
// Neither cached nor has records (worker provided none and no cache) → skip (worker should ensure changed files carry records)
|
|
431
|
+
}
|
|
432
|
+
return { records: allRecords, cache };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function round2(n) { return Math.round(n * 100) / 100; }
|
|
436
|
+
function round3(n) { return Math.round(n * 1000) / 1000; }
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// waste, review P1). Legacy v1 *.jsonl files are no longer counted — their
|
|
6
6
|
// numbers return once the user migrates (ccv convert / the migrate prompt).
|
|
7
7
|
import { parentPort } from 'node:worker_threads';
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
9
9
|
import { join, basename } from 'node:path';
|
|
10
10
|
import { readJsonlTolerant, listSessionIds } from './v2/replay.js';
|
|
11
11
|
import { dirSizeSync } from './v2/layout.js';
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
INTER_SESSION_TYPES, isSystemText, extractUserTexts, isSuggestionMode,
|
|
16
16
|
collectPromptsFromEvents, sortEpochFiles,
|
|
17
17
|
} from './user-prompt-extract.js';
|
|
18
|
+
import { aggregateRecords, mergeProxyFileCache, parseDailyFileName } from './proxy-stats.js';
|
|
18
19
|
|
|
19
20
|
// Prompt/preview extraction moved to the shared server/lib/user-prompt-extract.js
|
|
20
21
|
// (also feeds the V2Writer prompts.jsonl cache and the log-list read side).
|
|
@@ -29,7 +30,12 @@ export { INTER_SESSION_TYPES, isSystemText, extractUserTexts };
|
|
|
29
30
|
// invalidates pre-discard caches so their probe units can't be reused
|
|
30
31
|
// back into filesStats, which lets the discard check sit AFTER the
|
|
31
32
|
// cache-reuse branch (cache hits skip the journal head scan entirely)
|
|
32
|
-
|
|
33
|
+
// v12: proxyStats field (proxy retry detail aggregation from per-day
|
|
34
|
+
// proxy_YYYY-MM-DD.jsonl shards; the bump invalidates v10 caches written
|
|
35
|
+
// by the pre-rebase feat/proxy-stats branch and v11 caches that lack the
|
|
36
|
+
// proxy fields). The per-file records cache lives in worker memory only
|
|
37
|
+
// (_proxyCacheByDir) — never persisted into the stats JSON.
|
|
38
|
+
const STATS_VERSION = 12;
|
|
33
39
|
|
|
34
40
|
/**
|
|
35
41
|
* Parse one v2 session directory into the same stats shape parseJsonlFile
|
|
@@ -168,7 +174,14 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
|
|
|
168
174
|
// Scan units = v2 session dirs. The cache key is the journal's size+mtime:
|
|
169
175
|
// every request/completion appends journal lines, so any change moves it.
|
|
170
176
|
const sessionIds = listSessionIds(projectDir).sort();
|
|
171
|
-
|
|
177
|
+
// Proxy retry shards (proxy_YYYY-MM-DD.jsonl) live at the project top level
|
|
178
|
+
// and can exist without any v2 session (proxy-only usage) — only bail out
|
|
179
|
+
// when BOTH are absent so aggregateProxyStats still runs for proxy-only dirs.
|
|
180
|
+
let proxyFiles = [];
|
|
181
|
+
try {
|
|
182
|
+
proxyFiles = readdirSync(projectDir).filter(f => f.startsWith('proxy_') && f.endsWith('.jsonl'));
|
|
183
|
+
} catch { /* unreadable project dir → nothing to aggregate from it either */ }
|
|
184
|
+
if (sessionIds.length === 0 && proxyFiles.length === 0) return;
|
|
172
185
|
|
|
173
186
|
const filesStats = {};
|
|
174
187
|
const topModels = {};
|
|
@@ -234,8 +247,9 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
|
|
|
234
247
|
}
|
|
235
248
|
}
|
|
236
249
|
|
|
237
|
-
// No parsable session yet (dirs without journals) — keep whatever exists
|
|
238
|
-
|
|
250
|
+
// No parsable session yet (dirs without journals) — keep whatever exists,
|
|
251
|
+
// unless proxy shards are present (they alone justify a stats write).
|
|
252
|
+
if (Object.keys(filesStats).length === 0 && proxyFiles.length === 0) return;
|
|
239
253
|
|
|
240
254
|
// 计算全局汇总
|
|
241
255
|
let totalRequests = 0;
|
|
@@ -256,6 +270,12 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
|
|
|
256
270
|
totalCacheCreation += f.summary.cache_creation_input_tokens;
|
|
257
271
|
}
|
|
258
272
|
|
|
273
|
+
// 代理重试明细聚合:扫描 proxy_YYYY-MM-DD.jsonl 文件,聚合成 proxyStats。
|
|
274
|
+
// 与 token 统计同文件同 worker,避免新建独立 worker。明细文件按天分片,聚合逻辑在
|
|
275
|
+
// proxy-stats.js:aggregateRecords(纯函数,可单测)。增量优化:仅对 size+mtime 变化的分片
|
|
276
|
+
// 重新解析,未变化分片复用 worker 内存缓存(mergeProxyFileCache + _proxyCacheByDir)。
|
|
277
|
+
const proxyStats = aggregateProxyStats(projectDir);
|
|
278
|
+
|
|
259
279
|
const stats = {
|
|
260
280
|
_v: STATS_VERSION,
|
|
261
281
|
project: projectName,
|
|
@@ -272,6 +292,7 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
|
|
|
272
292
|
cache_read_input_tokens: totalCacheRead,
|
|
273
293
|
cache_creation_input_tokens: totalCacheCreation,
|
|
274
294
|
},
|
|
295
|
+
proxyStats,
|
|
275
296
|
};
|
|
276
297
|
|
|
277
298
|
try {
|
|
@@ -281,6 +302,99 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
|
|
|
281
302
|
}
|
|
282
303
|
}
|
|
283
304
|
|
|
305
|
+
/**
|
|
306
|
+
* 扫描项目目录下所有 proxy_YYYY-MM-DD.jsonl 明细文件,聚合成 proxyStats 结构。
|
|
307
|
+
* 增量优化:proxy 明细按天分片且仅追加——文件 size+mtime 未变即内容未变,复用上次解析的 records,
|
|
308
|
+
* 仅对变化/新增文件重新 readFileSync+解析(仿会话 JSONL 的 files[f] 缓存)。
|
|
309
|
+
*
|
|
310
|
+
* Review P1 fix: the per-file records cache is WORKER-MEMORY ONLY
|
|
311
|
+
* (_proxyCacheByDir). It was previously persisted as `proxyStatsFiles` inside
|
|
312
|
+
* `<project>.json`, which duplicated every raw record on disk (unbounded
|
|
313
|
+
* growth) and made every stats update rewrite — and every /api/proxy-stats
|
|
314
|
+
* read re-parse — the full history. A worker restart just re-parses the
|
|
315
|
+
* shards once; the JSON now carries only the aggregated `proxyStats`.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} projectDir
|
|
318
|
+
* @returns {object} aggregated proxyStats
|
|
319
|
+
*/
|
|
320
|
+
const _proxyCacheByDir = new Map(); // projectDir → per-file cache {name:{records,size,lastModified}}
|
|
321
|
+
|
|
322
|
+
function aggregateProxyStats(projectDir) {
|
|
323
|
+
let proxyFiles = [];
|
|
324
|
+
try {
|
|
325
|
+
proxyFiles = readdirSync(projectDir)
|
|
326
|
+
.filter(f => f.startsWith('proxy_') && f.endsWith('.jsonl'))
|
|
327
|
+
.sort();
|
|
328
|
+
} catch {
|
|
329
|
+
return aggregateRecords([]);
|
|
330
|
+
}
|
|
331
|
+
if (proxyFiles.length === 0) return aggregateRecords([]);
|
|
332
|
+
|
|
333
|
+
// Optional retention (review P2): per-day shards otherwise grow without
|
|
334
|
+
// bound and every one participates in aggregation forever. Opt-in via
|
|
335
|
+
// CCV_PROXY_STATS_RETAIN_DAYS=N — shards dated older than N days ago are
|
|
336
|
+
// deleted before aggregation (and drop out of the memory cache, which is
|
|
337
|
+
// rebuilt from the surviving file list). Deliberately OFF by default:
|
|
338
|
+
// silently deleting a user's detail history is a policy the user must
|
|
339
|
+
// choose, so without the env var nothing is ever removed.
|
|
340
|
+
const retainDays = parseInt(process.env.CCV_PROXY_STATS_RETAIN_DAYS, 10);
|
|
341
|
+
if (Number.isFinite(retainDays) && retainDays > 0) {
|
|
342
|
+
const cutoff = new Date(Date.now() - retainDays * 86400000);
|
|
343
|
+
const cutoffStr = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}-${String(cutoff.getDate()).padStart(2, '0')}`;
|
|
344
|
+
proxyFiles = proxyFiles.filter((f) => {
|
|
345
|
+
const date = parseDailyFileName(f); // strict proxy_YYYY-MM-DD.jsonl; unparsable names are never deleted
|
|
346
|
+
if (!date || date >= cutoffStr) return true;
|
|
347
|
+
try { unlinkSync(join(projectDir, f)); } catch { /* deletion failed — keep it in this scan, retry next time */ }
|
|
348
|
+
return false;
|
|
349
|
+
});
|
|
350
|
+
if (proxyFiles.length === 0) return aggregateRecords([]);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const cachedFiles = _proxyCacheByDir.get(projectDir) || {};
|
|
354
|
+
|
|
355
|
+
const files = [];
|
|
356
|
+
for (const f of proxyFiles) {
|
|
357
|
+
const filePath = join(projectDir, f);
|
|
358
|
+
let stat;
|
|
359
|
+
try {
|
|
360
|
+
stat = statSync(filePath);
|
|
361
|
+
} catch {
|
|
362
|
+
continue; // stat 失败跳过此文件
|
|
363
|
+
}
|
|
364
|
+
const size = stat.size;
|
|
365
|
+
const lastModified = stat.mtime.toISOString();
|
|
366
|
+
const cached = cachedFiles[f];
|
|
367
|
+
const unchanged = cached && cached.size === size && cached.lastModified === lastModified;
|
|
368
|
+
if (unchanged) {
|
|
369
|
+
// 命中缓存:不传 records,由 mergeProxyFileCache 复用 cached.records
|
|
370
|
+
files.push({ name: f, size, lastModified });
|
|
371
|
+
} else {
|
|
372
|
+
// 变化/新增:重新解析记录(仅变化文件)
|
|
373
|
+
const records = [];
|
|
374
|
+
try {
|
|
375
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
376
|
+
for (const line of content.split('\n')) {
|
|
377
|
+
const trimmed = line.trim();
|
|
378
|
+
if (!trimmed) continue;
|
|
379
|
+
try {
|
|
380
|
+
records.push(JSON.parse(trimmed));
|
|
381
|
+
} catch {
|
|
382
|
+
// 跳过无法解析的行
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
// 文件读取失败:跳过(不进 cache,下次仍会重试)
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
files.push({ name: f, size, lastModified, records });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const { records: allRecords, cache } = mergeProxyFileCache({ existingCache: cachedFiles, files });
|
|
394
|
+
_proxyCacheByDir.set(projectDir, cache);
|
|
395
|
+
return aggregateRecords(allRecords);
|
|
396
|
+
}
|
|
397
|
+
|
|
284
398
|
/**
|
|
285
399
|
* 扫描 logDir 下所有项目目录,逐个生成统计
|
|
286
400
|
*/
|