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