commons-proxy 2.0.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 (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +757 -0
  3. package/bin/cli.js +146 -0
  4. package/package.json +97 -0
  5. package/public/Complaint Details.pdf +0 -0
  6. package/public/Cyber Crime Portal.pdf +0 -0
  7. package/public/app.js +229 -0
  8. package/public/css/src/input.css +523 -0
  9. package/public/css/style.css +1 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +549 -0
  12. package/public/js/components/account-manager.js +356 -0
  13. package/public/js/components/add-account-modal.js +414 -0
  14. package/public/js/components/claude-config.js +420 -0
  15. package/public/js/components/dashboard/charts.js +605 -0
  16. package/public/js/components/dashboard/filters.js +362 -0
  17. package/public/js/components/dashboard/stats.js +110 -0
  18. package/public/js/components/dashboard.js +236 -0
  19. package/public/js/components/logs-viewer.js +100 -0
  20. package/public/js/components/models.js +36 -0
  21. package/public/js/components/server-config.js +349 -0
  22. package/public/js/config/constants.js +102 -0
  23. package/public/js/data-store.js +375 -0
  24. package/public/js/settings-store.js +58 -0
  25. package/public/js/store.js +99 -0
  26. package/public/js/translations/en.js +367 -0
  27. package/public/js/translations/id.js +412 -0
  28. package/public/js/translations/pt.js +308 -0
  29. package/public/js/translations/tr.js +358 -0
  30. package/public/js/translations/zh.js +373 -0
  31. package/public/js/utils/account-actions.js +189 -0
  32. package/public/js/utils/error-handler.js +96 -0
  33. package/public/js/utils/model-config.js +42 -0
  34. package/public/js/utils/ui-logger.js +143 -0
  35. package/public/js/utils/validators.js +77 -0
  36. package/public/js/utils.js +69 -0
  37. package/public/proxy-server-64.png +0 -0
  38. package/public/views/accounts.html +361 -0
  39. package/public/views/dashboard.html +484 -0
  40. package/public/views/logs.html +97 -0
  41. package/public/views/models.html +331 -0
  42. package/public/views/settings.html +1327 -0
  43. package/src/account-manager/credentials.js +378 -0
  44. package/src/account-manager/index.js +462 -0
  45. package/src/account-manager/onboarding.js +112 -0
  46. package/src/account-manager/rate-limits.js +369 -0
  47. package/src/account-manager/storage.js +160 -0
  48. package/src/account-manager/strategies/base-strategy.js +109 -0
  49. package/src/account-manager/strategies/hybrid-strategy.js +339 -0
  50. package/src/account-manager/strategies/index.js +79 -0
  51. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  52. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  53. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  54. package/src/account-manager/strategies/trackers/index.js +9 -0
  55. package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
  56. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
  57. package/src/auth/database.js +169 -0
  58. package/src/auth/oauth.js +548 -0
  59. package/src/auth/token-extractor.js +117 -0
  60. package/src/cli/accounts.js +648 -0
  61. package/src/cloudcode/index.js +29 -0
  62. package/src/cloudcode/message-handler.js +510 -0
  63. package/src/cloudcode/model-api.js +248 -0
  64. package/src/cloudcode/rate-limit-parser.js +235 -0
  65. package/src/cloudcode/request-builder.js +93 -0
  66. package/src/cloudcode/session-manager.js +47 -0
  67. package/src/cloudcode/sse-parser.js +121 -0
  68. package/src/cloudcode/sse-streamer.js +293 -0
  69. package/src/cloudcode/streaming-handler.js +615 -0
  70. package/src/config.js +125 -0
  71. package/src/constants.js +407 -0
  72. package/src/errors.js +242 -0
  73. package/src/fallback-config.js +29 -0
  74. package/src/format/content-converter.js +193 -0
  75. package/src/format/index.js +20 -0
  76. package/src/format/request-converter.js +255 -0
  77. package/src/format/response-converter.js +120 -0
  78. package/src/format/schema-sanitizer.js +673 -0
  79. package/src/format/signature-cache.js +88 -0
  80. package/src/format/thinking-utils.js +648 -0
  81. package/src/index.js +148 -0
  82. package/src/modules/usage-stats.js +205 -0
  83. package/src/providers/anthropic-provider.js +258 -0
  84. package/src/providers/base-provider.js +157 -0
  85. package/src/providers/cloudcode.js +94 -0
  86. package/src/providers/copilot.js +399 -0
  87. package/src/providers/github-provider.js +287 -0
  88. package/src/providers/google-provider.js +192 -0
  89. package/src/providers/index.js +211 -0
  90. package/src/providers/openai-compatible.js +265 -0
  91. package/src/providers/openai-provider.js +271 -0
  92. package/src/providers/openrouter-provider.js +325 -0
  93. package/src/providers/setup.js +83 -0
  94. package/src/server.js +870 -0
  95. package/src/utils/claude-config.js +245 -0
  96. package/src/utils/helpers.js +51 -0
  97. package/src/utils/logger.js +142 -0
  98. package/src/utils/native-module-helper.js +162 -0
  99. package/src/webui/index.js +1134 -0
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Dashboard Charts Module
3
+ * 职责:使用 Chart.js 渲染配额分布图和使用趋势图
4
+ *
5
+ * 调用时机:
6
+ * - dashboard 组件 init() 时初始化图表
7
+ * - 筛选器变化时更新图表数据
8
+ * - $store.data 更新时刷新图表
9
+ *
10
+ * 图表类型:
11
+ * 1. Quota Distribution(饼图):按模型家族或具体模型显示配额分布
12
+ * 2. Usage Trend(折线图):显示历史使用趋势
13
+ *
14
+ * 特殊处理:
15
+ * - 使用 _trendChartUpdateLock 防止并发更新导致的竞争条件
16
+ * - 通过 debounce 优化频繁更新的性能
17
+ * - 响应式处理:移动端自动调整图表大小和标签显示
18
+ *
19
+ * @module DashboardCharts
20
+ */
21
+ window.DashboardCharts = window.DashboardCharts || {};
22
+
23
+ // Helper to get CSS variable values (alias to window.utils.getThemeColor)
24
+ const getThemeColor = (name) => window.utils.getThemeColor(name);
25
+
26
+ // Color palette for different families and models
27
+ const FAMILY_COLORS = {
28
+ get claude() {
29
+ return getThemeColor("--color-neon-purple");
30
+ },
31
+ get gemini() {
32
+ return getThemeColor("--color-neon-green");
33
+ },
34
+ get gpt() {
35
+ return '#10b981';
36
+ },
37
+ get o1() {
38
+ return '#f97316';
39
+ },
40
+ get other() {
41
+ return getThemeColor("--color-neon-cyan");
42
+ },
43
+ };
44
+
45
+ const PROVIDER_COLORS = {
46
+ google: '#4285f4',
47
+ anthropic: '#d97706',
48
+ openai: '#10b981',
49
+ github: '#6366f1',
50
+ copilot: '#f97316',
51
+ openrouter: '#6d28d9'
52
+ };
53
+
54
+ const MODEL_COLORS = Array.from({ length: 16 }, (_, i) =>
55
+ getThemeColor(`--color-chart-${i + 1}`)
56
+ );
57
+
58
+ // Export constants for filter module
59
+ window.DashboardConstants = { FAMILY_COLORS, MODEL_COLORS, PROVIDER_COLORS };
60
+
61
+ // Module-level lock to prevent concurrent chart updates (fixes race condition)
62
+ let _trendChartUpdateLock = false;
63
+
64
+ /**
65
+ * Convert hex color to rgba
66
+ * @param {string} hex - Hex color string
67
+ * @param {number} alpha - Alpha value (0-1)
68
+ * @returns {string} rgba color string
69
+ */
70
+ window.DashboardCharts.hexToRgba = function (hex, alpha) {
71
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
72
+ if (result) {
73
+ return `rgba(${parseInt(result[1], 16)}, ${parseInt(
74
+ result[2],
75
+ 16
76
+ )}, ${parseInt(result[3], 16)}, ${alpha})`;
77
+ }
78
+ return hex;
79
+ };
80
+
81
+ /**
82
+ * Check if canvas is ready for Chart creation
83
+ * @param {HTMLCanvasElement} canvas - Canvas element
84
+ * @returns {boolean} True if canvas is ready
85
+ */
86
+ function isCanvasReady(canvas) {
87
+ if (!canvas || !canvas.isConnected) return false;
88
+ if (canvas.offsetWidth === 0 || canvas.offsetHeight === 0) return false;
89
+
90
+ try {
91
+ const ctx = canvas.getContext("2d");
92
+ return !!ctx;
93
+ } catch (e) {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Create a Chart.js dataset with gradient fill
100
+ * @param {string} label - Dataset label
101
+ * @param {Array} data - Data points
102
+ * @param {string} color - Line color
103
+ * @param {HTMLCanvasElement} canvas - Canvas element
104
+ * @returns {object} Chart.js dataset configuration
105
+ */
106
+ window.DashboardCharts.createDataset = function (label, data, color, canvas) {
107
+ let gradient;
108
+
109
+ try {
110
+ // Safely create gradient with fallback
111
+ if (canvas && canvas.getContext) {
112
+ const ctx = canvas.getContext("2d");
113
+ if (ctx && ctx.createLinearGradient) {
114
+ gradient = ctx.createLinearGradient(0, 0, 0, 200);
115
+ gradient.addColorStop(0, window.DashboardCharts.hexToRgba(color, 0.12));
116
+ gradient.addColorStop(
117
+ 0.6,
118
+ window.DashboardCharts.hexToRgba(color, 0.05)
119
+ );
120
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0)");
121
+ }
122
+ }
123
+ } catch (e) {
124
+ if (window.UILogger) window.UILogger.debug("Gradient fallback:", e.message);
125
+ gradient = null;
126
+ }
127
+
128
+ // Fallback to solid color if gradient creation failed
129
+ const backgroundColor =
130
+ gradient || window.DashboardCharts.hexToRgba(color, 0.08);
131
+
132
+ return {
133
+ label,
134
+ data,
135
+ borderColor: color,
136
+ backgroundColor: backgroundColor,
137
+ borderWidth: 2.5,
138
+ tension: 0.35,
139
+ fill: true,
140
+ pointRadius: 2.5,
141
+ pointHoverRadius: 6,
142
+ pointBackgroundColor: color,
143
+ pointBorderColor: "rgba(9, 9, 11, 0.8)",
144
+ pointBorderWidth: 1.5,
145
+ };
146
+ };
147
+
148
+ /**
149
+ * Update quota distribution donut chart
150
+ * @param {object} component - Dashboard component instance
151
+ */
152
+ window.DashboardCharts.updateCharts = function (component) {
153
+ const canvas = document.getElementById("quotaChart");
154
+
155
+ // Safety checks
156
+ if (!canvas) {
157
+ console.debug("quotaChart canvas not found");
158
+ return;
159
+ }
160
+
161
+ // FORCE DESTROY: Check for existing chart on the canvas element property
162
+ // This handles cases where Component state is lost but DOM persists
163
+ if (canvas._chartInstance) {
164
+ console.debug("Destroying existing quota chart from canvas property");
165
+ try {
166
+ canvas._chartInstance.destroy();
167
+ } catch(e) { if (window.UILogger) window.UILogger.debug(e); }
168
+ canvas._chartInstance = null;
169
+ }
170
+
171
+ // Also check component state as backup
172
+ if (component.charts.quotaDistribution) {
173
+ try {
174
+ component.charts.quotaDistribution.destroy();
175
+ } catch(e) { }
176
+ component.charts.quotaDistribution = null;
177
+ }
178
+
179
+ // Also try Chart.js registry
180
+ if (typeof Chart !== "undefined" && Chart.getChart) {
181
+ const regChart = Chart.getChart(canvas);
182
+ if (regChart) {
183
+ try { regChart.destroy(); } catch(e) {}
184
+ }
185
+ }
186
+
187
+ if (typeof Chart === "undefined") {
188
+ if (window.UILogger) window.UILogger.warn("Chart.js not loaded");
189
+ return;
190
+ }
191
+ if (!isCanvasReady(canvas)) {
192
+ if (window.UILogger) window.UILogger.debug("quotaChart canvas not ready, skipping update");
193
+ return;
194
+ }
195
+
196
+ // Use UNFILTERED data for global health chart
197
+ const rows = Alpine.store("data").getUnfilteredQuotaData();
198
+ if (!rows || rows.length === 0) return;
199
+
200
+ const healthByFamily = {};
201
+ let totalHealthSum = 0;
202
+ let totalModelCount = 0;
203
+
204
+ rows.forEach((row) => {
205
+ const family = row.family || "unknown";
206
+ if (!healthByFamily[family]) {
207
+ healthByFamily[family] = { total: 0, weighted: 0 };
208
+ }
209
+
210
+ // Calculate average health from quotaInfo (each entry has { pct })
211
+ // Health = average of all account quotas for this model
212
+ const quotaInfo = row.quotaInfo || [];
213
+ let avgHealth = 0;
214
+
215
+ if (quotaInfo.length > 0) {
216
+ avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
217
+ }
218
+ // If quotaInfo is empty, avgHealth remains 0 (depleted/unknown)
219
+
220
+ healthByFamily[family].total++;
221
+ healthByFamily[family].weighted += avgHealth;
222
+ totalHealthSum += avgHealth;
223
+ totalModelCount++;
224
+ });
225
+
226
+ // Update overall health for dashboard display
227
+ component.stats.overallHealth = totalModelCount > 0
228
+ ? Math.round(totalHealthSum / totalModelCount)
229
+ : 0;
230
+
231
+ const familyColors = {
232
+ claude: getThemeColor("--color-neon-purple") || "#a855f7",
233
+ gemini: getThemeColor("--color-neon-green") || "#22c55e",
234
+ unknown: getThemeColor("--color-neon-cyan") || "#06b6d4",
235
+ };
236
+
237
+ const data = [];
238
+ const colors = [];
239
+ const labels = [];
240
+
241
+ const totalFamilies = Object.keys(healthByFamily).length;
242
+ const segmentSize = 100 / totalFamilies;
243
+
244
+ Object.entries(healthByFamily).forEach(([family, { total, weighted }]) => {
245
+ const health = weighted / total;
246
+ const activeVal = (health / 100) * segmentSize;
247
+ const inactiveVal = segmentSize - activeVal;
248
+
249
+ const familyColor = familyColors[family] || familyColors["unknown"];
250
+
251
+ // Get translation keys
252
+ const store = Alpine.store("global");
253
+ const familyKey =
254
+ "family" + family.charAt(0).toUpperCase() + family.slice(1);
255
+ const familyName = store.t(familyKey);
256
+
257
+ // Labels using translations if possible
258
+ const activeLabel =
259
+ family === "claude"
260
+ ? store.t("claudeActive")
261
+ : family === "gemini"
262
+ ? store.t("geminiActive")
263
+ : `${familyName} ${store.t("activeSuffix")}`;
264
+
265
+ const depletedLabel =
266
+ family === "claude"
267
+ ? store.t("claudeEmpty")
268
+ : family === "gemini"
269
+ ? store.t("geminiEmpty")
270
+ : `${familyName} ${store.t("depleted")}`;
271
+
272
+ // Active segment
273
+ data.push(activeVal);
274
+ colors.push(familyColor);
275
+ labels.push(activeLabel);
276
+
277
+ // Inactive segment
278
+ data.push(inactiveVal);
279
+ // Use higher opacity (0.6) to ensure the ring color matches the legend more closely
280
+ // while still differentiating "depleted" from "active" (1.0 opacity)
281
+ colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.6));
282
+ labels.push(depletedLabel);
283
+ });
284
+
285
+ // Create Chart
286
+ try {
287
+ const newChart = new Chart(canvas, {
288
+ // ... config
289
+ type: "doughnut",
290
+ data: {
291
+ labels: labels,
292
+ datasets: [
293
+ {
294
+ data: data,
295
+ backgroundColor: colors,
296
+ borderColor: getThemeColor("--color-space-950"),
297
+ borderWidth: 0,
298
+ hoverOffset: 0,
299
+ borderRadius: 0,
300
+ },
301
+ ],
302
+ },
303
+ options: {
304
+ responsive: true,
305
+ maintainAspectRatio: false,
306
+ cutout: "85%",
307
+ rotation: -90,
308
+ circumference: 360,
309
+ plugins: {
310
+ legend: { display: false },
311
+ tooltip: { enabled: false },
312
+ title: { display: false },
313
+ },
314
+ animation: {
315
+ // Disable animation for quota chart to prevent "double refresh" visual glitch
316
+ duration: 0
317
+ },
318
+ },
319
+ });
320
+
321
+ // SAVE INSTANCE TO CANVAS AND COMPONENT
322
+ canvas._chartInstance = newChart;
323
+ component.charts.quotaDistribution = newChart;
324
+
325
+ } catch (e) {
326
+ console.error("Failed to create quota chart:", e);
327
+ }
328
+ };
329
+
330
+ /**
331
+ * Update usage trend line chart
332
+ * @param {object} component - Dashboard component instance
333
+ */
334
+ window.DashboardCharts.updateTrendChart = function (component) {
335
+ // Prevent concurrent updates (fixes race condition on rapid toggling)
336
+ if (_trendChartUpdateLock) {
337
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Update already in progress, skipping");
338
+ return;
339
+ }
340
+ _trendChartUpdateLock = true;
341
+
342
+ const logger = window.UILogger || console;
343
+ logger.debug("[updateTrendChart] Starting update...");
344
+
345
+ const canvas = document.getElementById("usageTrendChart");
346
+
347
+ // FORCE DESTROY: Check for existing chart on the canvas element property
348
+ if (canvas) {
349
+ if (canvas._chartInstance) {
350
+ console.debug("Destroying existing trend chart from canvas property");
351
+ try {
352
+ canvas._chartInstance.stop();
353
+ canvas._chartInstance.destroy();
354
+ } catch(e) { if (window.UILogger) window.UILogger.debug(e); }
355
+ canvas._chartInstance = null;
356
+ }
357
+
358
+ // Also try Chart.js registry
359
+ if (typeof Chart !== "undefined" && Chart.getChart) {
360
+ const regChart = Chart.getChart(canvas);
361
+ if (regChart) {
362
+ try { regChart.stop(); regChart.destroy(); } catch(e) {}
363
+ }
364
+ }
365
+ }
366
+
367
+ // Also check component state
368
+ if (component.charts.usageTrend) {
369
+ try {
370
+ component.charts.usageTrend.stop();
371
+ component.charts.usageTrend.destroy();
372
+ } catch (e) { }
373
+ component.charts.usageTrend = null;
374
+ }
375
+
376
+ // Safety checks
377
+ if (!canvas) {
378
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas not found in DOM");
379
+ _trendChartUpdateLock = false;
380
+ return;
381
+ }
382
+ if (typeof Chart === "undefined") {
383
+ if (window.UILogger) window.UILogger.warn("[updateTrendChart] Chart.js not loaded");
384
+ _trendChartUpdateLock = false;
385
+ return;
386
+ }
387
+
388
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas element:", {
389
+ exists: !!canvas,
390
+ isConnected: canvas.isConnected,
391
+ width: canvas.offsetWidth,
392
+ height: canvas.offsetHeight,
393
+ parentElement: canvas.parentElement?.tagName,
394
+ });
395
+
396
+ if (!isCanvasReady(canvas)) {
397
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas not ready", {
398
+ isConnected: canvas.isConnected,
399
+ width: canvas.offsetWidth,
400
+ height: canvas.offsetHeight,
401
+ });
402
+ _trendChartUpdateLock = false;
403
+ return;
404
+ }
405
+
406
+ // Clear canvas to ensure clean state after destroy
407
+ try {
408
+ const ctx = canvas.getContext("2d");
409
+ if (ctx) {
410
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
411
+ }
412
+ } catch (e) {
413
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Failed to clear canvas:", e.message);
414
+ }
415
+
416
+ if (window.UILogger) window.UILogger.debug(
417
+ "[updateTrendChart] Canvas is ready, proceeding with chart creation"
418
+ );
419
+
420
+ // Use filtered history data based on time range
421
+ const history = window.DashboardFilters.getFilteredHistoryData(component);
422
+ if (!history || Object.keys(history).length === 0) {
423
+ if (window.UILogger) window.UILogger.debug("No history data available for trend chart (after filtering)");
424
+ component.hasFilteredTrendData = false;
425
+ _trendChartUpdateLock = false;
426
+ return;
427
+ }
428
+
429
+ component.hasFilteredTrendData = true;
430
+
431
+ // Sort entries by timestamp for correct order
432
+ const sortedEntries = Object.entries(history).sort(
433
+ ([a], [b]) => new Date(a).getTime() - new Date(b).getTime()
434
+ );
435
+
436
+ // Determine if data spans multiple days (for smart label formatting)
437
+ const timestamps = sortedEntries.map(([iso]) => new Date(iso));
438
+ const isMultiDay = timestamps.length > 1 &&
439
+ timestamps[0].toDateString() !== timestamps[timestamps.length - 1].toDateString();
440
+
441
+ // Helper to format X-axis labels based on time range and multi-day status
442
+ const formatLabel = (date) => {
443
+ const timeRange = component.timeRange || '24h';
444
+
445
+ if (timeRange === '7d') {
446
+ // Week view: show MM/DD
447
+ return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' });
448
+ } else if (isMultiDay || timeRange === 'all') {
449
+ // Multi-day data: show MM/DD HH:MM
450
+ return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' }) + ' ' +
451
+ date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
452
+ } else {
453
+ // Same day: show HH:MM only
454
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
455
+ }
456
+ };
457
+
458
+ const labels = [];
459
+ const datasets = [];
460
+
461
+ if (component.displayMode === "family") {
462
+ // Aggregate by family
463
+ const dataByFamily = {};
464
+ component.selectedFamilies.forEach((family) => {
465
+ dataByFamily[family] = [];
466
+ });
467
+
468
+ sortedEntries.forEach(([iso, hourData]) => {
469
+ const date = new Date(iso);
470
+ labels.push(formatLabel(date));
471
+
472
+ component.selectedFamilies.forEach((family) => {
473
+ const familyData = hourData[family];
474
+ const count = familyData?._subtotal || 0;
475
+ dataByFamily[family].push(count);
476
+ });
477
+ });
478
+
479
+ // Build datasets for families
480
+ component.selectedFamilies.forEach((family) => {
481
+ const color = window.DashboardFilters.getFamilyColor(family);
482
+ const familyKey =
483
+ "family" + family.charAt(0).toUpperCase() + family.slice(1);
484
+ const label = Alpine.store("global").t(familyKey);
485
+ datasets.push(
486
+ window.DashboardCharts.createDataset(
487
+ label,
488
+ dataByFamily[family],
489
+ color,
490
+ canvas
491
+ )
492
+ );
493
+ });
494
+ } else {
495
+ // Show individual models
496
+ const dataByModel = {};
497
+
498
+ // Initialize data arrays
499
+ component.families.forEach((family) => {
500
+ (component.selectedModels[family] || []).forEach((model) => {
501
+ const key = `${family}:${model}`;
502
+ dataByModel[key] = [];
503
+ });
504
+ });
505
+
506
+ sortedEntries.forEach(([iso, hourData]) => {
507
+ const date = new Date(iso);
508
+ labels.push(formatLabel(date));
509
+
510
+ component.families.forEach((family) => {
511
+ const familyData = hourData[family] || {};
512
+ (component.selectedModels[family] || []).forEach((model) => {
513
+ const key = `${family}:${model}`;
514
+ dataByModel[key].push(familyData[model] || 0);
515
+ });
516
+ });
517
+ });
518
+
519
+ // Build datasets for models
520
+ component.families.forEach((family) => {
521
+ (component.selectedModels[family] || []).forEach((model, modelIndex) => {
522
+ const key = `${family}:${model}`;
523
+ const color = window.DashboardFilters.getModelColor(family, modelIndex);
524
+ datasets.push(
525
+ window.DashboardCharts.createDataset(
526
+ model,
527
+ dataByModel[key],
528
+ color,
529
+ canvas
530
+ )
531
+ );
532
+ });
533
+ });
534
+ }
535
+
536
+ try {
537
+ const newChart = new Chart(canvas, {
538
+ type: "line",
539
+ data: { labels, datasets },
540
+ options: {
541
+ responsive: true,
542
+ maintainAspectRatio: false,
543
+ animation: {
544
+ duration: 300, // Reduced animation for faster updates
545
+ },
546
+ interaction: {
547
+ mode: "index",
548
+ intersect: false,
549
+ },
550
+ plugins: {
551
+ legend: { display: false },
552
+ tooltip: {
553
+ backgroundColor:
554
+ getThemeColor("--color-space-950") || "rgba(24, 24, 27, 0.9)",
555
+ titleColor: getThemeColor("--color-text-main"),
556
+ bodyColor: getThemeColor("--color-text-bright"),
557
+ borderColor: getThemeColor("--color-space-border"),
558
+ borderWidth: 1,
559
+ padding: 10,
560
+ displayColors: true,
561
+ callbacks: {
562
+ label: function (context) {
563
+ return context.dataset.label + ": " + context.parsed.y;
564
+ },
565
+ },
566
+ },
567
+ },
568
+ scales: {
569
+ x: {
570
+ display: true,
571
+ grid: { display: false },
572
+ ticks: {
573
+ color: getThemeColor("--color-text-muted"),
574
+ font: { size: 10 },
575
+ },
576
+ },
577
+ y: {
578
+ display: true,
579
+ beginAtZero: true,
580
+ grid: {
581
+ display: true,
582
+ color:
583
+ getThemeColor("--color-space-border") + "1a" ||
584
+ "rgba(255,255,255,0.05)",
585
+ },
586
+ ticks: {
587
+ color: getThemeColor("--color-text-muted"),
588
+ font: { size: 10 },
589
+ },
590
+ },
591
+ },
592
+ },
593
+ });
594
+
595
+ // SAVE INSTANCE
596
+ canvas._chartInstance = newChart;
597
+ component.charts.usageTrend = newChart;
598
+
599
+ } catch (e) {
600
+ console.error("Failed to create trend chart:", e);
601
+ } finally {
602
+ // Always release lock
603
+ _trendChartUpdateLock = false;
604
+ }
605
+ };