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,128 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const pkg = require(path.join(__dirname, '..', 'package.json'));
4
+
5
+ const { agentIcons, refreshIcon, tankIcon, syncIcon, copyIcon } = require('./icons');
6
+ const { formatUsage } = require('./usage-formatters');
7
+ const { clientScript } = require('./client-script');
8
+ const { getStatusBadgeClass, getStatusText } = require('./public-status');
9
+
10
+ const styles = fs.readFileSync(path.join(__dirname, 'status-page.css'), 'utf8');
11
+
12
+ function statusPage(status) {
13
+ const agents = Object.entries(status);
14
+
15
+ // Calculate the maximum lastUpdated timestamp across all agents for global display
16
+ const maxLastUpdated = agents.reduce((max, [, data]) => {
17
+ if (data.lastUpdated) {
18
+ const ts = new Date(data.lastUpdated).getTime();
19
+ return ts > max ? ts : max;
20
+ }
21
+ return max;
22
+ }, 0);
23
+
24
+ const globalLastChecked = maxLastUpdated > 0
25
+ ? new Date(maxLastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
26
+ : null;
27
+
28
+ const agentCards = agents.map(([name, data]) => {
29
+ const usageHtml = formatUsage(name, data.usage);
30
+ const statusClass = data.error ? 'error' : data.isRefreshing ? 'refreshing' : 'ok';
31
+
32
+ const icon = agentIcons[name] || '';
33
+ const displayName = name.charAt(0).toUpperCase() + name.slice(1);
34
+
35
+ // Public API status badge
36
+ const publicStatus = data.publicStatus;
37
+ const badgeClass = publicStatus ? getStatusBadgeClass(publicStatus.status) : 'status-badge-grey';
38
+ const badgeText = publicStatus ? getStatusText(publicStatus.status) : 'Unknown';
39
+ const badgeTitle = publicStatus?.description || 'Unable to fetch status';
40
+ const statusBadgeHtml = `<span class="status-badge ${badgeClass}" title="${badgeTitle}">${badgeText}</span>`;
41
+
42
+ // Version update notice - check usage.version (Codex) or metadata.updateAvailable (Gemini)
43
+ const version = data.usage?.version || data.metadata?.updateAvailable;
44
+ const updateHtml = (version && version.current && version.latest && version.current !== version.latest)
45
+ ? `<div class="version-update-notice">Update available: v${version.current} → v${version.latest}</div>`
46
+ : '';
47
+
48
+ // Only show card-footer if there's an error or update notice
49
+ const hasFooterContent = data.error || updateHtml;
50
+ const footerHtml = hasFooterContent
51
+ ? `<div class="card-footer">${data.error ? `<span class="error-msg">${data.error}</span>` : ''}${updateHtml}</div>`
52
+ : '';
53
+
54
+ return `
55
+ <div class="agent-card ${statusClass} agent-${name}">
56
+ <h2 class="agent-heading">
57
+ ${icon}
58
+ <span>${displayName}</span>
59
+ ${statusBadgeHtml}
60
+ <button class="refresh-icon-btn" onclick="refresh('${name}', event)" ${data.isRefreshing ? 'disabled' : ''} title="Refresh ${displayName}">
61
+ ${refreshIcon}
62
+ </button>
63
+ </h2>
64
+ <div class="usage">${usageHtml}</div>
65
+ ${footerHtml}
66
+ </div>
67
+ `;
68
+ }).join('');
69
+
70
+ return `<!DOCTYPE html>
71
+ <html>
72
+ <head>
73
+ <meta charset="utf-8">
74
+ <meta name="viewport" content="width=device-width, initial-scale=1">
75
+ <title>Agent Tank</title>
76
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
77
+ <style>
78
+ ${styles}
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <nav class="top-nav">
83
+ <div class="top-nav-left">
84
+ <a href="https://agenttank.io" target="_blank" rel="noopener noreferrer" class="logo-link">
85
+ ${tankIcon}
86
+ <h1 class="top-nav-title">AGENT <span class="brand-emphasis">TANK</span></h1>
87
+ </a>
88
+ </div>
89
+ <div class="top-nav-right">
90
+ ${globalLastChecked ? `<span class="last-checked">Last checked: ${globalLastChecked}</span>` : ''}
91
+ <button class="refresh-all-btn" onclick="refreshAll(event)">
92
+ ${syncIcon}
93
+ <span class="btn-text">Refresh All</span>
94
+ </button>
95
+ <button class="theme-toggle-btn" onclick="toggleTheme()" id="theme-toggle-btn">
96
+ <span id="theme-icon">☀️</span>
97
+ <span id="theme-text">Light</span>
98
+ </button>
99
+ </div>
100
+ </nav>
101
+ <div class="container">
102
+ <div class="agents">
103
+ ${agentCards || '<p>No agents configured</p>'}
104
+ </div>
105
+ <footer class="footer">
106
+ <div class="footer-left">
107
+ <span>&copy; ${new Date().getFullYear()} <a href="https://propr.dev">Rinalds Uzkalns</a></span>
108
+ </div>
109
+ <div class="footer-right">
110
+ <span class="footer-version"><a href="https://github.com/integry/agent-tank/releases">v${pkg.version}</a></span>
111
+ <span class="footer-separator">•</span>
112
+ <a href="https://github.com/integry/agent-tank/blob/main/CHANGELOG.md">Changelog</a>
113
+ <span class="footer-separator">•</span>
114
+ <a href="https://github.com/integry/agent-tank">GitHub</a>
115
+ <span class="footer-separator">•</span>
116
+ <a href="https://www.reddit.com/r/agenttank">Reddit</a>
117
+ <span class="footer-separator">•</span>
118
+ <a href="/status" target="_blank" class="api-link" id="api-link" title="Open API endpoint">[ API Endpoint ]</a>
119
+ <button class="copy-api-btn" onclick="copyApiEndpoint(event)" title="Copy API URL">${copyIcon}</button>
120
+ </div>
121
+ </footer>
122
+ </div>
123
+ <script>${clientScript}</script>
124
+ </body>
125
+ </html>`;
126
+ }
127
+
128
+ module.exports = { statusPage };
@@ -0,0 +1,259 @@
1
+ const { trackIcon } = require('./icons');
2
+ const { formatPaceWarning } = require('./pace-evaluator');
3
+
4
+ // Cycle duration constants in seconds
5
+ const CYCLE_DURATIONS = {
6
+ session: 5 * 60 * 60, // 5 hours = 18000 seconds
7
+ sessionGemini: 24 * 60 * 60, // 24 hours = 86400 seconds (Gemini uses 24h sessions)
8
+ weekly: 7 * 24 * 60 * 60, // 7 days = 604800 seconds
9
+ fiveHour: 5 * 60 * 60 // 5 hours = 18000 seconds
10
+ };
11
+
12
+ // Parse "Resets in" time string to seconds
13
+ function parseResetsInToSeconds(resetsIn) {
14
+ if (!resetsIn || typeof resetsIn !== 'string') return null;
15
+
16
+ let totalSeconds = 0;
17
+
18
+ // Match patterns like "2h 30m", "5 hours", "3d 12h", "45 minutes", etc.
19
+ const dayMatch = resetsIn.match(/(\d+)\s*d(?:ay)?s?/i);
20
+ const hourMatch = resetsIn.match(/(\d+)\s*h(?:our)?s?/i);
21
+ const minMatch = resetsIn.match(/(\d+)\s*m(?:in(?:ute)?)?s?/i);
22
+ const secMatch = resetsIn.match(/(\d+)\s*s(?:ec(?:ond)?)?s?/i);
23
+
24
+ if (dayMatch) totalSeconds += parseInt(dayMatch[1], 10) * 24 * 60 * 60;
25
+ if (hourMatch) totalSeconds += parseInt(hourMatch[1], 10) * 60 * 60;
26
+ if (minMatch) totalSeconds += parseInt(minMatch[1], 10) * 60;
27
+ if (secMatch) totalSeconds += parseInt(secMatch[1], 10);
28
+
29
+ return totalSeconds > 0 ? totalSeconds : null;
30
+ }
31
+
32
+ // Helper to determine color class based on usage percentage
33
+ function getColorClass(value, isUsed) {
34
+ if (isUsed) {
35
+ if (value < 50) return 'high';
36
+ if (value < 80) return 'medium';
37
+ return 'low';
38
+ }
39
+ if (value > 50) return 'high';
40
+ if (value > 20) return 'medium';
41
+ return 'low';
42
+ }
43
+
44
+ // Helper to determine status dot class based on value
45
+ function getStatusDotClass(value) {
46
+ if (value <= 50) return 'status-green';
47
+ if (value <= 80) return 'status-yellow';
48
+ return 'status-red';
49
+ }
50
+
51
+ function resetInfoItem(resetsIn, originalValue, cycleType, options = {}) {
52
+ const { isZero = false, paceData = null } = options;
53
+ // Always render the wrapper so XHR updates can show/hide it dynamically.
54
+ // Hidden when at 0% (no useful info when at full capacity).
55
+ const hidden = isZero ? ' style="display:none"' : '';
56
+ const tooltip = originalValue ? ` title="${originalValue}"` : '';
57
+
58
+ // Calculate elapsed time progress bar
59
+ let timeProgressHtml = '';
60
+ const resetsInSeconds = parseResetsInToSeconds(resetsIn);
61
+ const cycleDuration = cycleType ? CYCLE_DURATIONS[cycleType] : null;
62
+
63
+ if (resetsInSeconds !== null && cycleDuration) {
64
+ const elapsedSeconds = cycleDuration - resetsInSeconds;
65
+ const elapsedPercent = Math.min(100, Math.max(0, (elapsedSeconds / cycleDuration) * 100));
66
+
67
+ timeProgressHtml = `<div class="time-progress-bar" title="Time elapsed in cycle: ${Math.round(elapsedPercent)}%">
68
+ <div class="time-progress-fill" style="width: ${elapsedPercent}%;"></div>
69
+ </div>`;
70
+ }
71
+
72
+ // Add pace warning if applicable
73
+ const paceWarningHtml = formatPaceWarning(paceData);
74
+
75
+ return `<div class="reset-info-wrapper"${hidden}>
76
+ <div class="usage-item reset-info"${tooltip}><span class="usage-label">↳ Resets in</span><span class="usage-value">${resetsIn || ''}${paceWarningHtml}</span></div>
77
+ ${timeProgressHtml}
78
+ </div>`;
79
+ }
80
+
81
+ function usageItem(label, value, suffix, options = {}) {
82
+ const { isUsed = true, isZero = false, isModelName = false, isNestedMetric = false, agentName = '', resetsIn = '', metricId = '' } = options;
83
+ const colorClass = getColorClass(value, isUsed);
84
+ const progressPercent = isUsed ? value : (100 - value);
85
+ const progressColor = colorClass === 'high' ? '#48bb78' : colorClass === 'medium' ? '#ecc94b' : '#e53e3e';
86
+ const zeroClass = isZero ? ' zero-usage' : '';
87
+ const nestedClass = isNestedMetric ? ' nested-metric' : '';
88
+ const statusDotClass = getStatusDotClass(value);
89
+ // All usage items now have status dots (LEDs) for unified monitoring station look
90
+ const labelHtml = `<span class="usage-label${isModelName ? ' model-name' : ''}"><span class="status-dot ${statusDotClass}"></span>${label}</span>`;
91
+
92
+ // Generate unique metric ID for tracking
93
+ const trackingId = metricId || `${agentName}-${label.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
94
+
95
+ // Track button with data attributes for client-side tracking
96
+ const trackButton = `<button class="track-btn"
97
+ data-metric-id="${trackingId}"
98
+ data-agent="${agentName}"
99
+ data-label="${label}"
100
+ data-percent="${value}"
101
+ data-color="${progressColor}"
102
+ data-resets-in="${resetsIn}"
103
+ onclick="event.stopPropagation(); toggleTracking(this)"
104
+ title="Track this metric in tab">${trackIcon}</button>`;
105
+
106
+ return `
107
+ <div class="usage-item${zeroClass}${nestedClass} trackable"
108
+ data-metric-id="${trackingId}"
109
+ data-agent="${agentName}"
110
+ data-label="${label}"
111
+ data-percent="${value}"
112
+ data-color="${progressColor}"
113
+ data-resets-in="${resetsIn}"
114
+ onclick="toggleTrackingFromRow(this)"
115
+ title="Click to track this metric">
116
+ ${labelHtml}
117
+ <span class="usage-value ${colorClass}"><span class="usage-percent">${value}</span><span class="usage-suffix">${suffix}</span>${trackButton}</span>
118
+ </div>
119
+ <div class="progress-bar${zeroClass}${nestedClass}">
120
+ <div class="progress-fill" style="width: ${progressPercent}%; background: ${progressColor};"></div>
121
+ </div>
122
+ `;
123
+ }
124
+
125
+ function formatClaudeUsage(usage) {
126
+ let html = '';
127
+ const sections = [
128
+ { data: usage.session, label: 'Session', cycle: 'session' },
129
+ { data: usage.weeklyAll, label: 'Weekly (all)', cycle: 'weekly' },
130
+ { data: usage.weeklySonnet, label: 'Weekly (Sonnet)', cycle: 'weekly' },
131
+ { data: usage.weekly, label: 'Weekly', cycle: 'weekly' },
132
+ ];
133
+ for (const { data, label, cycle } of sections) {
134
+ if (data) {
135
+ const percent = data.percent ?? 0;
136
+ const isZero = percent === 0;
137
+ const resetsIn = data.resetsIn || '';
138
+
139
+ // Use pre-calculated pace data from the usage object
140
+ const paceData = data.pace || null;
141
+
142
+ html += '<div class="model-container">';
143
+ html += usageItem(label, percent, '% used', { isZero, agentName: 'claude', resetsIn });
144
+ html += resetInfoItem(data.resetsIn, data.resetsAt, cycle, { isZero, paceData });
145
+ html += '</div>';
146
+ }
147
+ }
148
+ // Extra usage (paid overage budget)
149
+ if (usage.extraUsage) {
150
+ const extra = usage.extraUsage;
151
+ const percent = extra.percent ?? 0;
152
+ const isZero = percent === 0;
153
+ const resetsIn = extra.resetsIn || '';
154
+
155
+ // Use pre-calculated pace data from the usage object
156
+ const paceData = extra.pace || null;
157
+
158
+ const spentLabel = extra.spent != null && extra.budget != null
159
+ ? `$${extra.spent.toFixed(2)} / $${extra.budget.toFixed(2)}`
160
+ : '';
161
+ html += '<div class="model-container">';
162
+ html += usageItem('Extra', percent, '% used', { isZero, agentName: 'claude', resetsIn });
163
+ if (spentLabel) {
164
+ html += `<div class="usage-item reset-info"><span class="usage-label">↳ Budget</span><span class="usage-value">${spentLabel}</span></div>`;
165
+ }
166
+ html += resetInfoItem(extra.resetsIn, extra.resetsAt, 'weekly', { isZero, paceData });
167
+ html += '</div>';
168
+ }
169
+ return html;
170
+ }
171
+
172
+ function formatGeminiUsage(usage) {
173
+ let html = '';
174
+ if (usage.models && usage.models.length > 0) {
175
+ for (const model of usage.models) {
176
+ const percent = model.percentUsed ?? 0;
177
+ const isZero = percent === 0;
178
+ // Use lowercase model name with model-name styling
179
+ const modelName = model.model.toLowerCase();
180
+ const resetsIn = model.resetsIn || '';
181
+
182
+ // Use pre-calculated pace data from the usage object
183
+ const paceData = model.pace || null;
184
+
185
+ html += '<div class="model-container">';
186
+ html += usageItem(modelName, percent, '% used', { isZero, isModelName: true, agentName: 'gemini', resetsIn });
187
+ html += resetInfoItem(model.resetsIn, null, 'sessionGemini', { isZero, paceData });
188
+ html += '</div>';
189
+ }
190
+ }
191
+ return html;
192
+ }
193
+
194
+ function formatCodexUsage(usage) {
195
+ let html = '';
196
+ const models = [];
197
+ if (usage.fiveHour || usage.weekly) {
198
+ models.push({ name: usage.model || 'Default', fiveHour: usage.fiveHour, weekly: usage.weekly });
199
+ }
200
+ if (usage.modelLimits) {
201
+ models.push(...usage.modelLimits);
202
+ }
203
+ for (const ml of models) {
204
+ // Use model-subheading class for Codex models to create visual grouping (keeps ALL CAPS)
205
+ // CSS :first-child handles the first item styling automatically
206
+ const modelSlug = ml.name.toLowerCase().replace(/[^a-z0-9]/g, '-');
207
+ html += `<div class="usage-item model-subheading"><span class="usage-label">${ml.name.toUpperCase()}</span></div>`;
208
+ if (ml.fiveHour) {
209
+ const fiveHourPercent = ml.fiveHour.percentUsed ?? 0;
210
+ const isZero = fiveHourPercent === 0;
211
+ const resetsIn = ml.fiveHour.resetsIn || '';
212
+
213
+ // Use pre-calculated pace data from the usage object
214
+ const paceData = ml.fiveHour.pace || null;
215
+
216
+ html += '<div class="model-container nested-container">';
217
+ html += usageItem('5h limit', fiveHourPercent, '% used', { isZero, isNestedMetric: true, agentName: 'codex', resetsIn, metricId: `codex-${modelSlug}-5h` });
218
+ html += resetInfoItem(ml.fiveHour.resetsIn, ml.fiveHour.resetsAt, 'fiveHour', { isZero, paceData });
219
+ html += '</div>';
220
+ }
221
+ if (ml.weekly) {
222
+ const weeklyPercent = ml.weekly.percentUsed ?? 0;
223
+ const isZero = weeklyPercent === 0;
224
+ const resetsIn = ml.weekly.resetsIn || '';
225
+
226
+ // Use pre-calculated pace data from the usage object
227
+ const paceData = ml.weekly.pace || null;
228
+
229
+ html += '<div class="model-container nested-container">';
230
+ html += usageItem('Weekly', weeklyPercent, '% used', { isZero, isNestedMetric: true, agentName: 'codex', resetsIn, metricId: `codex-${modelSlug}-weekly` });
231
+ html += resetInfoItem(ml.weekly.resetsIn, ml.weekly.resetsAt, 'weekly', { isZero, paceData });
232
+ html += '</div>';
233
+ }
234
+ }
235
+ return html;
236
+ }
237
+
238
+ function formatUsage(agentName, usage) {
239
+ if (!usage) {
240
+ return '<p class="usage-item">No data available</p>';
241
+ }
242
+
243
+ const formatters = { claude: formatClaudeUsage, gemini: formatGeminiUsage, codex: formatCodexUsage };
244
+ const formatter = formatters[agentName];
245
+ const html = formatter ? formatter(usage) : '';
246
+
247
+ return html || '<p class="usage-item">No usage data</p>';
248
+ }
249
+
250
+ module.exports = {
251
+ formatUsage,
252
+ formatClaudeUsage,
253
+ formatGeminiUsage,
254
+ formatCodexUsage,
255
+ usageItem,
256
+ resetInfoItem,
257
+ parseResetsInToSeconds,
258
+ CYCLE_DURATIONS
259
+ };