agent-tank 0.9.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,321 @@
1
+ // Client-side JavaScript for auto-refresh system
2
+ // This module exports a string containing the XHR-based auto-refresh code
3
+
4
+ const { colorHelpers, timeHelpers, metricExtractors } = require('./client-auto-refresh-helpers');
5
+
6
+ const autoRefreshScript = `
7
+ // ===== Auto-Refresh System (XHR-based) =====
8
+ // Fetches /status endpoint and updates the DOM without page reload
9
+
10
+ ${colorHelpers}
11
+ ${timeHelpers}
12
+ ${metricExtractors}
13
+
14
+ // Update a single metric's DOM elements
15
+ function updateMetricDOM(metric) {
16
+ const { metricId, percent, resetsIn, cycle } = metric;
17
+ const color = getProgressColorFromPercent(percent);
18
+ const colorClass = getColorClassFromPercent(percent);
19
+ const statusDotClass = getStatusDotClassFromPercent(percent);
20
+ const isZero = percent === 0;
21
+
22
+ // Find the usage-item row and track button
23
+ const usageItem = document.querySelector(\`.usage-item[data-metric-id="\${metricId}"]\`);
24
+ const trackBtn = document.querySelector(\`.track-btn[data-metric-id="\${metricId}"]\`);
25
+
26
+ if (usageItem) {
27
+ // Update data attributes
28
+ usageItem.dataset.percent = percent;
29
+ usageItem.dataset.color = color;
30
+ usageItem.dataset.resetsIn = resetsIn;
31
+
32
+ // Update zero-usage class
33
+ if (isZero) {
34
+ usageItem.classList.add('zero-usage');
35
+ } else {
36
+ usageItem.classList.remove('zero-usage');
37
+ }
38
+
39
+ // Update the percentage text
40
+ const percentSpan = usageItem.querySelector('.usage-percent');
41
+ if (percentSpan) {
42
+ percentSpan.textContent = percent;
43
+ }
44
+
45
+ // Update the color class on usage-value
46
+ const usageValue = usageItem.querySelector('.usage-value');
47
+ if (usageValue) {
48
+ usageValue.classList.remove('high', 'medium', 'low');
49
+ usageValue.classList.add(colorClass);
50
+ }
51
+
52
+ // Update the status dot
53
+ const statusDot = usageItem.querySelector('.status-dot');
54
+ if (statusDot) {
55
+ statusDot.classList.remove('status-green', 'status-yellow', 'status-red');
56
+ statusDot.classList.add(statusDotClass);
57
+ }
58
+ }
59
+
60
+ if (trackBtn) {
61
+ // Update track button data attributes
62
+ trackBtn.dataset.percent = percent;
63
+ trackBtn.dataset.color = color;
64
+ trackBtn.dataset.resetsIn = resetsIn;
65
+ }
66
+
67
+ // Find and update the progress bar (should be next sibling of usage-item)
68
+ const progressBar = usageItem ? usageItem.nextElementSibling : null;
69
+ if (progressBar && progressBar.classList.contains('progress-bar')) {
70
+ // Update zero-usage class on progress bar
71
+ if (isZero) {
72
+ progressBar.classList.add('zero-usage');
73
+ } else {
74
+ progressBar.classList.remove('zero-usage');
75
+ }
76
+
77
+ // Update progress fill
78
+ const progressFill = progressBar.querySelector('.progress-fill');
79
+ if (progressFill) {
80
+ progressFill.style.width = percent + '%';
81
+ progressFill.style.background = color;
82
+ }
83
+ }
84
+
85
+ // Update the "Resets in" info if present
86
+ const modelContainer = usageItem ? usageItem.closest('.model-container') : null;
87
+ if (modelContainer) {
88
+ const resetWrapper = modelContainer.querySelector('.reset-info-wrapper');
89
+ if (resetWrapper) {
90
+ if (isZero || !resetsIn) {
91
+ // Hide "Resets in" when at 0%
92
+ resetWrapper.style.display = 'none';
93
+ } else {
94
+ resetWrapper.style.display = '';
95
+ // Update the reset text
96
+ const resetValue = resetWrapper.querySelector('.usage-value');
97
+ if (resetValue) {
98
+ resetValue.textContent = resetsIn;
99
+ }
100
+ // Update time progress bar
101
+ const timeProgressFill = resetWrapper.querySelector('.time-progress-fill');
102
+ if (timeProgressFill && cycle && CYCLE_DURATIONS[cycle]) {
103
+ const resetsInSeconds = parseResetsInToSeconds(resetsIn);
104
+ if (resetsInSeconds !== null) {
105
+ const cycleDuration = CYCLE_DURATIONS[cycle];
106
+ const elapsedSeconds = cycleDuration - resetsInSeconds;
107
+ const elapsedPercent = Math.min(100, Math.max(0, (elapsedSeconds / cycleDuration) * 100));
108
+ timeProgressFill.style.width = elapsedPercent + '%';
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ // Update the "Last checked" timestamp in the nav
117
+ function updateLastCheckedTime() {
118
+ const lastCheckedEl = document.querySelector('.last-checked');
119
+ if (lastCheckedEl) {
120
+ const now = new Date();
121
+ const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
122
+ lastCheckedEl.textContent = 'Last checked: ' + timeStr;
123
+ }
124
+ }
125
+
126
+ // Main function to fetch /status and update DOM
127
+ async function performAutoRefresh() {
128
+ try {
129
+ const response = await fetch('/status');
130
+ if (!response.ok) {
131
+ console.error('[Auto-refresh] Failed to fetch /status:', response.status);
132
+ return;
133
+ }
134
+
135
+ const status = await response.json();
136
+ const allMetrics = [];
137
+
138
+ // Extract and update all metrics from each agent
139
+ for (const [agentName, agentData] of Object.entries(status)) {
140
+ if (!agentData.usage) continue;
141
+
142
+ let metrics = [];
143
+ if (agentName === 'claude') {
144
+ metrics = extractClaudeMetrics(agentData.usage);
145
+ } else if (agentName === 'gemini') {
146
+ metrics = extractGeminiMetrics(agentData.usage);
147
+ } else if (agentName === 'codex') {
148
+ metrics = extractCodexMetrics(agentData.usage);
149
+ }
150
+
151
+ // Update DOM for each metric
152
+ for (const metric of metrics) {
153
+ updateMetricDOM(metric);
154
+ allMetrics.push(metric);
155
+ }
156
+
157
+ // Update agent card error state
158
+ const agentCard = document.querySelector(\`.agent-card.agent-\${agentName}\`);
159
+ if (agentCard) {
160
+ if (agentData.error) {
161
+ agentCard.classList.add('error');
162
+ agentCard.classList.remove('ok', 'refreshing');
163
+ } else if (agentData.isRefreshing) {
164
+ agentCard.classList.add('refreshing');
165
+ agentCard.classList.remove('ok', 'error');
166
+ } else {
167
+ agentCard.classList.add('ok');
168
+ agentCard.classList.remove('error', 'refreshing');
169
+ }
170
+ }
171
+ }
172
+
173
+ // Update "Last checked" time
174
+ updateLastCheckedTime();
175
+
176
+ // Recalculate highest metric and update tracked metric
177
+ recalculateTrackedMetric(allMetrics);
178
+
179
+ // Update favicon and title based on new data
180
+ updateFaviconAndTitle();
181
+ checkAndNotify();
182
+
183
+ console.log('[Auto-refresh] Updated', allMetrics.length, 'metrics');
184
+ } catch (error) {
185
+ console.error('[Auto-refresh] Error:', error);
186
+ }
187
+ }
188
+
189
+ // Recalculate tracked metric after refresh
190
+ function recalculateTrackedMetric(allMetrics) {
191
+ if (pinnedMetric) {
192
+ // Manual mode: update pinned metric with fresh data
193
+ const freshData = allMetrics.find(m => m.metricId === pinnedMetric.metricId);
194
+ if (freshData) {
195
+ pinnedMetric.percent = freshData.percent;
196
+ pinnedMetric.color = getProgressColorFromPercent(freshData.percent);
197
+ pinnedMetric.resetsIn = freshData.resetsIn;
198
+ trackedMetric = pinnedMetric;
199
+ localStorage.setItem('pinnedMetric', JSON.stringify(pinnedMetric));
200
+ }
201
+ } else {
202
+ // Automatic mode: find highest usage metric
203
+ let highestMetric = null;
204
+ let highestPercent = -1;
205
+ for (const metric of allMetrics) {
206
+ if (metric.percent > highestPercent) {
207
+ highestPercent = metric.percent;
208
+ highestMetric = {
209
+ metricId: metric.metricId,
210
+ agent: metric.agent,
211
+ label: metric.label,
212
+ percent: metric.percent,
213
+ color: getProgressColorFromPercent(metric.percent),
214
+ resetsIn: metric.resetsIn
215
+ };
216
+ }
217
+ }
218
+ if (highestMetric) {
219
+ // Clear previous highlight
220
+ const prevTracked = document.querySelector('.track-btn.tracking');
221
+ if (prevTracked && prevTracked.dataset.metricId !== highestMetric.metricId) {
222
+ prevTracked.classList.remove('tracking');
223
+ const prevRow = document.querySelector('.usage-item.tracking');
224
+ if (prevRow) prevRow.classList.remove('tracking');
225
+ }
226
+ trackedMetric = highestMetric;
227
+ highlightTrackedMetric();
228
+ }
229
+ }
230
+ }
231
+
232
+ // Fetch backend configuration and initialize auto-refresh
233
+ async function initAutoRefreshFromBackend() {
234
+ try {
235
+ const response = await fetch('/config');
236
+ if (!response.ok) {
237
+ console.warn('[Auto-refresh] Could not fetch /config, using defaults');
238
+ startAutoRefreshWithConfig(autoRefreshConfig.enabled, autoRefreshConfig.interval);
239
+ return;
240
+ }
241
+
242
+ const config = await response.json();
243
+ const backendEnabled = config.autoRefresh?.enabled ?? false;
244
+ const backendInterval = config.autoRefresh?.interval ?? 0;
245
+ const lastRefreshedAt = config.lastRefreshedAt;
246
+
247
+ // If backend auto-refresh is disabled, disable frontend auto-refresh too
248
+ if (!backendEnabled || backendInterval <= 0) {
249
+ console.log('[Auto-refresh] Disabled (backend auto-refresh is off)');
250
+ autoRefreshConfig.enabled = false;
251
+ autoRefreshConfig.interval = 0;
252
+ return;
253
+ }
254
+
255
+ // Update frontend config to match backend
256
+ autoRefreshConfig.enabled = true;
257
+ autoRefreshConfig.interval = backendInterval * 1000;
258
+
259
+ // Calculate delay to sync with backend refresh cycle
260
+ let initialDelay = 2000;
261
+ if (lastRefreshedAt) {
262
+ const lastRefreshTime = new Date(lastRefreshedAt).getTime();
263
+ const now = Date.now();
264
+ const timeSinceLastRefresh = now - lastRefreshTime;
265
+ const intervalMs = backendInterval * 1000;
266
+ const timeUntilNextRefresh = intervalMs - (timeSinceLastRefresh % intervalMs);
267
+ initialDelay = timeUntilNextRefresh + 2000;
268
+ if (timeUntilNextRefresh < 1000) {
269
+ initialDelay = intervalMs + 2000;
270
+ }
271
+ }
272
+
273
+ console.log('[Auto-refresh] Backend interval:', backendInterval, 'seconds');
274
+ console.log('[Auto-refresh] Initial delay:', Math.round(initialDelay / 1000), 'seconds');
275
+ startAutoRefreshWithDelay(initialDelay, autoRefreshConfig.interval);
276
+ } catch (error) {
277
+ console.error('[Auto-refresh] Error fetching config:', error);
278
+ startAutoRefreshWithConfig(autoRefreshConfig.enabled, autoRefreshConfig.interval);
279
+ }
280
+ }
281
+
282
+ // Start auto-refresh with a specific initial delay and then regular interval
283
+ function startAutoRefreshWithDelay(initialDelay, interval) {
284
+ if (autoRefreshTimer) {
285
+ clearInterval(autoRefreshTimer);
286
+ autoRefreshTimer = null;
287
+ }
288
+ setTimeout(async () => {
289
+ await performAutoRefresh();
290
+ autoRefreshTimer = setInterval(performAutoRefresh, interval);
291
+ console.log('[Auto-refresh] Regular interval started:', interval, 'ms');
292
+ }, initialDelay);
293
+ }
294
+
295
+ // Start auto-refresh with given configuration
296
+ function startAutoRefreshWithConfig(enabled, intervalMs) {
297
+ if (!enabled || intervalMs === 0) {
298
+ console.log('[Auto-refresh] Disabled by configuration');
299
+ return;
300
+ }
301
+ if (autoRefreshTimer) {
302
+ clearInterval(autoRefreshTimer);
303
+ }
304
+ autoRefreshTimer = setInterval(performAutoRefresh, intervalMs);
305
+ console.log('[Auto-refresh] Started with interval:', intervalMs, 'ms');
306
+ }
307
+
308
+ // Stop auto-refresh polling
309
+ function stopAutoRefresh() {
310
+ if (autoRefreshTimer) {
311
+ clearInterval(autoRefreshTimer);
312
+ autoRefreshTimer = null;
313
+ console.log('[Auto-refresh] Stopped');
314
+ }
315
+ }
316
+
317
+ // Initialize auto-refresh from backend config on page load
318
+ initAutoRefreshFromBackend();
319
+ `;
320
+
321
+ module.exports = { autoRefreshScript };
@@ -0,0 +1,198 @@
1
+ // Client-side JavaScript for favicon generation
2
+ // This module exports a string containing the favicon generation code
3
+
4
+ const faviconScript = `
5
+ // Generate vertical tank favicon using Canvas API
6
+ // Tank drains/fills based on usage - available space shown as liquid level
7
+ function generateProgressFavicon(percent, color) {
8
+ const canvas = document.createElement('canvas');
9
+ const size = 64; // Favicon size
10
+ canvas.width = size;
11
+ canvas.height = size;
12
+ const ctx = canvas.getContext('2d');
13
+
14
+ // Clear canvas
15
+ ctx.clearRect(0, 0, size, size);
16
+
17
+ // Tank dimensions - U-shaped vertical tank
18
+ const tankLeft = 12;
19
+ const tankRight = size - 12;
20
+ const tankTop = 8;
21
+ const tankBottom = size - 4;
22
+ const tankWidth = tankRight - tankLeft;
23
+ const tankHeight = tankBottom - tankTop;
24
+ const cornerRadius = 8;
25
+
26
+ // Determine fill color based on percentage thresholds
27
+ // 0-50% green (plenty left), 51-80% yellow (caution), 81-100% red (critical)
28
+ let fillColor;
29
+ if (percent <= 50) {
30
+ fillColor = '#48bb78'; // Green - plenty available
31
+ } else if (percent <= 80) {
32
+ fillColor = '#ecc94b'; // Yellow - caution
33
+ } else {
34
+ fillColor = '#e53e3e'; // Red - critical
35
+ }
36
+
37
+ // Calculate liquid level (inverted - 0% usage = full tank, 100% usage = empty tank)
38
+ const availablePercent = 100 - percent;
39
+ const liquidHeight = (availablePercent / 100) * (tankHeight - cornerRadius);
40
+ const liquidTop = tankBottom - cornerRadius - liquidHeight;
41
+
42
+ // Draw tank background (empty space)
43
+ ctx.fillStyle = 'rgba(100, 100, 100, 0.3)';
44
+ ctx.beginPath();
45
+ ctx.moveTo(tankLeft, tankTop);
46
+ ctx.lineTo(tankRight, tankTop);
47
+ ctx.lineTo(tankRight, tankBottom - cornerRadius);
48
+ ctx.quadraticCurveTo(tankRight, tankBottom, tankRight - cornerRadius, tankBottom);
49
+ ctx.lineTo(tankLeft + cornerRadius, tankBottom);
50
+ ctx.quadraticCurveTo(tankLeft, tankBottom, tankLeft, tankBottom - cornerRadius);
51
+ ctx.lineTo(tankLeft, tankTop);
52
+ ctx.closePath();
53
+ ctx.fill();
54
+
55
+ // Draw liquid fill
56
+ if (availablePercent > 0) {
57
+ ctx.fillStyle = fillColor;
58
+ ctx.beginPath();
59
+
60
+ // Start from bottom-left corner
61
+ if (liquidTop >= tankBottom - cornerRadius) {
62
+ // Liquid is below the curved part
63
+ ctx.moveTo(tankLeft, liquidTop);
64
+ ctx.lineTo(tankRight, liquidTop);
65
+ ctx.lineTo(tankRight, tankBottom - cornerRadius);
66
+ ctx.quadraticCurveTo(tankRight, tankBottom, tankRight - cornerRadius, tankBottom);
67
+ ctx.lineTo(tankLeft + cornerRadius, tankBottom);
68
+ ctx.quadraticCurveTo(tankLeft, tankBottom, tankLeft, tankBottom - cornerRadius);
69
+ } else {
70
+ // Liquid extends into the curved area or fills more
71
+ ctx.moveTo(tankLeft, liquidTop);
72
+ ctx.lineTo(tankRight, liquidTop);
73
+ ctx.lineTo(tankRight, tankBottom - cornerRadius);
74
+ ctx.quadraticCurveTo(tankRight, tankBottom, tankRight - cornerRadius, tankBottom);
75
+ ctx.lineTo(tankLeft + cornerRadius, tankBottom);
76
+ ctx.quadraticCurveTo(tankLeft, tankBottom, tankLeft, tankBottom - cornerRadius);
77
+ }
78
+ ctx.closePath();
79
+ ctx.fill();
80
+
81
+ // Add liquid shine effect
82
+ const gradient = ctx.createLinearGradient(tankLeft, 0, tankRight, 0);
83
+ gradient.addColorStop(0, 'rgba(255, 255, 255, 0.2)');
84
+ gradient.addColorStop(0.3, 'rgba(255, 255, 255, 0.1)');
85
+ gradient.addColorStop(1, 'rgba(0, 0, 0, 0.1)');
86
+ ctx.fillStyle = gradient;
87
+ ctx.fill();
88
+ }
89
+
90
+ // Draw tank outline
91
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
92
+ ctx.lineWidth = 2;
93
+ ctx.beginPath();
94
+ ctx.moveTo(tankLeft, tankTop);
95
+ ctx.lineTo(tankRight, tankTop);
96
+ ctx.lineTo(tankRight, tankBottom - cornerRadius);
97
+ ctx.quadraticCurveTo(tankRight, tankBottom, tankRight - cornerRadius, tankBottom);
98
+ ctx.lineTo(tankLeft + cornerRadius, tankBottom);
99
+ ctx.quadraticCurveTo(tankLeft, tankBottom, tankLeft, tankBottom - cornerRadius);
100
+ ctx.lineTo(tankLeft, tankTop);
101
+ ctx.stroke();
102
+
103
+ // Draw measurement tick marks
104
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
105
+ ctx.lineWidth = 1;
106
+ for (let i = 1; i <= 4; i++) {
107
+ const tickY = tankTop + (tankHeight - cornerRadius) * (i / 5);
108
+ ctx.beginPath();
109
+ ctx.moveTo(tankLeft + 2, tickY);
110
+ ctx.lineTo(tankLeft + 8, tickY);
111
+ ctx.stroke();
112
+ }
113
+
114
+ // Convert to favicon
115
+ const faviconUrl = canvas.toDataURL('image/png');
116
+
117
+ // Update or create favicon link
118
+ let faviconLink = document.querySelector('link[rel="icon"]');
119
+ if (!faviconLink) {
120
+ faviconLink = document.createElement('link');
121
+ faviconLink.rel = 'icon';
122
+ document.head.appendChild(faviconLink);
123
+ }
124
+ faviconLink.href = faviconUrl;
125
+ }
126
+
127
+ // Generate default idle tank favicon when monitoring is not active
128
+ function generateDefaultFavicon() {
129
+ const canvas = document.createElement('canvas');
130
+ const size = 64;
131
+ canvas.width = size;
132
+ canvas.height = size;
133
+ const ctx = canvas.getContext('2d');
134
+
135
+ // Clear canvas
136
+ ctx.clearRect(0, 0, size, size);
137
+
138
+ // Tank dimensions - same as progress favicon
139
+ const tankLeft = 12;
140
+ const tankRight = size - 12;
141
+ const tankTop = 8;
142
+ const tankBottom = size - 4;
143
+ const tankWidth = tankRight - tankLeft;
144
+ const tankHeight = tankBottom - tankTop;
145
+ const cornerRadius = 8;
146
+
147
+ // Draw empty tank background (no liquid)
148
+ ctx.fillStyle = 'rgba(100, 100, 100, 0.25)';
149
+ ctx.beginPath();
150
+ ctx.moveTo(tankLeft, tankTop);
151
+ ctx.lineTo(tankRight, tankTop);
152
+ ctx.lineTo(tankRight, tankBottom - cornerRadius);
153
+ ctx.quadraticCurveTo(tankRight, tankBottom, tankRight - cornerRadius, tankBottom);
154
+ ctx.lineTo(tankLeft + cornerRadius, tankBottom);
155
+ ctx.quadraticCurveTo(tankLeft, tankBottom, tankLeft, tankBottom - cornerRadius);
156
+ ctx.lineTo(tankLeft, tankTop);
157
+ ctx.closePath();
158
+ ctx.fill();
159
+
160
+ // Draw tank outline (subdued color for idle state)
161
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
162
+ ctx.lineWidth = 2;
163
+ ctx.beginPath();
164
+ ctx.moveTo(tankLeft, tankTop);
165
+ ctx.lineTo(tankRight, tankTop);
166
+ ctx.lineTo(tankRight, tankBottom - cornerRadius);
167
+ ctx.quadraticCurveTo(tankRight, tankBottom, tankRight - cornerRadius, tankBottom);
168
+ ctx.lineTo(tankLeft + cornerRadius, tankBottom);
169
+ ctx.quadraticCurveTo(tankLeft, tankBottom, tankLeft, tankBottom - cornerRadius);
170
+ ctx.lineTo(tankLeft, tankTop);
171
+ ctx.stroke();
172
+
173
+ // Draw measurement tick marks (subdued)
174
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
175
+ ctx.lineWidth = 1;
176
+ for (let i = 1; i <= 4; i++) {
177
+ const tickY = tankTop + (tankHeight - cornerRadius) * (i / 5);
178
+ ctx.beginPath();
179
+ ctx.moveTo(tankLeft + 2, tickY);
180
+ ctx.lineTo(tankLeft + 8, tickY);
181
+ ctx.stroke();
182
+ }
183
+
184
+ // Convert to favicon
185
+ const faviconUrl = canvas.toDataURL('image/png');
186
+
187
+ // Update or create favicon link
188
+ let faviconLink = document.querySelector('link[rel="icon"]');
189
+ if (!faviconLink) {
190
+ faviconLink = document.createElement('link');
191
+ faviconLink.rel = 'icon';
192
+ document.head.appendChild(faviconLink);
193
+ }
194
+ faviconLink.href = faviconUrl;
195
+ }
196
+ `;
197
+
198
+ module.exports = { faviconScript };