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,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pace Evaluator Module
|
|
3
|
+
*
|
|
4
|
+
* Calculates whether usage is out-pacing the time window.
|
|
5
|
+
* Warns users if they are burning through rate limits faster than time is elapsing.
|
|
6
|
+
* Provides extrapolation of ETA until 100% usage is reached.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Calculate the pace of usage relative to time elapsed in a cycle.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} options - The pace calculation options
|
|
13
|
+
* @param {number} options.usagePercent - Current usage percentage (0-100)
|
|
14
|
+
* @param {number} options.resetsInSeconds - Seconds until the cycle resets
|
|
15
|
+
* @param {number} options.cycleDurationSeconds - Total duration of the cycle in seconds
|
|
16
|
+
* @param {number} [options.warningThreshold=1.2] - Pace ratio threshold to trigger warning (default: 20% faster than time)
|
|
17
|
+
* @returns {Object|null} Pace data or null if calculation not possible
|
|
18
|
+
* - paceRatio: Usage rate vs time rate (>1 means out-pacing)
|
|
19
|
+
* - elapsedPercent: Percentage of time elapsed in the cycle
|
|
20
|
+
* - isWarning: Whether usage is significantly out-pacing time
|
|
21
|
+
* - warningMessage: Human-readable warning message (if warning triggered)
|
|
22
|
+
*/
|
|
23
|
+
function calculatePace(options) {
|
|
24
|
+
const {
|
|
25
|
+
usagePercent,
|
|
26
|
+
resetsInSeconds,
|
|
27
|
+
cycleDurationSeconds,
|
|
28
|
+
warningThreshold = 1.2
|
|
29
|
+
} = options;
|
|
30
|
+
|
|
31
|
+
// Validate inputs
|
|
32
|
+
if (
|
|
33
|
+
typeof usagePercent !== 'number' ||
|
|
34
|
+
typeof resetsInSeconds !== 'number' ||
|
|
35
|
+
typeof cycleDurationSeconds !== 'number' ||
|
|
36
|
+
cycleDurationSeconds <= 0
|
|
37
|
+
) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Calculate elapsed time in the cycle
|
|
42
|
+
const elapsedSeconds = cycleDurationSeconds - resetsInSeconds;
|
|
43
|
+
|
|
44
|
+
// Handle edge case: no time elapsed yet
|
|
45
|
+
if (elapsedSeconds <= 0) {
|
|
46
|
+
return {
|
|
47
|
+
paceRatio: usagePercent > 0 ? Infinity : 0,
|
|
48
|
+
elapsedPercent: 0,
|
|
49
|
+
isWarning: usagePercent > 0,
|
|
50
|
+
warningMessage: usagePercent > 0 ? 'Usage started before time window opened' : null
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const elapsedPercent = (elapsedSeconds / cycleDurationSeconds) * 100;
|
|
55
|
+
|
|
56
|
+
// Handle edge case: no usage
|
|
57
|
+
if (usagePercent === 0) {
|
|
58
|
+
return {
|
|
59
|
+
paceRatio: 0,
|
|
60
|
+
elapsedPercent,
|
|
61
|
+
isWarning: false,
|
|
62
|
+
warningMessage: null
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Calculate pace ratio: usage rate vs time rate
|
|
67
|
+
// paceRatio > 1 means using faster than time is passing
|
|
68
|
+
// paceRatio = 2 means using at 2x the sustainable rate
|
|
69
|
+
const paceRatio = usagePercent / elapsedPercent;
|
|
70
|
+
|
|
71
|
+
// Determine if warning should be triggered
|
|
72
|
+
const isWarning = paceRatio >= warningThreshold;
|
|
73
|
+
|
|
74
|
+
// Generate warning message if applicable
|
|
75
|
+
let warningMessage = null;
|
|
76
|
+
if (isWarning) {
|
|
77
|
+
const paceMultiplier = paceRatio.toFixed(1);
|
|
78
|
+
warningMessage = `Using ${paceMultiplier}x faster than sustainable`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
paceRatio: Math.round(paceRatio * 100) / 100, // Round to 2 decimal places
|
|
83
|
+
elapsedPercent: Math.round(elapsedPercent * 100) / 100,
|
|
84
|
+
isWarning,
|
|
85
|
+
warningMessage
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Evaluate usage pace and extrapolate when user will hit 100%.
|
|
91
|
+
*
|
|
92
|
+
* This function calculates the expected linear usage based on elapsed time,
|
|
93
|
+
* determines if the user is burning faster than expected, and if so,
|
|
94
|
+
* extrapolates the ETA in seconds until they hit 100%.
|
|
95
|
+
*
|
|
96
|
+
* @param {Object} options - The evaluation options
|
|
97
|
+
* @param {number} options.usagePercent - Current usage percentage (0-100)
|
|
98
|
+
* @param {number} options.resetsInSeconds - Seconds until the cycle resets
|
|
99
|
+
* @param {number} options.cycleDurationSeconds - Total duration of the cycle in seconds
|
|
100
|
+
* @param {number} [options.warningThreshold=1.2] - Pace ratio threshold to trigger warning
|
|
101
|
+
* @returns {Object|null} Pace evaluation result or null if calculation not possible
|
|
102
|
+
* - expectedPercent: Expected usage percentage based on linear pace
|
|
103
|
+
* - isBurningFast: Whether actual usage exceeds expected usage
|
|
104
|
+
* - etaSeconds: Seconds until 100% usage (only if isBurningFast is true, null otherwise)
|
|
105
|
+
* - paceRatio: Usage rate vs time rate (>1 means out-pacing)
|
|
106
|
+
* - elapsedPercent: Percentage of time elapsed in the cycle
|
|
107
|
+
* - deltaPercent: Difference between actual and expected usage (positive = ahead)
|
|
108
|
+
*/
|
|
109
|
+
function evaluatePace(options) {
|
|
110
|
+
const {
|
|
111
|
+
usagePercent,
|
|
112
|
+
resetsInSeconds,
|
|
113
|
+
cycleDurationSeconds,
|
|
114
|
+
warningThreshold = 1.2
|
|
115
|
+
} = options;
|
|
116
|
+
|
|
117
|
+
// Validate inputs
|
|
118
|
+
if (
|
|
119
|
+
typeof usagePercent !== 'number' ||
|
|
120
|
+
typeof resetsInSeconds !== 'number' ||
|
|
121
|
+
typeof cycleDurationSeconds !== 'number' ||
|
|
122
|
+
cycleDurationSeconds <= 0
|
|
123
|
+
) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Calculate elapsed time in the cycle
|
|
128
|
+
const elapsedSeconds = cycleDurationSeconds - resetsInSeconds;
|
|
129
|
+
|
|
130
|
+
// Handle edge case: no time elapsed yet
|
|
131
|
+
if (elapsedSeconds <= 0) {
|
|
132
|
+
return {
|
|
133
|
+
expectedPercent: 0,
|
|
134
|
+
isBurningFast: usagePercent > 0,
|
|
135
|
+
etaSeconds: usagePercent > 0 ? 0 : null, // Already burning if any usage at start
|
|
136
|
+
paceRatio: usagePercent > 0 ? Infinity : 0,
|
|
137
|
+
elapsedPercent: 0,
|
|
138
|
+
deltaPercent: usagePercent
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Calculate expected usage based on linear pace
|
|
143
|
+
// If 50% of time has elapsed, expected usage should be 50%
|
|
144
|
+
const elapsedPercent = (elapsedSeconds / cycleDurationSeconds) * 100;
|
|
145
|
+
const expectedPercent = elapsedPercent;
|
|
146
|
+
|
|
147
|
+
// Calculate delta (positive = ahead of pace, negative = behind)
|
|
148
|
+
const deltaPercent = usagePercent - expectedPercent;
|
|
149
|
+
|
|
150
|
+
// Determine if burning fast (usage exceeds expected by threshold)
|
|
151
|
+
// Using threshold ratio: if usage/expected >= threshold, burning fast
|
|
152
|
+
const paceRatio = usagePercent > 0 && elapsedPercent > 0
|
|
153
|
+
? usagePercent / elapsedPercent
|
|
154
|
+
: (usagePercent > 0 ? Infinity : 0);
|
|
155
|
+
|
|
156
|
+
const isBurningFast = paceRatio >= warningThreshold;
|
|
157
|
+
|
|
158
|
+
// Calculate ETA to 100% if burning fast
|
|
159
|
+
let etaSeconds = null;
|
|
160
|
+
if (isBurningFast && usagePercent < 100 && usagePercent > 0) {
|
|
161
|
+
// Current burn rate: usagePercent per elapsedSeconds
|
|
162
|
+
const burnRatePerSecond = usagePercent / elapsedSeconds;
|
|
163
|
+
|
|
164
|
+
// Remaining usage to reach 100%
|
|
165
|
+
const remainingPercent = 100 - usagePercent;
|
|
166
|
+
|
|
167
|
+
// Time to reach 100% at current burn rate
|
|
168
|
+
etaSeconds = Math.round(remainingPercent / burnRatePerSecond);
|
|
169
|
+
} else if (usagePercent >= 100) {
|
|
170
|
+
etaSeconds = 0; // Already at or past 100%
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
expectedPercent: Math.round(expectedPercent * 100) / 100,
|
|
175
|
+
isBurningFast,
|
|
176
|
+
etaSeconds,
|
|
177
|
+
paceRatio: paceRatio === Infinity ? Infinity : Math.round(paceRatio * 100) / 100,
|
|
178
|
+
elapsedPercent: Math.round(elapsedPercent * 100) / 100,
|
|
179
|
+
deltaPercent: Math.round(deltaPercent * 100) / 100
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Format a pace warning as HTML for display in the UI.
|
|
185
|
+
* Includes ETA countdown when burning fast.
|
|
186
|
+
*
|
|
187
|
+
* @param {Object|null} paceData - Result from evaluatePace() or calculatePace()
|
|
188
|
+
* @returns {string} HTML string for the pace warning (empty string if no warning)
|
|
189
|
+
*/
|
|
190
|
+
function formatPaceWarning(paceData) {
|
|
191
|
+
if (!paceData) {
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Determine if we should show warning based on isWarning or isBurningFast
|
|
196
|
+
const shouldShowWarning = paceData.isWarning || paceData.isBurningFast;
|
|
197
|
+
if (!shouldShowWarning) {
|
|
198
|
+
return '';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const paceDisplay = paceData.paceRatio.toFixed(1);
|
|
202
|
+
// Add critical class for 2x or higher pace
|
|
203
|
+
const criticalClass = paceData.paceRatio >= 2 ? ' pace-critical' : '';
|
|
204
|
+
|
|
205
|
+
// Build tooltip message
|
|
206
|
+
let tooltip = paceData.warningMessage || `Using ${paceDisplay}x faster than sustainable`;
|
|
207
|
+
|
|
208
|
+
// Generate ETA section if available
|
|
209
|
+
let etaHtml = '';
|
|
210
|
+
if (paceData.etaSeconds != null && paceData.etaSeconds >= 0 && paceData.isBurningFast) {
|
|
211
|
+
const etaString = formatEtaString(paceData.etaSeconds);
|
|
212
|
+
if (etaString) {
|
|
213
|
+
etaHtml = `<span class="pace-eta">runs out in ${etaString}</span>`;
|
|
214
|
+
tooltip += ` • Runs out in ${etaString}`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return `<div class="pace-warning${criticalClass}" title="${tooltip}">
|
|
219
|
+
<span class="pace-icon">⚠</span>
|
|
220
|
+
<span class="pace-text">${paceDisplay}x pace</span>${etaHtml}
|
|
221
|
+
</div>`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Format ETA seconds into a human-readable string.
|
|
226
|
+
*
|
|
227
|
+
* @param {number|null} etaSeconds - Seconds until 100% usage
|
|
228
|
+
* @returns {string} Formatted ETA string (e.g., "2h 15m", "45m", "~30s")
|
|
229
|
+
*/
|
|
230
|
+
function formatEtaString(etaSeconds) {
|
|
231
|
+
if (etaSeconds == null || etaSeconds < 0) {
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (etaSeconds === 0) {
|
|
236
|
+
return 'now';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const days = Math.floor(etaSeconds / (24 * 60 * 60));
|
|
240
|
+
const hours = Math.floor((etaSeconds % (24 * 60 * 60)) / (60 * 60));
|
|
241
|
+
const minutes = Math.floor((etaSeconds % (60 * 60)) / 60);
|
|
242
|
+
|
|
243
|
+
const parts = [];
|
|
244
|
+
|
|
245
|
+
if (days > 0) {
|
|
246
|
+
parts.push(`${days}d`);
|
|
247
|
+
}
|
|
248
|
+
if (hours > 0) {
|
|
249
|
+
parts.push(`${hours}h`);
|
|
250
|
+
}
|
|
251
|
+
if (minutes > 0 && days === 0) {
|
|
252
|
+
// Only show minutes if no days (for readability)
|
|
253
|
+
parts.push(`${minutes}m`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// If less than a minute, show "< 1m"
|
|
257
|
+
if (parts.length === 0) {
|
|
258
|
+
return '< 1m';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return parts.join(' ');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = {
|
|
265
|
+
calculatePace,
|
|
266
|
+
formatPaceWarning,
|
|
267
|
+
evaluatePace,
|
|
268
|
+
formatEtaString
|
|
269
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public Status API Polling Module
|
|
3
|
+
*
|
|
4
|
+
* Fetches status information from public API status pages for Anthropic, OpenAI,
|
|
5
|
+
* and Google Cloud. Uses Statuspage.io JSON API for Anthropic and OpenAI, and
|
|
6
|
+
* Google Cloud's incidents JSON for Google/Gemini.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const https = require('node:https');
|
|
10
|
+
|
|
11
|
+
// Status page configurations
|
|
12
|
+
const STATUS_PAGES = {
|
|
13
|
+
claude: {
|
|
14
|
+
url: 'https://anthropic.statuspage.io/api/v2/status.json',
|
|
15
|
+
type: 'statuspage',
|
|
16
|
+
name: 'Anthropic',
|
|
17
|
+
},
|
|
18
|
+
codex: {
|
|
19
|
+
url: 'https://status.openai.com/api/v2/status.json',
|
|
20
|
+
type: 'statuspage',
|
|
21
|
+
name: 'OpenAI',
|
|
22
|
+
},
|
|
23
|
+
gemini: {
|
|
24
|
+
url: 'https://status.cloud.google.com/incidents.json',
|
|
25
|
+
type: 'google-cloud',
|
|
26
|
+
name: 'Google Cloud',
|
|
27
|
+
// Vertex AI Gemini product ID for filtering incidents
|
|
28
|
+
productId: 'Z0FZJAMvEB4j3NbCJs6B',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Status indicator mapping (statuspage.io format)
|
|
33
|
+
// Possible values: none, minor, major, critical
|
|
34
|
+
const STATUS_INDICATOR_MAP = {
|
|
35
|
+
none: 'operational',
|
|
36
|
+
minor: 'degraded',
|
|
37
|
+
major: 'outage',
|
|
38
|
+
critical: 'outage',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Request timeout in milliseconds
|
|
42
|
+
const FETCH_TIMEOUT = 10000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Makes an HTTPS GET request and returns the response body as a string
|
|
46
|
+
* @param {string} url - The URL to fetch
|
|
47
|
+
* @returns {Promise<string>} - The response body
|
|
48
|
+
*/
|
|
49
|
+
function httpsGet(url) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const request = https.get(url, { timeout: FETCH_TIMEOUT }, (res) => {
|
|
52
|
+
if (res.statusCode !== 200) {
|
|
53
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let data = '';
|
|
58
|
+
res.on('data', (chunk) => {
|
|
59
|
+
data += chunk;
|
|
60
|
+
});
|
|
61
|
+
res.on('end', () => {
|
|
62
|
+
resolve(data);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
request.on('error', reject);
|
|
67
|
+
request.on('timeout', () => {
|
|
68
|
+
request.destroy();
|
|
69
|
+
reject(new Error('Request timeout'));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse Statuspage.io JSON response
|
|
76
|
+
* @param {string} json - Raw JSON response
|
|
77
|
+
* @returns {object} - Parsed status object
|
|
78
|
+
*/
|
|
79
|
+
function parseStatuspageResponse(json) {
|
|
80
|
+
const data = JSON.parse(json);
|
|
81
|
+
const indicator = data.status?.indicator || 'none';
|
|
82
|
+
const description = data.status?.description || 'Unknown';
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
status: STATUS_INDICATOR_MAP[indicator] || 'unknown',
|
|
86
|
+
indicator,
|
|
87
|
+
description,
|
|
88
|
+
updatedAt: data.page?.updated_at || null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse Google Cloud incidents JSON response
|
|
94
|
+
* Determines status based on recent active incidents affecting the specified product
|
|
95
|
+
* @param {string} json - Raw JSON response
|
|
96
|
+
* @param {string} productId - Product ID to filter incidents
|
|
97
|
+
* @returns {object} - Parsed status object
|
|
98
|
+
*/
|
|
99
|
+
function parseGoogleCloudResponse(json, productId) {
|
|
100
|
+
const incidents = JSON.parse(json);
|
|
101
|
+
|
|
102
|
+
// Find active incidents affecting the specified product (e.g., Vertex AI Gemini)
|
|
103
|
+
const now = new Date();
|
|
104
|
+
const activeIncidents = incidents.filter((incident) => {
|
|
105
|
+
// Check if incident is ongoing (no end date or recent)
|
|
106
|
+
if (incident.end) {
|
|
107
|
+
const endDate = new Date(incident.end);
|
|
108
|
+
// Consider incidents that ended less than 1 hour ago as potentially still affecting
|
|
109
|
+
if (now - endDate > 60 * 60 * 1000) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check if incident affects the target product
|
|
115
|
+
if (productId && incident.affected_products) {
|
|
116
|
+
const affectsProduct = incident.affected_products.some(
|
|
117
|
+
(p) => p.id === productId || p.title?.toLowerCase().includes('gemini') ||
|
|
118
|
+
p.title?.toLowerCase().includes('vertex')
|
|
119
|
+
);
|
|
120
|
+
if (!affectsProduct) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check status - include ongoing or recent disruptions
|
|
126
|
+
return incident.status === 'SERVICE_DISRUPTION' ||
|
|
127
|
+
incident.status === 'SERVICE_OUTAGE' ||
|
|
128
|
+
!incident.end;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Determine overall status based on active incidents
|
|
132
|
+
if (activeIncidents.length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
status: 'operational',
|
|
135
|
+
indicator: 'none',
|
|
136
|
+
description: 'All Systems Operational',
|
|
137
|
+
updatedAt: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Find the most severe active incident
|
|
142
|
+
const hasOutage = activeIncidents.some((i) => i.status === 'SERVICE_OUTAGE');
|
|
143
|
+
const hasDisruption = activeIncidents.some((i) => i.status === 'SERVICE_DISRUPTION');
|
|
144
|
+
|
|
145
|
+
if (hasOutage) {
|
|
146
|
+
return {
|
|
147
|
+
status: 'outage',
|
|
148
|
+
indicator: 'major',
|
|
149
|
+
description: activeIncidents[0].external_desc || 'Service Outage',
|
|
150
|
+
updatedAt: activeIncidents[0].modified || null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (hasDisruption) {
|
|
155
|
+
return {
|
|
156
|
+
status: 'degraded',
|
|
157
|
+
indicator: 'minor',
|
|
158
|
+
description: activeIncidents[0].external_desc || 'Service Disruption',
|
|
159
|
+
updatedAt: activeIncidents[0].modified || null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Default to operational if incidents are resolved
|
|
164
|
+
return {
|
|
165
|
+
status: 'operational',
|
|
166
|
+
indicator: 'none',
|
|
167
|
+
description: 'All Systems Operational',
|
|
168
|
+
updatedAt: null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Fetch public status for a single agent
|
|
174
|
+
* @param {string} agentName - Name of the agent (claude, gemini, codex)
|
|
175
|
+
* @returns {Promise<object>} - Status object with status, indicator, description
|
|
176
|
+
*/
|
|
177
|
+
async function fetchAgentPublicStatus(agentName) {
|
|
178
|
+
const config = STATUS_PAGES[agentName];
|
|
179
|
+
|
|
180
|
+
if (!config) {
|
|
181
|
+
return {
|
|
182
|
+
status: 'unknown',
|
|
183
|
+
indicator: 'unknown',
|
|
184
|
+
description: 'No status page configured',
|
|
185
|
+
error: null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const response = await httpsGet(config.url);
|
|
191
|
+
|
|
192
|
+
if (config.type === 'statuspage') {
|
|
193
|
+
return parseStatuspageResponse(response);
|
|
194
|
+
} else if (config.type === 'google-cloud') {
|
|
195
|
+
return parseGoogleCloudResponse(response, config.productId);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
status: 'unknown',
|
|
200
|
+
indicator: 'unknown',
|
|
201
|
+
description: 'Unknown status page type',
|
|
202
|
+
error: null,
|
|
203
|
+
};
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error(`[PublicStatus] Error fetching ${agentName} status:`, err.message);
|
|
206
|
+
return {
|
|
207
|
+
status: 'unknown',
|
|
208
|
+
indicator: 'unknown',
|
|
209
|
+
description: 'Unable to fetch status',
|
|
210
|
+
error: err.message,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Fetch public status for all configured agents
|
|
217
|
+
* @param {string[]} agentNames - Array of agent names to fetch status for
|
|
218
|
+
* @returns {Promise<object>} - Object mapping agent names to their public status
|
|
219
|
+
*/
|
|
220
|
+
async function fetchPublicStatus(agentNames = Object.keys(STATUS_PAGES)) {
|
|
221
|
+
const results = {};
|
|
222
|
+
|
|
223
|
+
const promises = agentNames.map(async (name) => {
|
|
224
|
+
results[name] = await fetchAgentPublicStatus(name);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await Promise.all(promises);
|
|
228
|
+
|
|
229
|
+
return results;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get the badge color class for a status
|
|
234
|
+
* @param {string} status - Status string (operational, degraded, outage, unknown)
|
|
235
|
+
* @returns {string} - CSS class name for the badge
|
|
236
|
+
*/
|
|
237
|
+
function getStatusBadgeClass(status) {
|
|
238
|
+
switch (status) {
|
|
239
|
+
case 'operational':
|
|
240
|
+
return 'status-badge-green';
|
|
241
|
+
case 'degraded':
|
|
242
|
+
return 'status-badge-yellow';
|
|
243
|
+
case 'outage':
|
|
244
|
+
return 'status-badge-red';
|
|
245
|
+
default:
|
|
246
|
+
return 'status-badge-grey';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get the human-readable status text
|
|
252
|
+
* @param {string} status - Status string
|
|
253
|
+
* @returns {string} - Human-readable status
|
|
254
|
+
*/
|
|
255
|
+
function getStatusText(status) {
|
|
256
|
+
switch (status) {
|
|
257
|
+
case 'operational':
|
|
258
|
+
return 'Operational';
|
|
259
|
+
case 'degraded':
|
|
260
|
+
return 'Degraded';
|
|
261
|
+
case 'outage':
|
|
262
|
+
return 'Outage';
|
|
263
|
+
default:
|
|
264
|
+
return 'Unknown';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = {
|
|
269
|
+
fetchPublicStatus,
|
|
270
|
+
fetchAgentPublicStatus,
|
|
271
|
+
getStatusBadgeClass,
|
|
272
|
+
getStatusText,
|
|
273
|
+
parseStatuspageResponse,
|
|
274
|
+
parseGoogleCloudResponse,
|
|
275
|
+
STATUS_PAGES,
|
|
276
|
+
};
|
package/src/server.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Server module for Agent Tank
|
|
3
|
+
* Handles routing and request processing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const http = require('node:http');
|
|
7
|
+
const logger = require('./logger.js');
|
|
8
|
+
const { statusPage } = require('./status-page.js');
|
|
9
|
+
|
|
10
|
+
function createRequestHandler(tank) {
|
|
11
|
+
return async (req, res) => {
|
|
12
|
+
const url = new URL(req.url, `http://localhost:${tank.port}`);
|
|
13
|
+
const path = url.pathname;
|
|
14
|
+
|
|
15
|
+
// CORS headers
|
|
16
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
17
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
18
|
+
|
|
19
|
+
if (req.method === 'OPTIONS') {
|
|
20
|
+
res.writeHead(204);
|
|
21
|
+
res.end();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!tank.authenticate(req, url)) {
|
|
26
|
+
res.setHeader('WWW-Authenticate', 'Basic realm="LLM Limit Watcher"');
|
|
27
|
+
res.writeHead(401);
|
|
28
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// Routes
|
|
34
|
+
if (req.method === 'GET' && path === '/') {
|
|
35
|
+
res.setHeader('Content-Type', 'text/html');
|
|
36
|
+
res.writeHead(200);
|
|
37
|
+
res.end(statusPage(tank.getStatus()));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (req.method === 'GET' && path === '/status') {
|
|
42
|
+
const status = tank.getStatus();
|
|
43
|
+
logger.server(`GET /status - returning status for ${Object.keys(status).length} agents`);
|
|
44
|
+
res.setHeader('Content-Type', 'application/json');
|
|
45
|
+
res.writeHead(200);
|
|
46
|
+
res.end(JSON.stringify(status, null, 2));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (req.method === 'GET' && path === '/config') {
|
|
51
|
+
const config = {
|
|
52
|
+
autoRefresh: {
|
|
53
|
+
enabled: tank.autoRefresh.enabled && tank.autoRefresh.interval > 0,
|
|
54
|
+
interval: tank.autoRefresh.interval, // in seconds
|
|
55
|
+
},
|
|
56
|
+
lastRefreshedAt: tank.lastRefreshedAt,
|
|
57
|
+
};
|
|
58
|
+
res.setHeader('Content-Type', 'application/json');
|
|
59
|
+
res.writeHead(200);
|
|
60
|
+
res.end(JSON.stringify(config, null, 2));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (req.method === 'GET' && path.startsWith('/status/')) {
|
|
65
|
+
const agentName = path.slice(8);
|
|
66
|
+
const status = tank.getAgentStatus(agentName);
|
|
67
|
+
if (!status) {
|
|
68
|
+
res.writeHead(404);
|
|
69
|
+
res.end(JSON.stringify({ error: 'Agent not found' }));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
res.setHeader('Content-Type', 'application/json');
|
|
73
|
+
res.writeHead(200);
|
|
74
|
+
res.end(JSON.stringify(status, null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (req.method === 'POST' && path === '/refresh') {
|
|
79
|
+
await tank.refreshAll();
|
|
80
|
+
res.setHeader('Content-Type', 'application/json');
|
|
81
|
+
res.writeHead(200);
|
|
82
|
+
res.end(JSON.stringify({ success: true, status: tank.getStatus() }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (req.method === 'POST' && path.startsWith('/refresh/')) {
|
|
87
|
+
const agentName = path.slice(9);
|
|
88
|
+
try {
|
|
89
|
+
await tank.refreshAgent(agentName);
|
|
90
|
+
res.setHeader('Content-Type', 'application/json');
|
|
91
|
+
res.writeHead(200);
|
|
92
|
+
res.end(JSON.stringify({ success: true, status: tank.getAgentStatus(agentName) }));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
res.writeHead(404);
|
|
95
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 404
|
|
101
|
+
res.writeHead(404);
|
|
102
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
103
|
+
|
|
104
|
+
} catch (err) {
|
|
105
|
+
logger.error('Server error:', err);
|
|
106
|
+
res.writeHead(500);
|
|
107
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create and configure the HTTP server
|
|
114
|
+
* @param {Object} tank - AgentTank instance
|
|
115
|
+
* @returns {http.Server}
|
|
116
|
+
*/
|
|
117
|
+
function createServer(tank) {
|
|
118
|
+
return http.createServer(createRequestHandler(tank));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Display server startup banner
|
|
123
|
+
* @param {Object} tank - AgentTank instance
|
|
124
|
+
*/
|
|
125
|
+
function displayServerBanner(tank) {
|
|
126
|
+
const ANSI = logger.ANSI;
|
|
127
|
+
console.log('');
|
|
128
|
+
console.log(`${ANSI.cyan}${ANSI.bold}╔════════════════════════════════════════════╗${ANSI.reset}`);
|
|
129
|
+
console.log(`${ANSI.cyan}${ANSI.bold}║${ANSI.reset} ${ANSI.brightCyan}🚀 Agent Tank Server${ANSI.reset} ${ANSI.cyan}${ANSI.bold}║${ANSI.reset}`);
|
|
130
|
+
console.log(`${ANSI.cyan}${ANSI.bold}╚════════════════════════════════════════════╝${ANSI.reset}`);
|
|
131
|
+
console.log('');
|
|
132
|
+
if (tank.auth.user && tank.auth.pass) {
|
|
133
|
+
logger.info(`🔐 Authentication: basic auth (user: ${tank.auth.user})`);
|
|
134
|
+
}
|
|
135
|
+
if (tank.auth.token) {
|
|
136
|
+
logger.info('🔑 Authentication: API key');
|
|
137
|
+
}
|
|
138
|
+
logger.server(`Listening on: ${tank.host}:${tank.port}`);
|
|
139
|
+
logger.server(`📄 Status page: http://${tank.host}:${tank.port}/`);
|
|
140
|
+
logger.server(`📡 JSON API: http://${tank.host}:${tank.port}/status`);
|
|
141
|
+
console.log('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { createRequestHandler, createServer, displayServerBanner };
|