@trentapps/manager-protocol 1.1.1
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 +446 -0
- package/dist/analyzers/ArchitectureDetector.d.ts +44 -0
- package/dist/analyzers/ArchitectureDetector.d.ts.map +1 -0
- package/dist/analyzers/ArchitectureDetector.js +218 -0
- package/dist/analyzers/ArchitectureDetector.js.map +1 -0
- package/dist/analyzers/CSSAnalyzer.d.ts +104 -0
- package/dist/analyzers/CSSAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/CSSAnalyzer.js +578 -0
- package/dist/analyzers/CSSAnalyzer.js.map +1 -0
- package/dist/analyzers/index.d.ts +5 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/index.js +5 -0
- package/dist/analyzers/index.js.map +1 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +174 -0
- package/dist/cli.js.map +1 -0
- package/dist/design-system/index.d.ts +6 -0
- package/dist/design-system/index.d.ts.map +1 -0
- package/dist/design-system/index.js +6 -0
- package/dist/design-system/index.js.map +1 -0
- package/dist/design-system/tokens.d.ts +106 -0
- package/dist/design-system/tokens.d.ts.map +1 -0
- package/dist/design-system/tokens.js +554 -0
- package/dist/design-system/tokens.js.map +1 -0
- package/dist/engine/AppMonitor.d.ts +162 -0
- package/dist/engine/AppMonitor.d.ts.map +1 -0
- package/dist/engine/AppMonitor.js +754 -0
- package/dist/engine/AppMonitor.js.map +1 -0
- package/dist/engine/AuditLogger.d.ts +138 -0
- package/dist/engine/AuditLogger.d.ts.map +1 -0
- package/dist/engine/AuditLogger.js +448 -0
- package/dist/engine/AuditLogger.js.map +1 -0
- package/dist/engine/GitHubApprovalManager.d.ts +106 -0
- package/dist/engine/GitHubApprovalManager.d.ts.map +1 -0
- package/dist/engine/GitHubApprovalManager.js +315 -0
- package/dist/engine/GitHubApprovalManager.js.map +1 -0
- package/dist/engine/RateLimiter.d.ts +79 -0
- package/dist/engine/RateLimiter.d.ts.map +1 -0
- package/dist/engine/RateLimiter.js +232 -0
- package/dist/engine/RateLimiter.js.map +1 -0
- package/dist/engine/RulesEngine.d.ts +77 -0
- package/dist/engine/RulesEngine.d.ts.map +1 -0
- package/dist/engine/RulesEngine.js +400 -0
- package/dist/engine/RulesEngine.js.map +1 -0
- package/dist/engine/TaskManager.d.ts +173 -0
- package/dist/engine/TaskManager.d.ts.map +1 -0
- package/dist/engine/TaskManager.js +678 -0
- package/dist/engine/TaskManager.js.map +1 -0
- package/dist/engine/index.d.ts +9 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +9 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/rules/architecture.d.ts +9 -0
- package/dist/rules/architecture.d.ts.map +1 -0
- package/dist/rules/architecture.js +322 -0
- package/dist/rules/architecture.js.map +1 -0
- package/dist/rules/azure.d.ts +7 -0
- package/dist/rules/azure.d.ts.map +1 -0
- package/dist/rules/azure.js +138 -0
- package/dist/rules/azure.js.map +1 -0
- package/dist/rules/compliance.d.ts +9 -0
- package/dist/rules/compliance.d.ts.map +1 -0
- package/dist/rules/compliance.js +304 -0
- package/dist/rules/compliance.js.map +1 -0
- package/dist/rules/css.d.ts +10 -0
- package/dist/rules/css.d.ts.map +1 -0
- package/dist/rules/css.js +1239 -0
- package/dist/rules/css.js.map +1 -0
- package/dist/rules/flask.d.ts +7 -0
- package/dist/rules/flask.d.ts.map +1 -0
- package/dist/rules/flask.js +155 -0
- package/dist/rules/flask.js.map +1 -0
- package/dist/rules/index.d.ts +607 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +401 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/ml-ai.d.ts +7 -0
- package/dist/rules/ml-ai.d.ts.map +1 -0
- package/dist/rules/ml-ai.js +150 -0
- package/dist/rules/ml-ai.js.map +1 -0
- package/dist/rules/operational.d.ts +9 -0
- package/dist/rules/operational.d.ts.map +1 -0
- package/dist/rules/operational.js +318 -0
- package/dist/rules/operational.js.map +1 -0
- package/dist/rules/security.d.ts +9 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +287 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/rules/storage.d.ts +7 -0
- package/dist/rules/storage.d.ts.map +1 -0
- package/dist/rules/storage.js +134 -0
- package/dist/rules/storage.js.map +1 -0
- package/dist/rules/stripe.d.ts +7 -0
- package/dist/rules/stripe.d.ts.map +1 -0
- package/dist/rules/stripe.js +140 -0
- package/dist/rules/stripe.js.map +1 -0
- package/dist/rules/testing.d.ts +7 -0
- package/dist/rules/testing.d.ts.map +1 -0
- package/dist/rules/testing.js +135 -0
- package/dist/rules/testing.js.map +1 -0
- package/dist/rules/ux.d.ts +9 -0
- package/dist/rules/ux.d.ts.map +1 -0
- package/dist/rules/ux.js +280 -0
- package/dist/rules/ux.js.map +1 -0
- package/dist/rules/websocket.d.ts +7 -0
- package/dist/rules/websocket.d.ts.map +1 -0
- package/dist/rules/websocket.js +136 -0
- package/dist/rules/websocket.js.map +1 -0
- package/dist/server.d.ts +49 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +2330 -0
- package/dist/server.js.map +1 -0
- package/dist/supervisor/AgentSupervisor.d.ts +235 -0
- package/dist/supervisor/AgentSupervisor.d.ts.map +1 -0
- package/dist/supervisor/AgentSupervisor.js +596 -0
- package/dist/supervisor/AgentSupervisor.js.map +1 -0
- package/dist/supervisor/ManagedServerRegistry.d.ts +48 -0
- package/dist/supervisor/ManagedServerRegistry.d.ts.map +1 -0
- package/dist/supervisor/ManagedServerRegistry.js +145 -0
- package/dist/supervisor/ManagedServerRegistry.js.map +1 -0
- package/dist/supervisor/ProjectTracker.d.ts +188 -0
- package/dist/supervisor/ProjectTracker.d.ts.map +1 -0
- package/dist/supervisor/ProjectTracker.js +617 -0
- package/dist/supervisor/ProjectTracker.js.map +1 -0
- package/dist/supervisor/index.d.ts +6 -0
- package/dist/supervisor/index.d.ts.map +1 -0
- package/dist/supervisor/index.js +6 -0
- package/dist/supervisor/index.js.map +1 -0
- package/dist/types/index.d.ts +1176 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +391 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/errors.d.ts +86 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +171 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/shell.d.ts +22 -0
- package/dist/utils/shell.d.ts.map +1 -0
- package/dist/utils/shell.js +29 -0
- package/dist/utils/shell.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise Agent Supervisor - App Monitor
|
|
3
|
+
*
|
|
4
|
+
* Monitors production applications running on the server.
|
|
5
|
+
* Tracks online status, health checks, and process information.
|
|
6
|
+
*/
|
|
7
|
+
import { exec } from 'child_process';
|
|
8
|
+
import { promisify } from 'util';
|
|
9
|
+
import * as http from 'http';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import { escapeForShell } from '../utils/shell.js';
|
|
13
|
+
import { auditLogger } from './AuditLogger.js';
|
|
14
|
+
const execAsync = promisify(exec);
|
|
15
|
+
export class AppMonitor {
|
|
16
|
+
apps = new Map();
|
|
17
|
+
statusHistory = new Map();
|
|
18
|
+
lastCheckResults = new Map();
|
|
19
|
+
checkIntervals = new Map();
|
|
20
|
+
prodBasePath;
|
|
21
|
+
maxHistoryEntries;
|
|
22
|
+
maxTotalHistoryEntries;
|
|
23
|
+
defaultCheckIntervalMs;
|
|
24
|
+
defaultTimeoutMs;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.prodBasePath = options.prodBasePath ?? '/mnt/prod';
|
|
27
|
+
this.maxHistoryEntries = options.maxHistoryEntries ?? 100; // Reduced from 1000
|
|
28
|
+
this.maxTotalHistoryEntries = options.maxTotalHistoryEntries ?? 10000; // Global cap
|
|
29
|
+
this.defaultCheckIntervalMs = options.defaultCheckIntervalMs ?? 30000;
|
|
30
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 5000;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Generate a unique app ID
|
|
34
|
+
*/
|
|
35
|
+
generateAppId() {
|
|
36
|
+
return `app_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Add a new app to monitor
|
|
40
|
+
*/
|
|
41
|
+
async addApp(config) {
|
|
42
|
+
// Validate path exists
|
|
43
|
+
const fullPath = config.path.startsWith('/')
|
|
44
|
+
? config.path
|
|
45
|
+
: path.join(this.prodBasePath, config.path);
|
|
46
|
+
const pathExists = await this.checkPathExists(fullPath);
|
|
47
|
+
if (!pathExists) {
|
|
48
|
+
throw new Error(`App path does not exist: ${fullPath}`);
|
|
49
|
+
}
|
|
50
|
+
// Check if app with same name or port already exists
|
|
51
|
+
for (const [, app] of this.apps) {
|
|
52
|
+
if (app.name === config.name) {
|
|
53
|
+
throw new Error(`App with name "${config.name}" already exists`);
|
|
54
|
+
}
|
|
55
|
+
if (app.port === config.port) {
|
|
56
|
+
throw new Error(`App with port ${config.port} already exists (${app.name})`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const now = new Date().toISOString();
|
|
60
|
+
const app = {
|
|
61
|
+
id: this.generateAppId(),
|
|
62
|
+
name: config.name,
|
|
63
|
+
path: fullPath,
|
|
64
|
+
port: config.port,
|
|
65
|
+
description: config.description,
|
|
66
|
+
healthEndpoint: config.healthEndpoint,
|
|
67
|
+
expectedResponseCode: config.expectedResponseCode ?? 200,
|
|
68
|
+
checkIntervalMs: config.checkIntervalMs ?? this.defaultCheckIntervalMs,
|
|
69
|
+
timeoutMs: config.timeoutMs ?? this.defaultTimeoutMs,
|
|
70
|
+
enabled: true,
|
|
71
|
+
tags: config.tags,
|
|
72
|
+
metadata: config.metadata,
|
|
73
|
+
createdAt: now
|
|
74
|
+
};
|
|
75
|
+
// Re-check for duplicates immediately before insertion to prevent race condition
|
|
76
|
+
// (another addApp() call could have completed between our initial check and now)
|
|
77
|
+
for (const [, existingApp] of this.apps) {
|
|
78
|
+
if (existingApp.name === config.name) {
|
|
79
|
+
throw new Error(`App with name "${config.name}" already exists (added concurrently)`);
|
|
80
|
+
}
|
|
81
|
+
if (existingApp.port === config.port) {
|
|
82
|
+
throw new Error(`App with port ${config.port} already exists (${existingApp.name}, added concurrently)`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.apps.set(app.id, app);
|
|
86
|
+
this.statusHistory.set(app.id, []);
|
|
87
|
+
// Start monitoring if autoStart is true (default)
|
|
88
|
+
if (config.autoStart !== false) {
|
|
89
|
+
this.startMonitoring(app.id);
|
|
90
|
+
}
|
|
91
|
+
// Do an initial check
|
|
92
|
+
await this.checkAppHealth(app.id);
|
|
93
|
+
return app;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Remove an app from monitoring
|
|
97
|
+
*/
|
|
98
|
+
removeApp(appId) {
|
|
99
|
+
const app = this.apps.get(appId);
|
|
100
|
+
if (!app) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
// Stop monitoring interval
|
|
104
|
+
this.stopMonitoring(appId);
|
|
105
|
+
// Remove from all maps
|
|
106
|
+
this.apps.delete(appId);
|
|
107
|
+
this.statusHistory.delete(appId);
|
|
108
|
+
this.lastCheckResults.delete(appId);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Get an app by ID
|
|
113
|
+
*/
|
|
114
|
+
getApp(appId) {
|
|
115
|
+
return this.apps.get(appId);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get app by name
|
|
119
|
+
*/
|
|
120
|
+
getAppByName(name) {
|
|
121
|
+
for (const [, app] of this.apps) {
|
|
122
|
+
if (app.name === name) {
|
|
123
|
+
return app;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Get all apps
|
|
130
|
+
*/
|
|
131
|
+
getAllApps() {
|
|
132
|
+
return Array.from(this.apps.values());
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Update an app configuration
|
|
136
|
+
*/
|
|
137
|
+
updateApp(appId, updates) {
|
|
138
|
+
const app = this.apps.get(appId);
|
|
139
|
+
if (!app) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const updatedApp = {
|
|
143
|
+
...app,
|
|
144
|
+
...updates,
|
|
145
|
+
updatedAt: new Date().toISOString()
|
|
146
|
+
};
|
|
147
|
+
this.apps.set(appId, updatedApp);
|
|
148
|
+
// Restart monitoring if interval changed
|
|
149
|
+
if (updates.checkIntervalMs !== undefined && updates.checkIntervalMs !== app.checkIntervalMs) {
|
|
150
|
+
this.stopMonitoring(appId);
|
|
151
|
+
if (updatedApp.enabled) {
|
|
152
|
+
this.startMonitoring(appId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return updatedApp;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Check if a path exists
|
|
159
|
+
*/
|
|
160
|
+
async checkPathExists(pathToCheck) {
|
|
161
|
+
try {
|
|
162
|
+
await fs.promises.access(pathToCheck, fs.constants.F_OK);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Check if a port is in use
|
|
171
|
+
*/
|
|
172
|
+
async isPortInUse(port) {
|
|
173
|
+
try {
|
|
174
|
+
const { stdout } = await execAsync(`lsof -i :${escapeForShell(port)} -t 2>/dev/null || true`);
|
|
175
|
+
const pid = stdout.trim();
|
|
176
|
+
if (pid) {
|
|
177
|
+
return { inUse: true, pid: parseInt(pid.split('\n')[0], 10) };
|
|
178
|
+
}
|
|
179
|
+
return { inUse: false };
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
await auditLogger.log({
|
|
183
|
+
eventType: 'system_event',
|
|
184
|
+
action: 'port_check_failed',
|
|
185
|
+
outcome: 'failure',
|
|
186
|
+
details: {
|
|
187
|
+
port,
|
|
188
|
+
error: error instanceof Error ? error.message : String(error)
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return { inUse: false };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get process info by PID
|
|
196
|
+
*/
|
|
197
|
+
async getProcessInfo(pid) {
|
|
198
|
+
try {
|
|
199
|
+
// Get memory and CPU using ps
|
|
200
|
+
const { stdout } = await execAsync(`ps -p ${escapeForShell(pid)} -o %mem,%cpu,etime --no-headers 2>/dev/null || true`);
|
|
201
|
+
const parts = stdout.trim().split(/\s+/);
|
|
202
|
+
if (parts.length >= 3) {
|
|
203
|
+
// Get total memory to calculate MB
|
|
204
|
+
const { stdout: memTotal } = await execAsync(`free -m | awk '/Mem:/ {print $2}'`);
|
|
205
|
+
const totalMemMb = parseInt(memTotal.trim(), 10);
|
|
206
|
+
const memPercent = parseFloat(parts[0]);
|
|
207
|
+
return {
|
|
208
|
+
memoryUsageMb: Math.round((memPercent / 100) * totalMemMb),
|
|
209
|
+
cpuPercent: parseFloat(parts[1]),
|
|
210
|
+
uptime: parts[2]
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
await auditLogger.log({
|
|
216
|
+
eventType: 'system_event',
|
|
217
|
+
action: 'process_info_failed',
|
|
218
|
+
outcome: 'failure',
|
|
219
|
+
details: {
|
|
220
|
+
pid,
|
|
221
|
+
error: error instanceof Error ? error.message : String(error)
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return {};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Make an HTTP health check request with proper cleanup to prevent memory leaks
|
|
229
|
+
*/
|
|
230
|
+
async httpHealthCheck(port, endpoint, timeoutMs, expectedStatusCode) {
|
|
231
|
+
const startTime = Date.now();
|
|
232
|
+
const url = `http://localhost:${port}${endpoint}`;
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
let resolved = false;
|
|
235
|
+
const safeResolve = (result) => {
|
|
236
|
+
if (!resolved) {
|
|
237
|
+
resolved = true;
|
|
238
|
+
resolve(result);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const request = http.get(url, { timeout: timeoutMs }, (res) => {
|
|
242
|
+
const responseTimeMs = Date.now() - startTime;
|
|
243
|
+
const success = res.statusCode === expectedStatusCode;
|
|
244
|
+
// Consume response body to prevent memory leak
|
|
245
|
+
res.resume();
|
|
246
|
+
// Clean up response listeners
|
|
247
|
+
res.on('end', () => {
|
|
248
|
+
safeResolve({
|
|
249
|
+
success,
|
|
250
|
+
statusCode: res.statusCode,
|
|
251
|
+
responseTimeMs,
|
|
252
|
+
error: success ? undefined : `Expected ${expectedStatusCode}, got ${res.statusCode}`
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
res.on('error', () => {
|
|
256
|
+
// Response error after connection established
|
|
257
|
+
request.destroy();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
request.on('error', (err) => {
|
|
261
|
+
request.destroy();
|
|
262
|
+
safeResolve({
|
|
263
|
+
success: false,
|
|
264
|
+
responseTimeMs: Date.now() - startTime,
|
|
265
|
+
error: err.message
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
request.on('timeout', () => {
|
|
269
|
+
request.destroy();
|
|
270
|
+
safeResolve({
|
|
271
|
+
success: false,
|
|
272
|
+
responseTimeMs: timeoutMs,
|
|
273
|
+
error: 'Request timed out'
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Check health of a specific app
|
|
280
|
+
*/
|
|
281
|
+
async checkAppHealth(appId) {
|
|
282
|
+
const app = this.apps.get(appId);
|
|
283
|
+
if (!app) {
|
|
284
|
+
throw new Error(`App not found: ${appId}`);
|
|
285
|
+
}
|
|
286
|
+
const now = new Date().toISOString();
|
|
287
|
+
const portCheck = await this.isPortInUse(app.port);
|
|
288
|
+
let status = 'unknown';
|
|
289
|
+
let responseTimeMs;
|
|
290
|
+
let httpStatusCode;
|
|
291
|
+
let errorMessage;
|
|
292
|
+
let processInfo;
|
|
293
|
+
if (!portCheck.inUse) {
|
|
294
|
+
status = 'offline';
|
|
295
|
+
errorMessage = `Port ${app.port} is not listening`;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// Get process info
|
|
299
|
+
if (portCheck.pid) {
|
|
300
|
+
processInfo = {
|
|
301
|
+
pid: portCheck.pid,
|
|
302
|
+
...(await this.getProcessInfo(portCheck.pid))
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// If health endpoint is configured, do HTTP check
|
|
306
|
+
if (app.healthEndpoint) {
|
|
307
|
+
const healthResult = await this.httpHealthCheck(app.port, app.healthEndpoint, app.timeoutMs, app.expectedResponseCode);
|
|
308
|
+
responseTimeMs = healthResult.responseTimeMs;
|
|
309
|
+
httpStatusCode = healthResult.statusCode;
|
|
310
|
+
if (healthResult.success) {
|
|
311
|
+
// Check if response time is degraded (> 2 seconds)
|
|
312
|
+
status = responseTimeMs > 2000 ? 'degraded' : 'online';
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
status = 'degraded';
|
|
316
|
+
errorMessage = healthResult.error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
// No health endpoint, just check port
|
|
321
|
+
status = 'online';
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const result = {
|
|
325
|
+
appId: app.id,
|
|
326
|
+
appName: app.name,
|
|
327
|
+
status,
|
|
328
|
+
port: app.port,
|
|
329
|
+
path: app.path,
|
|
330
|
+
responseTimeMs,
|
|
331
|
+
httpStatusCode,
|
|
332
|
+
errorMessage,
|
|
333
|
+
checkedAt: now,
|
|
334
|
+
processInfo
|
|
335
|
+
};
|
|
336
|
+
// Store result
|
|
337
|
+
this.lastCheckResults.set(appId, result);
|
|
338
|
+
// Add to history
|
|
339
|
+
this.addToHistory(appId, {
|
|
340
|
+
appId,
|
|
341
|
+
status,
|
|
342
|
+
timestamp: now,
|
|
343
|
+
responseTimeMs,
|
|
344
|
+
errorMessage
|
|
345
|
+
});
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Check health of all apps
|
|
350
|
+
*/
|
|
351
|
+
async checkAllApps() {
|
|
352
|
+
const results = [];
|
|
353
|
+
for (const [appId, app] of this.apps) {
|
|
354
|
+
if (app.enabled) {
|
|
355
|
+
try {
|
|
356
|
+
const result = await this.checkAppHealth(appId);
|
|
357
|
+
results.push(result);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
await auditLogger.log({
|
|
361
|
+
eventType: 'system_event',
|
|
362
|
+
action: 'app_health_check_failed',
|
|
363
|
+
outcome: 'failure',
|
|
364
|
+
details: {
|
|
365
|
+
appId,
|
|
366
|
+
appName: app.name,
|
|
367
|
+
port: app.port,
|
|
368
|
+
path: app.path,
|
|
369
|
+
error: error instanceof Error ? error.message : String(error)
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
results.push({
|
|
373
|
+
appId,
|
|
374
|
+
appName: app.name,
|
|
375
|
+
status: 'unknown',
|
|
376
|
+
port: app.port,
|
|
377
|
+
path: app.path,
|
|
378
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
379
|
+
checkedAt: new Date().toISOString()
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return results;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Get the last check result for an app
|
|
388
|
+
*/
|
|
389
|
+
getLastCheckResult(appId) {
|
|
390
|
+
return this.lastCheckResults.get(appId);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Get status history for an app
|
|
394
|
+
*/
|
|
395
|
+
getStatusHistory(appId, limit) {
|
|
396
|
+
const history = this.statusHistory.get(appId) ?? [];
|
|
397
|
+
if (limit) {
|
|
398
|
+
return history.slice(-limit);
|
|
399
|
+
}
|
|
400
|
+
return history;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Add entry to status history with global memory budget enforcement
|
|
404
|
+
*/
|
|
405
|
+
addToHistory(appId, entry) {
|
|
406
|
+
let history = this.statusHistory.get(appId);
|
|
407
|
+
if (!history) {
|
|
408
|
+
history = [];
|
|
409
|
+
this.statusHistory.set(appId, history);
|
|
410
|
+
}
|
|
411
|
+
history.push(entry);
|
|
412
|
+
// Trim per-app history if too long
|
|
413
|
+
if (history.length > this.maxHistoryEntries) {
|
|
414
|
+
history.splice(0, history.length - this.maxHistoryEntries);
|
|
415
|
+
}
|
|
416
|
+
// Enforce global memory budget
|
|
417
|
+
this.enforceGlobalHistoryLimit();
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Enforce global history limit to prevent unbounded memory growth
|
|
421
|
+
* Uses LRU-style eviction: removes oldest entries from largest histories first
|
|
422
|
+
*/
|
|
423
|
+
enforceGlobalHistoryLimit() {
|
|
424
|
+
let totalEntries = 0;
|
|
425
|
+
for (const history of this.statusHistory.values()) {
|
|
426
|
+
totalEntries += history.length;
|
|
427
|
+
}
|
|
428
|
+
// If under limit, no action needed
|
|
429
|
+
if (totalEntries <= this.maxTotalHistoryEntries) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
// Calculate how many entries to remove (10% of excess for efficiency)
|
|
433
|
+
const entriesToRemove = Math.ceil((totalEntries - this.maxTotalHistoryEntries) * 1.1);
|
|
434
|
+
let removed = 0;
|
|
435
|
+
// Sort apps by history size (largest first) for LRU-style eviction
|
|
436
|
+
const appHistories = Array.from(this.statusHistory.entries())
|
|
437
|
+
.sort((a, b) => b[1].length - a[1].length);
|
|
438
|
+
// Remove from largest histories first
|
|
439
|
+
for (const [appId, history] of appHistories) {
|
|
440
|
+
if (removed >= entriesToRemove) {
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
// Remove oldest 10% or 1 entry, whichever is larger
|
|
444
|
+
const toRemoveFromThis = Math.max(1, Math.floor(history.length * 0.1));
|
|
445
|
+
const actualRemoved = Math.min(toRemoveFromThis, entriesToRemove - removed);
|
|
446
|
+
history.splice(0, actualRemoved);
|
|
447
|
+
removed += actualRemoved;
|
|
448
|
+
// Clean up empty histories
|
|
449
|
+
if (history.length === 0) {
|
|
450
|
+
this.statusHistory.delete(appId);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (removed > 0) {
|
|
454
|
+
console.log(`[AppMonitor] Enforced global history limit: removed ${removed} entries across ${appHistories.length} apps`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Start monitoring an app
|
|
459
|
+
*/
|
|
460
|
+
startMonitoring(appId) {
|
|
461
|
+
const app = this.apps.get(appId);
|
|
462
|
+
if (!app) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
// Clear any existing interval
|
|
466
|
+
this.stopMonitoring(appId);
|
|
467
|
+
// Set up new interval
|
|
468
|
+
const interval = setInterval(async () => {
|
|
469
|
+
try {
|
|
470
|
+
await this.checkAppHealth(appId);
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
await auditLogger.log({
|
|
474
|
+
eventType: 'system_event',
|
|
475
|
+
action: 'monitoring_check_failed',
|
|
476
|
+
outcome: 'failure',
|
|
477
|
+
details: {
|
|
478
|
+
appId,
|
|
479
|
+
appName: app.name,
|
|
480
|
+
error: error instanceof Error ? error.message : String(error)
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}, app.checkIntervalMs);
|
|
485
|
+
this.checkIntervals.set(appId, interval);
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Stop monitoring an app
|
|
490
|
+
*/
|
|
491
|
+
stopMonitoring(appId) {
|
|
492
|
+
const interval = this.checkIntervals.get(appId);
|
|
493
|
+
if (interval) {
|
|
494
|
+
clearInterval(interval);
|
|
495
|
+
this.checkIntervals.delete(appId);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Enable/disable an app
|
|
502
|
+
*/
|
|
503
|
+
setAppEnabled(appId, enabled) {
|
|
504
|
+
const app = this.apps.get(appId);
|
|
505
|
+
if (!app) {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
app.enabled = enabled;
|
|
509
|
+
app.updatedAt = new Date().toISOString();
|
|
510
|
+
if (enabled) {
|
|
511
|
+
this.startMonitoring(appId);
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
this.stopMonitoring(appId);
|
|
515
|
+
}
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Get monitoring statistics
|
|
520
|
+
*/
|
|
521
|
+
getStats() {
|
|
522
|
+
let onlineApps = 0;
|
|
523
|
+
let offlineApps = 0;
|
|
524
|
+
let degradedApps = 0;
|
|
525
|
+
let unknownApps = 0;
|
|
526
|
+
let totalResponseTime = 0;
|
|
527
|
+
let responseTimeCount = 0;
|
|
528
|
+
let lastFullCheckAt;
|
|
529
|
+
for (const [appId] of this.apps) {
|
|
530
|
+
const result = this.lastCheckResults.get(appId);
|
|
531
|
+
if (result) {
|
|
532
|
+
if (!lastFullCheckAt || result.checkedAt > lastFullCheckAt) {
|
|
533
|
+
lastFullCheckAt = result.checkedAt;
|
|
534
|
+
}
|
|
535
|
+
switch (result.status) {
|
|
536
|
+
case 'online':
|
|
537
|
+
onlineApps++;
|
|
538
|
+
break;
|
|
539
|
+
case 'offline':
|
|
540
|
+
offlineApps++;
|
|
541
|
+
break;
|
|
542
|
+
case 'degraded':
|
|
543
|
+
degradedApps++;
|
|
544
|
+
break;
|
|
545
|
+
default:
|
|
546
|
+
unknownApps++;
|
|
547
|
+
}
|
|
548
|
+
if (result.responseTimeMs !== undefined) {
|
|
549
|
+
totalResponseTime += result.responseTimeMs;
|
|
550
|
+
responseTimeCount++;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
unknownApps++;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
totalApps: this.apps.size,
|
|
559
|
+
onlineApps,
|
|
560
|
+
offlineApps,
|
|
561
|
+
degradedApps,
|
|
562
|
+
unknownApps,
|
|
563
|
+
averageResponseTimeMs: responseTimeCount > 0
|
|
564
|
+
? Math.round(totalResponseTime / responseTimeCount)
|
|
565
|
+
: undefined,
|
|
566
|
+
lastFullCheckAt
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Find apps by tag
|
|
571
|
+
*/
|
|
572
|
+
findAppsByTag(tag) {
|
|
573
|
+
return Array.from(this.apps.values()).filter(app => app.tags?.includes(tag));
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Find apps by status
|
|
577
|
+
*/
|
|
578
|
+
findAppsByStatus(status) {
|
|
579
|
+
const results = [];
|
|
580
|
+
for (const [appId, app] of this.apps) {
|
|
581
|
+
const lastResult = this.lastCheckResults.get(appId);
|
|
582
|
+
if (lastResult?.status === status) {
|
|
583
|
+
results.push(app);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return results;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Get apps that are currently offline
|
|
590
|
+
*/
|
|
591
|
+
getOfflineApps() {
|
|
592
|
+
return this.findAppsByStatus('offline');
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Get apps that are degraded
|
|
596
|
+
*/
|
|
597
|
+
getDegradedApps() {
|
|
598
|
+
return this.findAppsByStatus('degraded');
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Scan /mnt/prod for potential apps (directories with package.json or similar)
|
|
602
|
+
*/
|
|
603
|
+
async scanForApps() {
|
|
604
|
+
const results = [];
|
|
605
|
+
try {
|
|
606
|
+
const entries = await fs.promises.readdir(this.prodBasePath, { withFileTypes: true });
|
|
607
|
+
for (const entry of entries) {
|
|
608
|
+
if (entry.isDirectory()) {
|
|
609
|
+
const appPath = path.join(this.prodBasePath, entry.name);
|
|
610
|
+
const packageJsonPath = path.join(appPath, 'package.json');
|
|
611
|
+
let hasPackageJson = false;
|
|
612
|
+
let appType = 'unknown';
|
|
613
|
+
const potentialPorts = [];
|
|
614
|
+
try {
|
|
615
|
+
await fs.promises.access(packageJsonPath, fs.constants.F_OK);
|
|
616
|
+
hasPackageJson = true;
|
|
617
|
+
// Try to read package.json for port info
|
|
618
|
+
const packageJson = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf-8'));
|
|
619
|
+
// Detect app type
|
|
620
|
+
if (packageJson.dependencies?.next) {
|
|
621
|
+
appType = 'nextjs';
|
|
622
|
+
potentialPorts.push(3000);
|
|
623
|
+
}
|
|
624
|
+
else if (packageJson.dependencies?.express) {
|
|
625
|
+
appType = 'express';
|
|
626
|
+
potentialPorts.push(3000);
|
|
627
|
+
}
|
|
628
|
+
else if (packageJson.dependencies?.fastify) {
|
|
629
|
+
appType = 'fastify';
|
|
630
|
+
potentialPorts.push(3000);
|
|
631
|
+
}
|
|
632
|
+
else if (packageJson.dependencies?.['@nestjs/core']) {
|
|
633
|
+
appType = 'nestjs';
|
|
634
|
+
potentialPorts.push(3000);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
appType = 'node';
|
|
638
|
+
}
|
|
639
|
+
// Check scripts for port mentions
|
|
640
|
+
if (packageJson.scripts) {
|
|
641
|
+
const scriptStr = JSON.stringify(packageJson.scripts);
|
|
642
|
+
const portMatches = scriptStr.match(/PORT[=:]\s*(\d+)/gi);
|
|
643
|
+
if (portMatches) {
|
|
644
|
+
for (const match of portMatches) {
|
|
645
|
+
const port = parseInt(match.replace(/PORT[=:]\s*/i, ''), 10);
|
|
646
|
+
if (!potentialPorts.includes(port)) {
|
|
647
|
+
potentialPorts.push(port);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
// Distinguish ENOENT (expected) from other errors (unexpected)
|
|
655
|
+
if (err.code !== 'ENOENT') {
|
|
656
|
+
console.warn(`[AppMonitor] Unexpected error reading package.json for ${entry.name}:`, err.message);
|
|
657
|
+
}
|
|
658
|
+
// Check for other app types
|
|
659
|
+
const dockerfilePath = path.join(appPath, 'Dockerfile');
|
|
660
|
+
const composePath = path.join(appPath, 'docker-compose.yml');
|
|
661
|
+
try {
|
|
662
|
+
await fs.promises.access(dockerfilePath, fs.constants.F_OK);
|
|
663
|
+
appType = 'docker';
|
|
664
|
+
}
|
|
665
|
+
catch (dockerErr) {
|
|
666
|
+
if (dockerErr.code !== 'ENOENT') {
|
|
667
|
+
console.warn(`[AppMonitor] Unexpected error checking Dockerfile for ${entry.name}:`, dockerErr.message);
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
await fs.promises.access(composePath, fs.constants.F_OK);
|
|
671
|
+
appType = 'docker-compose';
|
|
672
|
+
}
|
|
673
|
+
catch (composeErr) {
|
|
674
|
+
if (composeErr.code !== 'ENOENT') {
|
|
675
|
+
console.warn(`[AppMonitor] Unexpected error checking docker-compose.yml for ${entry.name}:`, composeErr.message);
|
|
676
|
+
}
|
|
677
|
+
// Unknown type - this is expected for directories without recognized app files
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
results.push({
|
|
682
|
+
name: entry.name,
|
|
683
|
+
path: appPath,
|
|
684
|
+
type: appType,
|
|
685
|
+
hasPackageJson,
|
|
686
|
+
potentialPorts
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
// Directory might not exist
|
|
693
|
+
console.error(`Error scanning ${this.prodBasePath}:`, error);
|
|
694
|
+
}
|
|
695
|
+
return results;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Get logs for an app (tail of stdout/stderr or PM2 logs)
|
|
699
|
+
*/
|
|
700
|
+
async getAppLogs(appId, lines = 50) {
|
|
701
|
+
const app = this.apps.get(appId);
|
|
702
|
+
if (!app) {
|
|
703
|
+
throw new Error(`App not found: ${appId}`);
|
|
704
|
+
}
|
|
705
|
+
// Try to get logs from PM2 first
|
|
706
|
+
try {
|
|
707
|
+
const { stdout } = await execAsync(`pm2 logs ${escapeForShell(app.name)} --lines ${escapeForShell(lines)} --nostream 2>/dev/null`);
|
|
708
|
+
return { logs: stdout, source: 'pm2' };
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
// PM2 not available or app not managed by PM2
|
|
712
|
+
}
|
|
713
|
+
// Try to find log files in app directory
|
|
714
|
+
const possibleLogPaths = [
|
|
715
|
+
path.join(app.path, 'logs', 'app.log'),
|
|
716
|
+
path.join(app.path, 'logs', 'error.log'),
|
|
717
|
+
path.join(app.path, 'log', 'app.log'),
|
|
718
|
+
path.join(app.path, 'app.log'),
|
|
719
|
+
path.join(app.path, '.log'),
|
|
720
|
+
`/var/log/${app.name}.log`
|
|
721
|
+
];
|
|
722
|
+
for (const logPath of possibleLogPaths) {
|
|
723
|
+
try {
|
|
724
|
+
const { stdout } = await execAsync(`tail -n ${escapeForShell(lines)} ${escapeForShell(logPath)} 2>/dev/null`);
|
|
725
|
+
return { logs: stdout, source: logPath };
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
// Try next path
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// Try journalctl if it's a systemd service
|
|
732
|
+
try {
|
|
733
|
+
const { stdout } = await execAsync(`journalctl -u ${escapeForShell(app.name)} -n ${escapeForShell(lines)} --no-pager 2>/dev/null`);
|
|
734
|
+
if (stdout.trim()) {
|
|
735
|
+
return { logs: stdout, source: 'journalctl' };
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// Not a systemd service
|
|
740
|
+
}
|
|
741
|
+
return { logs: 'No logs found', source: 'none' };
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Cleanup - stop all monitoring intervals
|
|
745
|
+
*/
|
|
746
|
+
cleanup() {
|
|
747
|
+
for (const [appId] of this.checkIntervals) {
|
|
748
|
+
this.stopMonitoring(appId);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
// Default instance
|
|
753
|
+
export const appMonitor = new AppMonitor();
|
|
754
|
+
//# sourceMappingURL=AppMonitor.js.map
|