claude-code-limiter-server 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.
@@ -0,0 +1,388 @@
1
+ /**
2
+ * charts.js — Canvas-based charts for the Claude Code Limiter dashboard.
3
+ * No external libraries. Uses Canvas 2D API directly.
4
+ *
5
+ * Exports on window.Charts:
6
+ * horizontalBar(canvas, data)
7
+ * creditGauge(canvas, used, total)
8
+ * trendLine(canvas, dataPoints)
9
+ */
10
+ (function () {
11
+ 'use strict';
12
+
13
+ /* ---- Color helpers matching the dark theme ---- */
14
+ var COLORS = {
15
+ bg: '#0d1117',
16
+ cardBg: '#161b22',
17
+ border: '#30363d',
18
+ textPrimary: '#e6edf3',
19
+ textMuted: '#6e7681',
20
+ green: '#3fb950',
21
+ yellow: '#d29922',
22
+ red: '#f85149',
23
+ blue: '#58a6ff',
24
+ purple: '#bc8cff',
25
+ orange: '#f0883e',
26
+ track: '#1c2129',
27
+ };
28
+
29
+ var MODEL_COLORS = {
30
+ opus: COLORS.purple,
31
+ sonnet: COLORS.blue,
32
+ haiku: COLORS.green,
33
+ default: COLORS.orange,
34
+ };
35
+
36
+ /**
37
+ * Get device pixel ratio for crisp rendering.
38
+ * @returns {number}
39
+ */
40
+ function dpr() {
41
+ return window.devicePixelRatio || 1;
42
+ }
43
+
44
+ /**
45
+ * Set up a canvas for high-DPI rendering. Returns the 2D context.
46
+ * @param {HTMLCanvasElement} canvas
47
+ * @param {number} cssW - CSS width
48
+ * @param {number} cssH - CSS height
49
+ * @returns {CanvasRenderingContext2D}
50
+ */
51
+ function setupCanvas(canvas, cssW, cssH) {
52
+ var ratio = dpr();
53
+ canvas.width = cssW * ratio;
54
+ canvas.height = cssH * ratio;
55
+ canvas.style.width = cssW + 'px';
56
+ canvas.style.height = cssH + 'px';
57
+ var ctx = canvas.getContext('2d');
58
+ ctx.scale(ratio, ratio);
59
+ return ctx;
60
+ }
61
+
62
+ /**
63
+ * Pick a color for a usage percentage.
64
+ * @param {number} pct - 0..1
65
+ * @returns {string}
66
+ */
67
+ function usageColor(pct) {
68
+ if (pct >= 0.9) return COLORS.red;
69
+ if (pct >= 0.7) return COLORS.yellow;
70
+ return COLORS.green;
71
+ }
72
+
73
+ /**
74
+ * Draw a rounded rectangle path.
75
+ * @param {CanvasRenderingContext2D} ctx
76
+ * @param {number} x
77
+ * @param {number} y
78
+ * @param {number} w
79
+ * @param {number} h
80
+ * @param {number} r - corner radius
81
+ */
82
+ function roundRect(ctx, x, y, w, h, r) {
83
+ if (w < 0) w = 0;
84
+ if (r > h / 2) r = h / 2;
85
+ if (r > w / 2) r = w / 2;
86
+ ctx.beginPath();
87
+ ctx.moveTo(x + r, y);
88
+ ctx.lineTo(x + w - r, y);
89
+ ctx.arcTo(x + w, y, x + w, y + r, r);
90
+ ctx.lineTo(x + w, y + h - r);
91
+ ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
92
+ ctx.lineTo(x + r, y + h);
93
+ ctx.arcTo(x, y + h, x, y + h - r, r);
94
+ ctx.lineTo(x, y + r);
95
+ ctx.arcTo(x, y, x + r, y, r);
96
+ ctx.closePath();
97
+ }
98
+
99
+ /* ================================================================
100
+ horizontalBar(canvas, data)
101
+ Renders per-model usage bars with limit markers.
102
+ data: [{ label, value, limit, color? }]
103
+ ================================================================ */
104
+ function horizontalBar(canvas, data) {
105
+ if (!canvas || !data || data.length === 0) return;
106
+
107
+ var barHeight = 22;
108
+ var barGap = 32;
109
+ var labelWidth = 70;
110
+ var valueWidth = 80;
111
+ var padding = { top: 8, right: 12, bottom: 8, left: 4 };
112
+ var totalH = padding.top + data.length * (barHeight + barGap) - barGap + padding.bottom;
113
+ var cssW = canvas.parentElement ? canvas.parentElement.clientWidth : 400;
114
+ if (cssW < 200) cssW = 400;
115
+ var cssH = totalH;
116
+
117
+ var ctx = setupCanvas(canvas, cssW, cssH);
118
+ var barAreaW = cssW - labelWidth - valueWidth - padding.left - padding.right;
119
+
120
+ // Find max for scale
121
+ var maxVal = 0;
122
+ for (var i = 0; i < data.length; i++) {
123
+ var cmp = data[i].limit > 0 ? data[i].limit : data[i].value;
124
+ if (cmp > maxVal) maxVal = cmp;
125
+ }
126
+ if (maxVal === 0) maxVal = 1;
127
+
128
+ for (var j = 0; j < data.length; j++) {
129
+ var item = data[j];
130
+ var y = padding.top + j * (barHeight + barGap);
131
+ var barX = padding.left + labelWidth;
132
+
133
+ // Label
134
+ ctx.fillStyle = COLORS.textMuted;
135
+ ctx.font = '500 12px system-ui, sans-serif';
136
+ ctx.textAlign = 'right';
137
+ ctx.textBaseline = 'middle';
138
+ ctx.fillText(item.label, barX - 10, y + barHeight / 2);
139
+
140
+ // Track
141
+ roundRect(ctx, barX, y, barAreaW, barHeight, 4);
142
+ ctx.fillStyle = COLORS.track;
143
+ ctx.fill();
144
+
145
+ // Fill
146
+ var pct = item.limit > 0 ? item.value / item.limit : (item.value > 0 ? 1 : 0);
147
+ if (pct > 1) pct = 1;
148
+ var fillW = barAreaW * pct;
149
+ if (fillW > 0) {
150
+ roundRect(ctx, barX, y, fillW, barHeight, 4);
151
+ var grad = ctx.createLinearGradient(barX, 0, barX + fillW, 0);
152
+ var barColor = item.color || MODEL_COLORS[item.label.toLowerCase()] || COLORS.blue;
153
+ grad.addColorStop(0, barColor);
154
+ grad.addColorStop(1, adjustAlpha(barColor, 0.7));
155
+ ctx.fillStyle = grad;
156
+ ctx.fill();
157
+ }
158
+
159
+ // Limit marker
160
+ if (item.limit > 0) {
161
+ var markerX = barX + barAreaW * (item.limit / maxVal);
162
+ if (markerX > barX + barAreaW) markerX = barX + barAreaW;
163
+ ctx.strokeStyle = COLORS.textPrimary;
164
+ ctx.lineWidth = 1.5;
165
+ ctx.beginPath();
166
+ ctx.moveTo(markerX, y - 3);
167
+ ctx.lineTo(markerX, y + barHeight + 3);
168
+ ctx.stroke();
169
+ }
170
+
171
+ // Value text
172
+ ctx.fillStyle = COLORS.textPrimary;
173
+ ctx.font = '600 12px "SF Mono", "Fira Code", monospace';
174
+ ctx.textAlign = 'left';
175
+ ctx.textBaseline = 'middle';
176
+ var valStr = item.value + (item.limit > 0 ? '/' + item.limit : (item.limit === -1 ? '/inf' : ''));
177
+ ctx.fillText(valStr, barX + barAreaW + 10, y + barHeight / 2);
178
+ }
179
+ }
180
+
181
+ /* ================================================================
182
+ creditGauge(canvas, used, total)
183
+ Renders a circular progress indicator showing credit usage.
184
+ ================================================================ */
185
+ function creditGauge(canvas, used, total) {
186
+ if (!canvas) return;
187
+
188
+ var size = 180;
189
+ var ctx = setupCanvas(canvas, size, size);
190
+ var cx = size / 2;
191
+ var cy = size / 2;
192
+ var radius = 68;
193
+ var lineWidth = 14;
194
+
195
+ var pct = total > 0 ? used / total : 0;
196
+ if (pct > 1) pct = 1;
197
+ var remaining = total - used;
198
+ if (remaining < 0) remaining = 0;
199
+
200
+ // Start angle at top (-PI/2), go clockwise
201
+ var startAngle = -Math.PI / 2;
202
+ var endAngle = startAngle + 2 * Math.PI * pct;
203
+
204
+ // Track
205
+ ctx.beginPath();
206
+ ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
207
+ ctx.strokeStyle = COLORS.track;
208
+ ctx.lineWidth = lineWidth;
209
+ ctx.lineCap = 'round';
210
+ ctx.stroke();
211
+
212
+ // Filled arc
213
+ if (pct > 0) {
214
+ ctx.beginPath();
215
+ ctx.arc(cx, cy, radius, startAngle, endAngle);
216
+ var color = usageColor(pct);
217
+ ctx.strokeStyle = color;
218
+ ctx.lineWidth = lineWidth;
219
+ ctx.lineCap = 'round';
220
+ ctx.stroke();
221
+ }
222
+
223
+ // Center text — remaining credits
224
+ ctx.fillStyle = COLORS.textPrimary;
225
+ ctx.font = '700 28px system-ui, sans-serif';
226
+ ctx.textAlign = 'center';
227
+ ctx.textBaseline = 'middle';
228
+ ctx.fillText(String(remaining), cx, cy - 6);
229
+
230
+ ctx.fillStyle = COLORS.textMuted;
231
+ ctx.font = '500 11px system-ui, sans-serif';
232
+ ctx.fillText('credits left', cx, cy + 16);
233
+
234
+ // Bottom label
235
+ ctx.fillStyle = COLORS.textMuted;
236
+ ctx.font = '500 10px system-ui, sans-serif';
237
+ ctx.fillText(used + ' / ' + total + ' used', cx, cy + 36);
238
+ }
239
+
240
+ /* ================================================================
241
+ trendLine(canvas, dataPoints)
242
+ Renders a 30-day daily usage sparkline.
243
+ dataPoints: [{ day: 'YYYY-MM-DD', value: number }]
244
+ ================================================================ */
245
+ function trendLine(canvas, dataPoints) {
246
+ if (!canvas || !dataPoints || dataPoints.length === 0) return;
247
+
248
+ var cssW = canvas.parentElement ? canvas.parentElement.clientWidth : 400;
249
+ if (cssW < 200) cssW = 400;
250
+ var cssH = 140;
251
+ var ctx = setupCanvas(canvas, cssW, cssH);
252
+
253
+ var padding = { top: 16, right: 16, bottom: 28, left: 40 };
254
+ var plotW = cssW - padding.left - padding.right;
255
+ var plotH = cssH - padding.top - padding.bottom;
256
+
257
+ // Find max
258
+ var maxVal = 0;
259
+ for (var i = 0; i < dataPoints.length; i++) {
260
+ if (dataPoints[i].value > maxVal) maxVal = dataPoints[i].value;
261
+ }
262
+ if (maxVal === 0) maxVal = 1;
263
+ // Round up to nice number
264
+ var gridMax = Math.ceil(maxVal / 5) * 5;
265
+ if (gridMax < 5) gridMax = 5;
266
+
267
+ // Horizontal grid lines
268
+ var gridLines = 4;
269
+ ctx.strokeStyle = COLORS.border;
270
+ ctx.lineWidth = 0.5;
271
+ ctx.fillStyle = COLORS.textMuted;
272
+ ctx.font = '500 10px system-ui, sans-serif';
273
+ ctx.textAlign = 'right';
274
+ ctx.textBaseline = 'middle';
275
+
276
+ for (var g = 0; g <= gridLines; g++) {
277
+ var gy = padding.top + plotH - (g / gridLines) * plotH;
278
+ ctx.beginPath();
279
+ ctx.moveTo(padding.left, gy);
280
+ ctx.lineTo(padding.left + plotW, gy);
281
+ ctx.stroke();
282
+ var gridVal = Math.round((g / gridLines) * gridMax);
283
+ ctx.fillText(String(gridVal), padding.left - 6, gy);
284
+ }
285
+
286
+ // Plot points
287
+ var pts = [];
288
+ for (var k = 0; k < dataPoints.length; k++) {
289
+ var px = padding.left + (k / (dataPoints.length - 1 || 1)) * plotW;
290
+ var py = padding.top + plotH - (dataPoints[k].value / gridMax) * plotH;
291
+ pts.push({ x: px, y: py });
292
+ }
293
+
294
+ // Fill area under line
295
+ if (pts.length > 1) {
296
+ ctx.beginPath();
297
+ ctx.moveTo(pts[0].x, padding.top + plotH);
298
+ for (var m = 0; m < pts.length; m++) {
299
+ ctx.lineTo(pts[m].x, pts[m].y);
300
+ }
301
+ ctx.lineTo(pts[pts.length - 1].x, padding.top + plotH);
302
+ ctx.closePath();
303
+ var areaGrad = ctx.createLinearGradient(0, padding.top, 0, padding.top + plotH);
304
+ areaGrad.addColorStop(0, 'rgba(88,166,255,0.2)');
305
+ areaGrad.addColorStop(1, 'rgba(88,166,255,0.02)');
306
+ ctx.fillStyle = areaGrad;
307
+ ctx.fill();
308
+ }
309
+
310
+ // Line
311
+ if (pts.length > 1) {
312
+ ctx.beginPath();
313
+ ctx.moveTo(pts[0].x, pts[0].y);
314
+ for (var n = 1; n < pts.length; n++) {
315
+ ctx.lineTo(pts[n].x, pts[n].y);
316
+ }
317
+ ctx.strokeStyle = COLORS.blue;
318
+ ctx.lineWidth = 2;
319
+ ctx.lineJoin = 'round';
320
+ ctx.lineCap = 'round';
321
+ ctx.stroke();
322
+ }
323
+
324
+ // Dots
325
+ for (var d = 0; d < pts.length; d++) {
326
+ ctx.beginPath();
327
+ ctx.arc(pts[d].x, pts[d].y, 3, 0, 2 * Math.PI);
328
+ ctx.fillStyle = COLORS.blue;
329
+ ctx.fill();
330
+ }
331
+
332
+ // X-axis labels (show first, middle, last)
333
+ ctx.fillStyle = COLORS.textMuted;
334
+ ctx.font = '500 9px system-ui, sans-serif';
335
+ ctx.textBaseline = 'top';
336
+ var labelY = padding.top + plotH + 8;
337
+
338
+ if (dataPoints.length > 0) {
339
+ ctx.textAlign = 'left';
340
+ ctx.fillText(formatDay(dataPoints[0].day), pts[0].x, labelY);
341
+ }
342
+ if (dataPoints.length > 2) {
343
+ var midIdx = Math.floor(dataPoints.length / 2);
344
+ ctx.textAlign = 'center';
345
+ ctx.fillText(formatDay(dataPoints[midIdx].day), pts[midIdx].x, labelY);
346
+ }
347
+ if (dataPoints.length > 1) {
348
+ ctx.textAlign = 'right';
349
+ ctx.fillText(formatDay(dataPoints[dataPoints.length - 1].day), pts[pts.length - 1].x, labelY);
350
+ }
351
+ }
352
+
353
+ /* ---- Helpers ---- */
354
+
355
+ /**
356
+ * Adjust a hex color's alpha. Returns an rgba() string.
357
+ * @param {string} hex
358
+ * @param {number} alpha
359
+ * @returns {string}
360
+ */
361
+ function adjustAlpha(hex, alpha) {
362
+ var r = parseInt(hex.slice(1, 3), 16);
363
+ var g = parseInt(hex.slice(3, 5), 16);
364
+ var b = parseInt(hex.slice(5, 7), 16);
365
+ return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
366
+ }
367
+
368
+ /**
369
+ * Format a YYYY-MM-DD string as a short label.
370
+ * @param {string} day
371
+ * @returns {string}
372
+ */
373
+ function formatDay(day) {
374
+ if (!day) return '';
375
+ var parts = day.split('-');
376
+ if (parts.length < 3) return day;
377
+ return parts[1] + '/' + parts[2];
378
+ }
379
+
380
+ /* ---- Public API ---- */
381
+ window.Charts = {
382
+ horizontalBar: horizontalBar,
383
+ creditGauge: creditGauge,
384
+ trendLine: trendLine,
385
+ COLORS: COLORS,
386
+ MODEL_COLORS: MODEL_COLORS,
387
+ };
388
+ })();
@@ -0,0 +1,172 @@
1
+ /**
2
+ * ws.js — WebSocket client for the Claude Code Limiter dashboard.
3
+ * Auto-reconnect with exponential backoff (max 30s).
4
+ *
5
+ * Exports on window.WS:
6
+ * connect()
7
+ * disconnect()
8
+ * onEvent(callback) — register handler for all server events
9
+ * isConnected()
10
+ */
11
+ (function () {
12
+ 'use strict';
13
+
14
+ var socket = null;
15
+ var listeners = [];
16
+ var reconnectTimer = null;
17
+ var reconnectDelay = 1000; // ms, grows exponentially
18
+ var MAX_RECONNECT_DELAY = 30000;
19
+ var intentionalClose = false;
20
+
21
+ /**
22
+ * Build the WebSocket URL based on the current page location.
23
+ * @returns {string}
24
+ */
25
+ function buildUrl() {
26
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
27
+ return proto + '//' + location.host + '/ws';
28
+ }
29
+
30
+ /**
31
+ * Update the connection indicator dots in the UI.
32
+ * @param {'connected'|'connecting'|'disconnected'} state
33
+ */
34
+ function setIndicator(state) {
35
+ var dots = document.querySelectorAll('.ws-dot');
36
+ for (var i = 0; i < dots.length; i++) {
37
+ dots[i].className = 'ws-dot ' + state;
38
+ }
39
+ var label = document.getElementById('ws-label');
40
+ if (label) {
41
+ var labels = {
42
+ connected: 'Live',
43
+ connecting: 'Reconnecting...',
44
+ disconnected: 'Disconnected',
45
+ };
46
+ label.textContent = labels[state] || state;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Notify all registered listeners of an event.
52
+ * @param {object} event
53
+ */
54
+ function emit(event) {
55
+ for (var i = 0; i < listeners.length; i++) {
56
+ try {
57
+ listeners[i](event);
58
+ } catch (err) {
59
+ console.error('[WS] Listener error:', err);
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Open a WebSocket connection. Automatically reconnects on close.
66
+ */
67
+ function connect() {
68
+ if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
69
+ return;
70
+ }
71
+
72
+ intentionalClose = false;
73
+ setIndicator('connecting');
74
+
75
+ try {
76
+ socket = new WebSocket(buildUrl());
77
+ } catch (err) {
78
+ console.error('[WS] Failed to create WebSocket:', err);
79
+ scheduleReconnect();
80
+ return;
81
+ }
82
+
83
+ socket.onopen = function () {
84
+ reconnectDelay = 1000; // reset backoff on successful connection
85
+ setIndicator('connected');
86
+ emit({ type: 'ws_connected', timestamp: new Date().toISOString() });
87
+ };
88
+
89
+ socket.onmessage = function (evt) {
90
+ var data;
91
+ try {
92
+ data = JSON.parse(evt.data);
93
+ } catch (err) {
94
+ console.warn('[WS] Non-JSON message:', evt.data);
95
+ return;
96
+ }
97
+ emit(data);
98
+ };
99
+
100
+ socket.onclose = function (evt) {
101
+ socket = null;
102
+ if (!intentionalClose) {
103
+ setIndicator('disconnected');
104
+ emit({ type: 'ws_disconnected', timestamp: new Date().toISOString() });
105
+ scheduleReconnect();
106
+ }
107
+ };
108
+
109
+ socket.onerror = function () {
110
+ // onclose will fire after onerror, so reconnect is handled there
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Schedule a reconnection attempt with exponential backoff.
116
+ */
117
+ function scheduleReconnect() {
118
+ if (reconnectTimer) return;
119
+ setIndicator('connecting');
120
+ reconnectTimer = setTimeout(function () {
121
+ reconnectTimer = null;
122
+ connect();
123
+ }, reconnectDelay);
124
+ // Exponential backoff, capped at MAX_RECONNECT_DELAY
125
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
126
+ }
127
+
128
+ /**
129
+ * Intentionally close the WebSocket (e.g. on logout). No auto-reconnect.
130
+ */
131
+ function disconnect() {
132
+ intentionalClose = true;
133
+ if (reconnectTimer) {
134
+ clearTimeout(reconnectTimer);
135
+ reconnectTimer = null;
136
+ }
137
+ if (socket) {
138
+ socket.close();
139
+ socket = null;
140
+ }
141
+ setIndicator('disconnected');
142
+ }
143
+
144
+ /**
145
+ * Register a callback for all WebSocket events from the server.
146
+ * @param {function(object):void} callback
147
+ * @returns {function} unsubscribe function
148
+ */
149
+ function onEvent(callback) {
150
+ listeners.push(callback);
151
+ return function unsubscribe() {
152
+ var idx = listeners.indexOf(callback);
153
+ if (idx !== -1) listeners.splice(idx, 1);
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Check if the WebSocket is currently open.
159
+ * @returns {boolean}
160
+ */
161
+ function isConnected() {
162
+ return socket !== null && socket.readyState === WebSocket.OPEN;
163
+ }
164
+
165
+ /* ---- Public API ---- */
166
+ window.WS = {
167
+ connect: connect,
168
+ disconnect: disconnect,
169
+ onEvent: onEvent,
170
+ isConnected: isConnected,
171
+ };
172
+ })();