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.
- package/LICENSE +21 -0
- package/README.md +675 -0
- package/bin/agent-tank.js +301 -0
- package/media/www-preview.png +0 -0
- package/package.json +65 -0
- package/src/activity-monitor.js +312 -0
- package/src/agents/api-response-parser.js +255 -0
- package/src/agents/base.js +396 -0
- package/src/agents/claude.js +388 -0
- package/src/agents/codex-pty-helpers.js +226 -0
- package/src/agents/codex-rpc-helpers.js +167 -0
- package/src/agents/codex.js +383 -0
- package/src/agents/gemini.js +302 -0
- package/src/agents/keepalive-helper.js +115 -0
- package/src/agents/oauth-helper.js +210 -0
- package/src/agents/pty-output-parser.js +301 -0
- package/src/auto-refresh-manager.js +246 -0
- package/src/client-auto-refresh-helpers.js +143 -0
- package/src/client-auto-refresh.js +321 -0
- package/src/client-favicon.js +198 -0
- package/src/client-monitor.js +242 -0
- package/src/client-script.js +147 -0
- package/src/discovery.js +128 -0
- package/src/history-store.js +273 -0
- package/src/http-handler.js +182 -0
- package/src/icons.js +67 -0
- package/src/index.js +376 -0
- package/src/json-rpc-client.js +285 -0
- package/src/keepalive-manager.js +163 -0
- package/src/logger.js +213 -0
- package/src/pace-attachment.js +104 -0
- package/src/pace-evaluator.js +269 -0
- package/src/public-status.js +276 -0
- package/src/server.js +144 -0
- package/src/snapshot-metrics.js +79 -0
- package/src/status-page.css +1149 -0
- package/src/status-page.js +128 -0
- package/src/usage-formatters.js +259 -0
|
@@ -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 };
|