coding-tool-x 3.2.0

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.
Files changed (185) hide show
  1. package/CHANGELOG.md +599 -0
  2. package/LICENSE +21 -0
  3. package/README.md +439 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
  6. package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
  7. package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
  8. package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
  9. package/dist/web/assets/Home-38JTUlYt.js +1 -0
  10. package/dist/web/assets/Home-CjupSEWE.css +1 -0
  11. package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
  12. package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
  13. package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
  14. package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
  15. package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
  16. package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
  17. package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
  18. package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
  19. package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
  20. package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
  21. package/dist/web/assets/icons-DRrXwWZi.js +1 -0
  22. package/dist/web/assets/index-CetESrXw.css +1 -0
  23. package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
  24. package/dist/web/assets/markdown-BfC0goYb.css +10 -0
  25. package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
  26. package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
  27. package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
  28. package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
  29. package/dist/web/favicon.ico +0 -0
  30. package/dist/web/index.html +20 -0
  31. package/dist/web/logo.png +0 -0
  32. package/docs/bannel.png +0 -0
  33. package/docs/home.png +0 -0
  34. package/docs/logo.png +0 -0
  35. package/docs/model-redirection.md +251 -0
  36. package/docs/multi-channel-load-balancing.md +249 -0
  37. package/package.json +80 -0
  38. package/src/commands/channels.js +551 -0
  39. package/src/commands/cli-type.js +101 -0
  40. package/src/commands/daemon.js +365 -0
  41. package/src/commands/doctor.js +333 -0
  42. package/src/commands/export-config.js +205 -0
  43. package/src/commands/list.js +222 -0
  44. package/src/commands/logs.js +261 -0
  45. package/src/commands/plugin.js +585 -0
  46. package/src/commands/port-config.js +135 -0
  47. package/src/commands/proxy-control.js +264 -0
  48. package/src/commands/proxy.js +152 -0
  49. package/src/commands/resume.js +137 -0
  50. package/src/commands/search.js +190 -0
  51. package/src/commands/security.js +37 -0
  52. package/src/commands/stats.js +398 -0
  53. package/src/commands/switch.js +48 -0
  54. package/src/commands/toggle-proxy.js +247 -0
  55. package/src/commands/ui.js +99 -0
  56. package/src/commands/update.js +97 -0
  57. package/src/commands/workspace.js +454 -0
  58. package/src/config/default.js +69 -0
  59. package/src/config/loader.js +149 -0
  60. package/src/config/model-metadata.js +167 -0
  61. package/src/config/model-metadata.json +125 -0
  62. package/src/config/model-pricing.js +35 -0
  63. package/src/config/paths.js +190 -0
  64. package/src/index.js +680 -0
  65. package/src/plugins/constants.js +15 -0
  66. package/src/plugins/event-bus.js +54 -0
  67. package/src/plugins/manifest-validator.js +129 -0
  68. package/src/plugins/plugin-api.js +128 -0
  69. package/src/plugins/plugin-installer.js +601 -0
  70. package/src/plugins/plugin-loader.js +229 -0
  71. package/src/plugins/plugin-manager.js +170 -0
  72. package/src/plugins/registry.js +152 -0
  73. package/src/plugins/schema/plugin-manifest.json +115 -0
  74. package/src/reset-config.js +94 -0
  75. package/src/server/api/agents.js +826 -0
  76. package/src/server/api/aliases.js +36 -0
  77. package/src/server/api/channels.js +368 -0
  78. package/src/server/api/claude-hooks.js +480 -0
  79. package/src/server/api/codex-channels.js +417 -0
  80. package/src/server/api/codex-projects.js +104 -0
  81. package/src/server/api/codex-proxy.js +195 -0
  82. package/src/server/api/codex-sessions.js +483 -0
  83. package/src/server/api/codex-statistics.js +57 -0
  84. package/src/server/api/commands.js +482 -0
  85. package/src/server/api/config-export.js +212 -0
  86. package/src/server/api/config-registry.js +357 -0
  87. package/src/server/api/config-sync.js +155 -0
  88. package/src/server/api/config-templates.js +248 -0
  89. package/src/server/api/config.js +521 -0
  90. package/src/server/api/convert.js +260 -0
  91. package/src/server/api/dashboard.js +142 -0
  92. package/src/server/api/env.js +144 -0
  93. package/src/server/api/favorites.js +77 -0
  94. package/src/server/api/gemini-channels.js +366 -0
  95. package/src/server/api/gemini-projects.js +91 -0
  96. package/src/server/api/gemini-proxy.js +173 -0
  97. package/src/server/api/gemini-sessions.js +376 -0
  98. package/src/server/api/gemini-statistics.js +57 -0
  99. package/src/server/api/health-check.js +31 -0
  100. package/src/server/api/mcp.js +399 -0
  101. package/src/server/api/opencode-channels.js +419 -0
  102. package/src/server/api/opencode-projects.js +99 -0
  103. package/src/server/api/opencode-proxy.js +207 -0
  104. package/src/server/api/opencode-sessions.js +327 -0
  105. package/src/server/api/opencode-statistics.js +57 -0
  106. package/src/server/api/plugins.js +463 -0
  107. package/src/server/api/pm2-autostart.js +269 -0
  108. package/src/server/api/projects.js +124 -0
  109. package/src/server/api/prompts.js +279 -0
  110. package/src/server/api/proxy.js +306 -0
  111. package/src/server/api/security.js +53 -0
  112. package/src/server/api/sessions.js +514 -0
  113. package/src/server/api/settings.js +142 -0
  114. package/src/server/api/skills.js +570 -0
  115. package/src/server/api/statistics.js +238 -0
  116. package/src/server/api/ui-config.js +64 -0
  117. package/src/server/api/workspaces.js +456 -0
  118. package/src/server/codex-proxy-server.js +681 -0
  119. package/src/server/dev-server.js +26 -0
  120. package/src/server/gemini-proxy-server.js +610 -0
  121. package/src/server/index.js +422 -0
  122. package/src/server/opencode-proxy-server.js +4771 -0
  123. package/src/server/proxy-server.js +669 -0
  124. package/src/server/services/agents-service.js +1137 -0
  125. package/src/server/services/alias.js +71 -0
  126. package/src/server/services/channel-health.js +234 -0
  127. package/src/server/services/channel-scheduler.js +240 -0
  128. package/src/server/services/channels.js +447 -0
  129. package/src/server/services/codex-channels.js +705 -0
  130. package/src/server/services/codex-config.js +90 -0
  131. package/src/server/services/codex-parser.js +322 -0
  132. package/src/server/services/codex-sessions.js +936 -0
  133. package/src/server/services/codex-settings-manager.js +619 -0
  134. package/src/server/services/codex-speed-test-template.json +24 -0
  135. package/src/server/services/codex-statistics-service.js +161 -0
  136. package/src/server/services/commands-service.js +574 -0
  137. package/src/server/services/config-export-service.js +1165 -0
  138. package/src/server/services/config-registry-service.js +828 -0
  139. package/src/server/services/config-sync-manager.js +941 -0
  140. package/src/server/services/config-sync-service.js +504 -0
  141. package/src/server/services/config-templates-service.js +913 -0
  142. package/src/server/services/enhanced-cache.js +196 -0
  143. package/src/server/services/env-checker.js +409 -0
  144. package/src/server/services/env-manager.js +436 -0
  145. package/src/server/services/favorites.js +165 -0
  146. package/src/server/services/format-converter.js +620 -0
  147. package/src/server/services/gemini-channels.js +459 -0
  148. package/src/server/services/gemini-config.js +73 -0
  149. package/src/server/services/gemini-sessions.js +689 -0
  150. package/src/server/services/gemini-settings-manager.js +263 -0
  151. package/src/server/services/gemini-statistics-service.js +157 -0
  152. package/src/server/services/health-check.js +85 -0
  153. package/src/server/services/mcp-client.js +790 -0
  154. package/src/server/services/mcp-service.js +1732 -0
  155. package/src/server/services/model-detector.js +1245 -0
  156. package/src/server/services/network-access.js +80 -0
  157. package/src/server/services/opencode-channels.js +366 -0
  158. package/src/server/services/opencode-gateway-adapters.js +1168 -0
  159. package/src/server/services/opencode-gateway-converter.js +639 -0
  160. package/src/server/services/opencode-sessions.js +931 -0
  161. package/src/server/services/opencode-settings-manager.js +478 -0
  162. package/src/server/services/opencode-statistics-service.js +161 -0
  163. package/src/server/services/plugins-service.js +1268 -0
  164. package/src/server/services/prompts-service.js +534 -0
  165. package/src/server/services/proxy-runtime.js +79 -0
  166. package/src/server/services/repo-scanner-base.js +708 -0
  167. package/src/server/services/request-logger.js +130 -0
  168. package/src/server/services/response-decoder.js +21 -0
  169. package/src/server/services/security-config.js +131 -0
  170. package/src/server/services/session-cache.js +127 -0
  171. package/src/server/services/session-converter.js +577 -0
  172. package/src/server/services/sessions.js +900 -0
  173. package/src/server/services/settings-manager.js +163 -0
  174. package/src/server/services/skill-service.js +1482 -0
  175. package/src/server/services/speed-test.js +1146 -0
  176. package/src/server/services/statistics-service.js +1043 -0
  177. package/src/server/services/ui-config.js +132 -0
  178. package/src/server/services/workspace-service.js +830 -0
  179. package/src/server/utils/pricing.js +73 -0
  180. package/src/server/websocket-server.js +513 -0
  181. package/src/ui/menu.js +139 -0
  182. package/src/ui/prompts.js +100 -0
  183. package/src/utils/format.js +43 -0
  184. package/src/utils/port-helper.js +108 -0
  185. package/src/utils/session.js +240 -0
@@ -0,0 +1,1043 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ // 北京时间辅助(UTC+8),统一所有时间计算
6
+ const CST_OFFSET_MS = 8 * 60 * 60 * 1000;
7
+
8
+ function toCSTDate(ts) {
9
+ // 返回以北京时间解释的 Date 对象各字段(通过偏移 UTC)
10
+ return new Date(new Date(ts).getTime() + CST_OFFSET_MS);
11
+ }
12
+
13
+ function getCSTDateStr(ts) {
14
+ // 返回北京时间日期字符串 YYYY-MM-DD
15
+ const d = toCSTDate(ts);
16
+ return d.toISOString().split('T')[0];
17
+ }
18
+
19
+ function getCSTHour(ts) {
20
+ // 返回北京时间小时 (0-23)
21
+ return toCSTDate(ts).getUTCHours();
22
+ }
23
+
24
+ /**
25
+ * 统计服务 - 数据采集和存储
26
+ *
27
+ * 文件结构:
28
+ * ~/.cc-tool/
29
+ * ├── statistics.json # 总体统计(实时更新)
30
+ * ├── daily-stats/
31
+ * │ ├── 2025-11-22.json # 每日汇总统计
32
+ * │ └── 2025-11-23.json
33
+ * └── request-logs/
34
+ * ├── 2025-11/
35
+ * │ ├── 22.jsonl # 每日详细日志(JSONL格式)
36
+ * │ └── 23.jsonl
37
+ * └── 2025-12/
38
+ */
39
+
40
+ // 获取基础目录
41
+ function getBaseDir() {
42
+ const dir = path.join(os.homedir(), '.cc-tool');
43
+ if (!fs.existsSync(dir)) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ }
46
+ return dir;
47
+ }
48
+
49
+ // 获取每日统计目录
50
+ function getDailyStatsDir() {
51
+ const dir = path.join(getBaseDir(), 'daily-stats');
52
+ if (!fs.existsSync(dir)) {
53
+ fs.mkdirSync(dir, { recursive: true });
54
+ }
55
+ return dir;
56
+ }
57
+
58
+ // 获取请求日志目录
59
+ function getRequestLogsDir(year, month) {
60
+ const baseDir = path.join(getBaseDir(), 'request-logs', `${year}-${month.toString().padStart(2, '0')}`);
61
+ if (!fs.existsSync(baseDir)) {
62
+ fs.mkdirSync(baseDir, { recursive: true });
63
+ }
64
+ return baseDir;
65
+ }
66
+
67
+ // 获取统计文件路径
68
+ function getStatisticsFilePath() {
69
+ return path.join(getBaseDir(), 'statistics.json');
70
+ }
71
+
72
+ // 获取每日统计文件路径
73
+ function getDailyStatsFilePath(date) {
74
+ // date 格式: YYYY-MM-DD
75
+ return path.join(getDailyStatsDir(), `${date}.json`);
76
+ }
77
+
78
+ // 获取请求日志文件路径
79
+ function getRequestLogFilePath(year, month, day) {
80
+ const dir = getRequestLogsDir(year, month);
81
+ return path.join(dir, `${day.toString().padStart(2, '0')}.jsonl`);
82
+ }
83
+
84
+ function getProxyLogsFilePath() {
85
+ return path.join(getBaseDir(), 'proxy-logs.json');
86
+ }
87
+
88
+ // 加载总体统计
89
+ function loadStatistics() {
90
+ const filePath = getStatisticsFilePath();
91
+ try {
92
+ if (fs.existsSync(filePath)) {
93
+ const data = fs.readFileSync(filePath, 'utf8');
94
+ return JSON.parse(data);
95
+ }
96
+ } catch (err) {
97
+ console.error('Failed to load statistics:', err);
98
+ }
99
+
100
+ // 返回默认结构
101
+ return {
102
+ version: '2.0',
103
+ lastUpdated: new Date().toISOString(),
104
+ global: {
105
+ totalRequests: 0,
106
+ totalTokens: 0,
107
+ totalCost: 0
108
+ },
109
+ byToolType: {},
110
+ byChannel: {},
111
+ byModel: {}
112
+ };
113
+ }
114
+
115
+ // 保存总体统计
116
+ function saveStatistics(stats) {
117
+ const filePath = getStatisticsFilePath();
118
+ stats.lastUpdated = new Date().toISOString();
119
+
120
+ try {
121
+ fs.writeFileSync(filePath, JSON.stringify(stats, null, 2), 'utf8');
122
+ } catch (err) {
123
+ console.error('Failed to save statistics:', err);
124
+ }
125
+ }
126
+
127
+ // 加载每日统计
128
+ function loadDailyStats(date) {
129
+ const filePath = getDailyStatsFilePath(date);
130
+ try {
131
+ if (fs.existsSync(filePath)) {
132
+ const data = fs.readFileSync(filePath, 'utf8');
133
+ return JSON.parse(data);
134
+ }
135
+ } catch (err) {
136
+ console.error('Failed to load daily stats:', err);
137
+ }
138
+
139
+ // 返回默认结构
140
+ return {
141
+ date: date,
142
+ summary: {
143
+ requests: 0,
144
+ tokens: 0,
145
+ cost: 0
146
+ },
147
+ hourly: {}, // 按小时统计
148
+ byToolType: {},
149
+ byChannel: {},
150
+ byModel: {}
151
+ };
152
+ }
153
+
154
+ // 保存每日统计
155
+ function saveDailyStats(date, stats) {
156
+ const filePath = getDailyStatsFilePath(date);
157
+
158
+ try {
159
+ fs.writeFileSync(filePath, JSON.stringify(stats, null, 2), 'utf8');
160
+ } catch (err) {
161
+ console.error('Failed to save daily stats:', err);
162
+ }
163
+ }
164
+
165
+ // 追加请求日志(JSONL格式)
166
+ function appendRequestLog(logEntry) {
167
+ const cst = toCSTDate(logEntry.timestamp);
168
+ const year = cst.getUTCFullYear();
169
+ const month = cst.getUTCMonth() + 1;
170
+ const day = cst.getUTCDate();
171
+
172
+ const filePath = getRequestLogFilePath(year, month, day);
173
+
174
+ try {
175
+ // JSONL 格式:每行一个 JSON 对象
176
+ const line = JSON.stringify(logEntry) + '\n';
177
+ fs.appendFileSync(filePath, line, 'utf8');
178
+ } catch (err) {
179
+ console.error('Failed to append request log:', err);
180
+ }
181
+ }
182
+
183
+ // 初始化统计对象
184
+ function initStatsObject() {
185
+ return {
186
+ requests: 0,
187
+ tokens: {
188
+ input: 0,
189
+ output: 0,
190
+ cacheCreation: 0,
191
+ cacheRead: 0,
192
+ total: 0
193
+ },
194
+ cost: 0
195
+ };
196
+ }
197
+
198
+ // 更新统计数据
199
+ function updateStats(stats, tokens, cost) {
200
+ stats.requests += 1;
201
+ stats.tokens.input += tokens.input || 0;
202
+ stats.tokens.output += tokens.output || 0;
203
+ stats.tokens.cacheCreation += tokens.cacheCreation || 0;
204
+ stats.tokens.cacheRead += tokens.cacheRead || 0;
205
+ stats.tokens.total += tokens.total || 0;
206
+ stats.cost += cost || 0;
207
+ }
208
+
209
+ /**
210
+ * 记录一次请求
211
+ * @param {Object} requestData - 请求数据
212
+ */
213
+ function recordRequest(requestData) {
214
+ try {
215
+ const {
216
+ id,
217
+ timestamp,
218
+ toolType = 'claude-code',
219
+ channel,
220
+ channelId,
221
+ model,
222
+ tokens,
223
+ duration,
224
+ success,
225
+ cost = 0,
226
+ session,
227
+ project
228
+ } = requestData;
229
+
230
+ // 1. 写入详细日志
231
+ const logEntry = {
232
+ id,
233
+ timestamp,
234
+ toolType,
235
+ channel,
236
+ channelId,
237
+ model,
238
+ tokens,
239
+ duration,
240
+ success,
241
+ cost,
242
+ session,
243
+ project
244
+ };
245
+ appendRequestLog(logEntry);
246
+
247
+ // 2. 更新总体统计
248
+ const globalStats = loadStatistics();
249
+
250
+ // 更新全局统计
251
+ globalStats.global.totalRequests += 1;
252
+ globalStats.global.totalTokens += tokens.total || 0;
253
+ globalStats.global.totalCost += cost || 0;
254
+
255
+ // 按工具类型统计
256
+ if (!globalStats.byToolType[toolType]) {
257
+ globalStats.byToolType[toolType] = {
258
+ ...initStatsObject(),
259
+ channels: {},
260
+ models: {}
261
+ };
262
+ }
263
+ updateStats(globalStats.byToolType[toolType], tokens, cost);
264
+
265
+ // 按工具类型 -> 渠道统计
266
+ if (!globalStats.byToolType[toolType].channels[channelId]) {
267
+ globalStats.byToolType[toolType].channels[channelId] = {
268
+ name: channel,
269
+ ...initStatsObject(),
270
+ firstUsed: timestamp,
271
+ lastUsed: timestamp
272
+ };
273
+ } else {
274
+ globalStats.byToolType[toolType].channels[channelId].lastUsed = timestamp;
275
+ }
276
+ updateStats(globalStats.byToolType[toolType].channels[channelId], tokens, cost);
277
+
278
+ // 按工具类型 -> 模型统计
279
+ if (!globalStats.byToolType[toolType].models[model]) {
280
+ globalStats.byToolType[toolType].models[model] = initStatsObject();
281
+ }
282
+ updateStats(globalStats.byToolType[toolType].models[model], tokens, cost);
283
+
284
+ // 按渠道统计(跨工具)
285
+ if (!globalStats.byChannel[channelId]) {
286
+ globalStats.byChannel[channelId] = {
287
+ toolType,
288
+ name: channel,
289
+ ...initStatsObject(),
290
+ firstUsed: timestamp,
291
+ lastUsed: timestamp
292
+ };
293
+ } else {
294
+ globalStats.byChannel[channelId].lastUsed = timestamp;
295
+ }
296
+ updateStats(globalStats.byChannel[channelId], tokens, cost);
297
+
298
+ // 按模型统计(跨工具)
299
+ if (!globalStats.byModel[model]) {
300
+ globalStats.byModel[model] = {
301
+ toolType,
302
+ ...initStatsObject()
303
+ };
304
+ }
305
+ updateStats(globalStats.byModel[model], tokens, cost);
306
+
307
+ saveStatistics(globalStats);
308
+
309
+ // 3. 更新每日统计(使用北京时间)
310
+ const date = getCSTDateStr(timestamp); // YYYY-MM-DD (CST)
311
+ const hour = getCSTHour(timestamp).toString().padStart(2, '0'); // HH (CST)
312
+
313
+ const dailyStats = loadDailyStats(date);
314
+
315
+ // 更新每日汇总
316
+ dailyStats.summary.requests += 1;
317
+ dailyStats.summary.tokens += tokens.total || 0;
318
+ dailyStats.summary.cost += cost || 0;
319
+
320
+ // 按小时统计
321
+ if (!dailyStats.hourly[hour]) {
322
+ dailyStats.hourly[hour] = {
323
+ ...initStatsObject(),
324
+ byToolType: {}
325
+ };
326
+ }
327
+ updateStats(dailyStats.hourly[hour], tokens, cost);
328
+
329
+ // 按小时 -> 工具类型
330
+ if (!dailyStats.hourly[hour].byToolType[toolType]) {
331
+ dailyStats.hourly[hour].byToolType[toolType] = initStatsObject();
332
+ }
333
+ updateStats(dailyStats.hourly[hour].byToolType[toolType], tokens, cost);
334
+
335
+ // 按工具类型统计
336
+ if (!dailyStats.byToolType[toolType]) {
337
+ dailyStats.byToolType[toolType] = {
338
+ ...initStatsObject(),
339
+ channels: {},
340
+ models: {}
341
+ };
342
+ }
343
+ updateStats(dailyStats.byToolType[toolType], tokens, cost);
344
+
345
+ // 按工具类型 -> 渠道
346
+ if (!dailyStats.byToolType[toolType].channels) {
347
+ dailyStats.byToolType[toolType].channels = {};
348
+ }
349
+ if (!dailyStats.byToolType[toolType].channels[channelId]) {
350
+ dailyStats.byToolType[toolType].channels[channelId] = {
351
+ name: channel,
352
+ ...initStatsObject()
353
+ };
354
+ }
355
+ updateStats(dailyStats.byToolType[toolType].channels[channelId], tokens, cost);
356
+
357
+ // 按渠道统计
358
+ if (!dailyStats.byChannel[channelId]) {
359
+ dailyStats.byChannel[channelId] = {
360
+ toolType,
361
+ name: channel,
362
+ ...initStatsObject()
363
+ };
364
+ }
365
+ updateStats(dailyStats.byChannel[channelId], tokens, cost);
366
+
367
+ // 按模型统计
368
+ if (!dailyStats.byModel[model]) {
369
+ dailyStats.byModel[model] = {
370
+ toolType,
371
+ ...initStatsObject()
372
+ };
373
+ }
374
+ updateStats(dailyStats.byModel[model], tokens, cost);
375
+
376
+ saveDailyStats(date, dailyStats);
377
+
378
+ // Invalidate cached trend results that cover this date
379
+ invalidateTrendCacheForDate(date);
380
+ } catch (err) {
381
+ console.error('[Statistics] Failed to record request:', err);
382
+ }
383
+ }
384
+
385
+ /**
386
+ * 获取统计数据
387
+ */
388
+ function getStatistics() {
389
+ return loadStatistics();
390
+ }
391
+
392
+ /**
393
+ * 获取每日统计
394
+ */
395
+ function getDailyStatistics(date) {
396
+ return aggregateDailyStatistics(date);
397
+ }
398
+
399
+ /**
400
+ * 获取今日统计
401
+ */
402
+ function getTodayStatistics() {
403
+ const today = getCSTDateStr(Date.now());
404
+ return aggregateDailyStatistics(today);
405
+ }
406
+
407
+ /**
408
+ * 从统计对象中提取指定指标值
409
+ */
410
+ function extractMetric(stats, metric) {
411
+ if (!stats) return 0;
412
+ if (metric === 'tokens') return stats.tokens?.total || stats.tokens || 0;
413
+ if (metric === 'cost') return stats.cost || 0;
414
+ if (metric === 'requests') return stats.requests || 0;
415
+ return 0;
416
+ }
417
+
418
+ /**
419
+ * 从 JSONL 日志文件读取指定日期+小时的数据(按 model 或 channel 聚合)
420
+ * @param {number} year
421
+ * @param {number} month
422
+ * @param {number} day
423
+ * @param {number} hour
424
+ * @param {string} groupBy
425
+ * @param {Object} [filters] - optional { toolType, channel, model }
426
+ */
427
+ function readJsonlForHour(year, month, day, hour, groupBy, filters) {
428
+ const filePath = getRequestLogFilePath(year, month, day);
429
+ const result = {};
430
+
431
+ try {
432
+ if (!fs.existsSync(filePath)) return result;
433
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
434
+
435
+ for (const line of lines) {
436
+ if (!line.trim()) continue;
437
+ let entry;
438
+ try { entry = JSON.parse(line); } catch { continue; }
439
+
440
+ const ts = new Date(entry.timestamp);
441
+ if (getCSTHour(ts) !== hour) continue;
442
+
443
+ // Apply filters
444
+ if (filters) {
445
+ if (filters.toolType && entry.toolType !== filters.toolType) continue;
446
+ if (filters.channel && entry.channel !== filters.channel) continue;
447
+ if (filters.model && entry.model !== filters.model) continue;
448
+ }
449
+
450
+ let key;
451
+ if (groupBy === 'model') key = entry.model || 'unknown';
452
+ else if (groupBy === 'channel') key = entry.channel || entry.channelId || 'unknown';
453
+ else if (groupBy === 'toolType') key = entry.toolType || 'claude-code';
454
+ else continue;
455
+
456
+ if (!result[key]) result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
457
+ result[key].tokens.total += entry.tokens?.total || 0;
458
+ result[key].cost += entry.cost || 0;
459
+ result[key].requests += 1;
460
+ }
461
+ } catch (err) {
462
+ console.error('Failed to read JSONL for hour:', err);
463
+ }
464
+
465
+ return result;
466
+ }
467
+
468
+ /**
469
+ * 从 JSONL 日志文件读取整天的数据(应用过滤器后按维度聚合)
470
+ * @param {number} year
471
+ * @param {number} month
472
+ * @param {number} day
473
+ * @param {string} groupBy
474
+ * @param {Object} [filters] - optional { toolType, channel, model }
475
+ */
476
+ function readJsonlForDay(year, month, day, groupBy, filters) {
477
+ const filePath = getRequestLogFilePath(year, month, day);
478
+ const result = {};
479
+
480
+ try {
481
+ if (!fs.existsSync(filePath)) return result;
482
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
483
+
484
+ for (const line of lines) {
485
+ if (!line.trim()) continue;
486
+ let entry;
487
+ try { entry = JSON.parse(line); } catch { continue; }
488
+
489
+ // Apply filters
490
+ if (filters) {
491
+ if (filters.toolType && entry.toolType !== filters.toolType) continue;
492
+ if (filters.channel && entry.channel !== filters.channel) continue;
493
+ if (filters.model && entry.model !== filters.model) continue;
494
+ }
495
+
496
+ let key;
497
+ if (groupBy === 'model') key = entry.model || 'unknown';
498
+ else if (groupBy === 'channel') key = entry.channel || entry.channelId || 'unknown';
499
+ else if (groupBy === 'toolType') key = entry.toolType || 'claude-code';
500
+ else continue;
501
+
502
+ if (!result[key]) result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
503
+ result[key].tokens.total += entry.tokens?.total || 0;
504
+ result[key].cost += entry.cost || 0;
505
+ result[key].requests += 1;
506
+ }
507
+ } catch (err) {
508
+ console.error('Failed to read JSONL for day:', err);
509
+ }
510
+
511
+ return result;
512
+ }
513
+
514
+ function mapSourceToToolType(source) {
515
+ if (source === 'codex') return 'codex';
516
+ if (source === 'gemini') return 'gemini';
517
+ if (source === 'opencode') return 'opencode';
518
+ return 'claude-code';
519
+ }
520
+
521
+ function loadProxyLogs() {
522
+ const filePath = getProxyLogsFilePath();
523
+ try {
524
+ if (!fs.existsSync(filePath)) return [];
525
+ const logs = JSON.parse(fs.readFileSync(filePath, 'utf8'));
526
+ return Array.isArray(logs) ? logs : [];
527
+ } catch (err) {
528
+ console.error('Failed to load proxy logs:', err);
529
+ return [];
530
+ }
531
+ }
532
+
533
+ function filterProxyLogsForHour(logs, dateStr, hour, groupBy) {
534
+ const result = {};
535
+
536
+ for (const entry of logs) {
537
+ if (!entry || entry.type === 'action') continue;
538
+
539
+ const ts = new Date(entry.timestamp || Date.now());
540
+ if (Number.isNaN(ts.getTime())) continue;
541
+
542
+ const entryDate = getCSTDateStr(ts);
543
+ if (entryDate !== dateStr || getCSTHour(ts) !== hour) continue;
544
+
545
+ let key;
546
+ if (groupBy === 'toolType') {
547
+ key = mapSourceToToolType(entry.source);
548
+ } else if (groupBy === 'model') {
549
+ key = entry.model || 'unknown';
550
+ } else if (groupBy === 'channel') {
551
+ key = entry.channel || 'unknown';
552
+ } else {
553
+ continue;
554
+ }
555
+
556
+ if (!result[key]) {
557
+ result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
558
+ }
559
+
560
+ const totalTokens = entry.totalTokens ||
561
+ entry.tokens?.total ||
562
+ (entry.inputTokens || 0) + (entry.outputTokens || 0) + (entry.reasoningTokens || 0);
563
+
564
+ result[key].tokens.total += totalTokens || 0;
565
+ result[key].cost += entry.cost || 0;
566
+ result[key].requests += 1;
567
+ }
568
+
569
+ return result;
570
+ }
571
+
572
+ function readProxyLogsForHour(dateStr, hour, groupBy) {
573
+ return filterProxyLogsForHour(loadProxyLogs(), dateStr, hour, groupBy);
574
+ }
575
+
576
+ /**
577
+ * 获取趋势统计数据
578
+ * @param {Object} options
579
+ * @param {string} options.startDate - YYYY-MM-DD
580
+ * @param {string} options.endDate - YYYY-MM-DD
581
+ * @param {string} options.granularity - 'day' | 'hour'
582
+ * @param {string} options.groupBy - 'model' | 'channel' | 'toolType'
583
+ * @param {string} options.metric - 'tokens' | 'cost' | 'requests'
584
+ */
585
+
586
+ // 工具类型到 daily-stats 目录前缀的映射
587
+ const TOOL_PREFIXES = {
588
+ 'claude-code': '',
589
+ 'codex': 'codex-',
590
+ 'gemini': 'gemini-',
591
+ 'opencode': 'opencode-'
592
+ };
593
+
594
+ function getTokenTotal(tokens) {
595
+ if (typeof tokens === 'number') return tokens;
596
+ if (tokens && typeof tokens === 'object') {
597
+ if (typeof tokens.total === 'number') return tokens.total;
598
+ return Object.entries(tokens).reduce((sum, [key, value]) => {
599
+ if (key === 'total') return sum;
600
+ return typeof value === 'number' ? sum + value : sum;
601
+ }, 0);
602
+ }
603
+ return 0;
604
+ }
605
+
606
+ function normalizeTokens(tokens) {
607
+ if (typeof tokens === 'number') {
608
+ return { total: tokens };
609
+ }
610
+
611
+ const normalized = { total: 0 };
612
+ if (tokens && typeof tokens === 'object') {
613
+ for (const [key, value] of Object.entries(tokens)) {
614
+ if (typeof value === 'number') {
615
+ normalized[key] = value;
616
+ }
617
+ }
618
+ }
619
+
620
+ normalized.total = getTokenTotal(tokens);
621
+ return normalized;
622
+ }
623
+
624
+ function mergeStatsEntry(target, source) {
625
+ if (!source) return;
626
+
627
+ target.requests += source.requests || 0;
628
+ target.cost += source.cost || 0;
629
+
630
+ const sourceTokens = normalizeTokens(source.tokens);
631
+ for (const [key, value] of Object.entries(sourceTokens)) {
632
+ target.tokens[key] = (target.tokens[key] || 0) + value;
633
+ }
634
+ }
635
+
636
+ function createEmptyEntry(toolType, name) {
637
+ const entry = {
638
+ requests: 0,
639
+ tokens: { total: 0 },
640
+ cost: 0
641
+ };
642
+
643
+ if (toolType) entry.toolType = toolType;
644
+ if (name) entry.name = name;
645
+ return entry;
646
+ }
647
+
648
+ function getScopedKey(container, baseKey, toolType) {
649
+ if (!container[baseKey] || container[baseKey].toolType === toolType) {
650
+ return baseKey;
651
+ }
652
+
653
+ let index = 1;
654
+ let scopedKey = `${toolType}:${baseKey}`;
655
+ while (container[scopedKey] && container[scopedKey].toolType !== toolType) {
656
+ scopedKey = `${toolType}:${baseKey}:${index}`;
657
+ index += 1;
658
+ }
659
+ return scopedKey;
660
+ }
661
+
662
+ function mergeHourlyStats(targetHourly, sourceHourly = {}) {
663
+ for (const [hour, hourStats] of Object.entries(sourceHourly)) {
664
+ if (!targetHourly[hour]) {
665
+ targetHourly[hour] = {
666
+ requests: 0,
667
+ tokens: { total: 0 },
668
+ cost: 0,
669
+ byToolType: {}
670
+ };
671
+ }
672
+
673
+ mergeStatsEntry(targetHourly[hour], hourStats);
674
+
675
+ if (hourStats.byToolType && typeof hourStats.byToolType === 'object') {
676
+ for (const [toolType, toolStats] of Object.entries(hourStats.byToolType)) {
677
+ if (!targetHourly[hour].byToolType[toolType]) {
678
+ targetHourly[hour].byToolType[toolType] = createEmptyEntry();
679
+ }
680
+ mergeStatsEntry(targetHourly[hour].byToolType[toolType], toolStats);
681
+ }
682
+ }
683
+ }
684
+ }
685
+
686
+ function hasStatsData(stats = {}) {
687
+ const requests = Number(stats.requests || 0);
688
+ const cost = Number(stats.cost || 0);
689
+ const totalTokens = getTokenTotal(stats.tokens);
690
+ return requests > 0 || cost > 0 || totalTokens > 0;
691
+ }
692
+
693
+ function aggregateDailyStatistics(dateStr) {
694
+ const aggregated = {
695
+ date: dateStr,
696
+ summary: {
697
+ requests: 0,
698
+ tokens: 0,
699
+ cost: 0
700
+ },
701
+ hourly: {},
702
+ byToolType: {},
703
+ byChannel: {},
704
+ byModel: {}
705
+ };
706
+
707
+ const sharedStats = loadDailyStats(dateStr);
708
+ const sharedSummaryEntry = createEmptyEntry();
709
+ mergeStatsEntry(sharedSummaryEntry, {
710
+ requests: sharedStats.summary?.requests || 0,
711
+ tokens: sharedStats.summary?.tokens || 0,
712
+ cost: sharedStats.summary?.cost || 0
713
+ });
714
+ aggregated.summary.requests += sharedSummaryEntry.requests;
715
+ aggregated.summary.tokens += sharedSummaryEntry.tokens.total || 0;
716
+ aggregated.summary.cost += sharedSummaryEntry.cost;
717
+ mergeHourlyStats(aggregated.hourly, sharedStats.hourly);
718
+
719
+ for (const toolType of Object.keys(TOOL_PREFIXES)) {
720
+ const toolStats = sharedStats.byToolType?.[toolType];
721
+ if (!aggregated.byToolType[toolType]) {
722
+ aggregated.byToolType[toolType] = createEmptyEntry();
723
+ }
724
+ if (!toolStats) continue;
725
+
726
+ mergeStatsEntry(aggregated.byToolType[toolType], toolStats);
727
+
728
+ for (const [channelId, channelStats] of Object.entries(toolStats.channels || {})) {
729
+ const key = getScopedKey(aggregated.byChannel, channelId, toolType);
730
+ if (!aggregated.byChannel[key]) {
731
+ aggregated.byChannel[key] = createEmptyEntry(toolType, channelStats.name || channelId);
732
+ }
733
+ mergeStatsEntry(aggregated.byChannel[key], channelStats);
734
+ }
735
+
736
+ for (const [modelName, modelStats] of Object.entries(toolStats.models || {})) {
737
+ const key = getScopedKey(aggregated.byModel, modelName, toolType);
738
+ if (!aggregated.byModel[key]) {
739
+ aggregated.byModel[key] = createEmptyEntry(toolType);
740
+ }
741
+ mergeStatsEntry(aggregated.byModel[key], modelStats);
742
+ }
743
+ }
744
+
745
+ // Fallback: if byModel is still empty (older daily-stats files store model data
746
+ // directly in byModel rather than byToolType[toolType].models), merge it directly.
747
+ if (Object.keys(aggregated.byModel).length === 0 && sharedStats.byModel) {
748
+ for (const [modelName, modelStats] of Object.entries(sharedStats.byModel)) {
749
+ const toolType = modelStats.toolType || 'claude-code';
750
+ if (!aggregated.byModel[modelName]) {
751
+ aggregated.byModel[modelName] = createEmptyEntry(toolType);
752
+ }
753
+ mergeStatsEntry(aggregated.byModel[modelName], modelStats);
754
+ }
755
+ }
756
+
757
+ // Fallback: if byChannel is still empty, merge from sharedStats.byChannel directly.
758
+ if (Object.keys(aggregated.byChannel).length === 0 && sharedStats.byChannel) {
759
+ for (const [channelId, channelStats] of Object.entries(sharedStats.byChannel)) {
760
+ const toolType = channelStats.toolType || 'claude-code';
761
+ if (!aggregated.byChannel[channelId]) {
762
+ aggregated.byChannel[channelId] = createEmptyEntry(toolType, channelStats.name || channelId);
763
+ }
764
+ mergeStatsEntry(aggregated.byChannel[channelId], channelStats);
765
+ }
766
+ }
767
+
768
+ // 保证前端可用的工具键始终存在
769
+ for (const toolType of Object.keys(TOOL_PREFIXES)) {
770
+ if (!aggregated.byToolType[toolType]) {
771
+ aggregated.byToolType[toolType] = createEmptyEntry();
772
+ }
773
+ }
774
+
775
+ return aggregated;
776
+ }
777
+
778
+ // 合并某天共享 daily-stats,groupBy 决定合并维度
779
+ function mergeAllToolsDailyStats(dateStr, groupBy) {
780
+ const merged = {};
781
+
782
+ const aggregated = aggregateDailyStatistics(dateStr);
783
+ if (!aggregated) return merged;
784
+
785
+ if (groupBy === 'toolType') {
786
+ for (const toolType of Object.keys(TOOL_PREFIXES)) {
787
+ const toolStats = aggregated.byToolType?.[toolType];
788
+ if (!toolStats || !hasStatsData(toolStats)) continue;
789
+ merged[toolType] = {
790
+ requests: toolStats.requests || 0,
791
+ tokens: { total: getTokenTotal(toolStats.tokens) },
792
+ cost: toolStats.cost || 0
793
+ };
794
+ }
795
+ return merged;
796
+ }
797
+
798
+ if (groupBy === 'model') {
799
+ for (const [modelName, modelStats] of Object.entries(aggregated.byModel || {})) {
800
+ if (!merged[modelName]) merged[modelName] = { requests: 0, tokens: { total: 0 }, cost: 0 };
801
+ merged[modelName].requests += modelStats.requests || 0;
802
+ merged[modelName].tokens.total += getTokenTotal(modelStats.tokens);
803
+ merged[modelName].cost += modelStats.cost || 0;
804
+ }
805
+ return merged;
806
+ }
807
+
808
+ if (groupBy === 'channel') {
809
+ for (const [channelId, channelStats] of Object.entries(aggregated.byChannel || {})) {
810
+ const key = channelStats.name || channelId;
811
+ if (!merged[key]) merged[key] = { requests: 0, tokens: { total: 0 }, cost: 0 };
812
+ merged[key].requests += channelStats.requests || 0;
813
+ merged[key].tokens.total += getTokenTotal(channelStats.tokens);
814
+ merged[key].cost += channelStats.cost || 0;
815
+ }
816
+ }
817
+
818
+ return merged;
819
+ }
820
+
821
+ /**
822
+ * 扫描日期范围内的 JSONL 文件,返回可用的过滤器选项
823
+ * @param {string} startDate - YYYY-MM-DD
824
+ * @param {string} endDate - YYYY-MM-DD
825
+ * @returns {{ toolTypes: string[], channels: string[], models: string[] }}
826
+ */
827
+ function getAvailableFilters(startDate, endDate) {
828
+ const toolTypes = new Set();
829
+ const channels = new Set();
830
+ const models = new Set();
831
+
832
+ const start = new Date(startDate + 'T00:00:00');
833
+ const end = new Date(endDate + 'T00:00:00');
834
+
835
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
836
+ const year = d.getFullYear();
837
+ const month = d.getMonth() + 1;
838
+ const day = d.getDate();
839
+ const filePath = getRequestLogFilePath(year, month, day);
840
+
841
+ try {
842
+ if (!fs.existsSync(filePath)) continue;
843
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
844
+ for (const line of lines) {
845
+ if (!line.trim()) continue;
846
+ let entry;
847
+ try { entry = JSON.parse(line); } catch { continue; }
848
+ if (entry.toolType) toolTypes.add(entry.toolType);
849
+ if (entry.channel) channels.add(entry.channel);
850
+ if (entry.model) models.add(entry.model);
851
+ }
852
+ } catch (err) {
853
+ console.error('Failed to scan JSONL for filters:', err);
854
+ }
855
+ }
856
+
857
+ return {
858
+ toolTypes: Array.from(toolTypes).sort(),
859
+ channels: Array.from(channels).sort(),
860
+ models: Array.from(models).sort()
861
+ };
862
+ }
863
+
864
+ // ─── Trend statistics in-memory cache ───────────────────────────────────────
865
+ // Key: JSON-serialized params, Value: { result, expiresAt }
866
+ const trendCache = new Map();
867
+ const TREND_CACHE_TTL_MS = 30 * 1000; // 30 seconds
868
+
869
+ function getTrendCacheKey(params) {
870
+ return JSON.stringify(params);
871
+ }
872
+
873
+ function invalidateTrendCache() {
874
+ trendCache.clear();
875
+ }
876
+
877
+ // Called by recordRequest so fresh data is visible immediately after a new request
878
+ function invalidateTrendCacheForDate(dateStr) {
879
+ for (const key of trendCache.keys()) {
880
+ try {
881
+ const p = JSON.parse(key);
882
+ if (p.startDate <= dateStr && dateStr <= p.endDate) {
883
+ trendCache.delete(key);
884
+ }
885
+ } catch { trendCache.delete(key); }
886
+ }
887
+ }
888
+ // ─────────────────────────────────────────────────────────────────────────────
889
+
890
+ async function getTrendStatistics({ startDate, endDate, granularity = 'day', step = 1, groupBy = 'model', metric = 'tokens', filters }) {
891
+ step = parseInt(step) || 1;
892
+
893
+ // Normalize filters: treat empty string as no filter
894
+ const activeFilters = filters && (filters.toolType || filters.channel || filters.model) ? filters : null;
895
+
896
+ // Check cache first
897
+ const cacheKey = getTrendCacheKey({ startDate, endDate, granularity, step: String(step), groupBy, metric, filters: activeFilters || null });
898
+ const cached = trendCache.get(cacheKey);
899
+ if (cached && Date.now() < cached.expiresAt) {
900
+ return cached.result;
901
+ }
902
+
903
+ const labels = [];
904
+ const seriesMap = {}; // { dimensionName: number[] }
905
+ const totals = {};
906
+
907
+ const start = new Date(startDate + 'T00:00:00');
908
+ const end = new Date(endDate + 'T00:00:00');
909
+
910
+ // Load proxy-logs once upfront (only needed for hour granularity) to avoid
911
+ // re-reading the large file on every iteration of the inner loop.
912
+ const cachedProxyLogs = granularity === 'hour' ? loadProxyLogs() : [];
913
+
914
+ // Iterate each day
915
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
916
+ const year = d.getFullYear();
917
+ const month = d.getMonth() + 1;
918
+ const day = d.getDate();
919
+ const dateStr = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
920
+
921
+ if (granularity === 'day') {
922
+ labels.push(dateStr);
923
+ const byDimension = activeFilters
924
+ ? readJsonlForDay(year, month, day, groupBy, activeFilters)
925
+ : mergeAllToolsDailyStats(dateStr, groupBy);
926
+
927
+ // Accumulate dimensions seen so far with 0 for this label position
928
+ const labelIdx = labels.length - 1;
929
+
930
+ // Fill existing series with 0 for this position first
931
+ for (const key of Object.keys(seriesMap)) {
932
+ seriesMap[key].push(0);
933
+ }
934
+
935
+ for (const [key, stats] of Object.entries(byDimension)) {
936
+ const val = extractMetric(stats, metric);
937
+ if (!seriesMap[key]) {
938
+ // New dimension: backfill with zeros for previous labels
939
+ seriesMap[key] = new Array(labelIdx).fill(0);
940
+ seriesMap[key].push(val);
941
+ } else {
942
+ // Already pushed 0 above, replace last element
943
+ seriesMap[key][labelIdx] = val;
944
+ }
945
+ totals[key] = (totals[key] || 0) + val;
946
+ }
947
+ } else {
948
+ // granularity === 'hour'
949
+ for (let h = 0; h < 24; h += step) {
950
+ const hourEnd = Math.min(h + step, 24);
951
+ const hourStr = h.toString().padStart(2, '0');
952
+ const label = step === 1
953
+ ? `${dateStr} ${hourStr}:00`
954
+ : `${dateStr} ${hourStr}:00-${String(hourEnd).padStart(2, '0')}:00`;
955
+ labels.push(label);
956
+ const labelIdx = labels.length - 1;
957
+
958
+ // Fill existing series with 0 for this label
959
+ for (const key of Object.keys(seriesMap)) {
960
+ seriesMap[key].push(0);
961
+ }
962
+
963
+ // Accumulate all hours in this step bucket
964
+ for (let hh = h; hh < hourEnd; hh++) {
965
+ const hhStr = hh.toString().padStart(2, '0');
966
+ let byDimension = {};
967
+
968
+ // 小时粒度优先使用 proxy-logs(含 codex/gemini/opencode 实时数据),
969
+ // 若该小时没有 proxy-logs 再回退到历史统计文件/JSONL。
970
+ byDimension = filterProxyLogsForHour(cachedProxyLogs, dateStr, hh, groupBy);
971
+
972
+ if (Object.keys(byDimension).length === 0 || activeFilters) {
973
+ if (activeFilters) {
974
+ byDimension = readJsonlForHour(year, month, day, hh, groupBy, activeFilters);
975
+ } else if (groupBy === 'toolType') {
976
+ const dailyStats = aggregateDailyStatistics(dateStr);
977
+ const hourData = dailyStats?.hourly?.[hhStr];
978
+ for (const [toolType, toolStats] of Object.entries(hourData?.byToolType || {})) {
979
+ if (!hasStatsData(toolStats)) continue;
980
+ byDimension[toolType] = {
981
+ requests: toolStats.requests || 0,
982
+ tokens: { total: getTokenTotal(toolStats.tokens) },
983
+ cost: toolStats.cost || 0
984
+ };
985
+ }
986
+ } else {
987
+ byDimension = readJsonlForHour(year, month, day, hh, groupBy);
988
+ }
989
+ }
990
+
991
+ for (const [key, stats] of Object.entries(byDimension)) {
992
+ const val = extractMetric(stats, metric);
993
+ if (!seriesMap[key]) {
994
+ seriesMap[key] = new Array(labelIdx).fill(0);
995
+ seriesMap[key].push(val);
996
+ } else {
997
+ if (seriesMap[key].length <= labelIdx) seriesMap[key].push(0);
998
+ seriesMap[key][labelIdx] = (seriesMap[key][labelIdx] || 0) + val;
999
+ }
1000
+ totals[key] = (totals[key] || 0) + val;
1001
+ }
1002
+ } // end hh loop
1003
+ } // end h loop
1004
+ } // end else (hour granularity)
1005
+ } // end for day loop
1006
+
1007
+ // Sort series by total desc, keep top 10, merge rest into 'Other'
1008
+ const sorted = Object.entries(totals).sort((a, b) => b[1] - a[1]);
1009
+ const top10 = sorted.slice(0, 10);
1010
+ const rest = sorted.slice(10);
1011
+
1012
+ const series = top10.map(([name]) => ({
1013
+ name,
1014
+ data: seriesMap[name] || []
1015
+ }));
1016
+
1017
+ if (rest.length > 0) {
1018
+ const otherData = labels.map((_, i) =>
1019
+ rest.reduce((sum, [name]) => sum + (seriesMap[name]?.[i] || 0), 0)
1020
+ );
1021
+ const otherTotal = rest.reduce((sum, [, total]) => sum + total, 0);
1022
+ series.push({ name: 'Other', data: otherData });
1023
+ totals['Other'] = otherTotal;
1024
+ // Remove merged keys from totals
1025
+ for (const [name] of rest) delete totals[name];
1026
+ }
1027
+
1028
+ const result = { labels, series, totals };
1029
+
1030
+ // Store in cache
1031
+ trendCache.set(cacheKey, { result, expiresAt: Date.now() + TREND_CACHE_TTL_MS });
1032
+
1033
+ return result;
1034
+ }
1035
+
1036
+ module.exports = {
1037
+ recordRequest,
1038
+ getStatistics,
1039
+ getDailyStatistics,
1040
+ getTodayStatistics,
1041
+ getTrendStatistics,
1042
+ getAvailableFilters
1043
+ };