ezpm2gui 1.4.0 → 1.5.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/README.md +295 -295
- package/bin/ezpm2gui.js +8 -8
- package/bin/ezpm2gui.ts +51 -51
- package/bin/generate-ecosystem.js +35 -35
- package/bin/generate-ecosystem.ts +56 -56
- package/dist/index.js +1 -1
- package/dist/server/config/project-configs.json +236 -0
- package/dist/server/index.js +212 -25
- package/dist/server/routes/deployApplication.js +6 -5
- package/dist/server/routes/remoteConnections.js +260 -0
- package/dist/server/routes/updates.d.ts +3 -0
- package/dist/server/routes/updates.js +135 -0
- package/dist/server/utils/remote-connection.d.ts +18 -0
- package/dist/server/utils/remote-connection.js +216 -9
- package/package.json +73 -71
- package/scripts/postinstall.js +36 -36
- package/src/client/build/asset-manifest.json +6 -6
- package/src/client/build/favicon.ico +2 -2
- package/src/client/build/index.html +1 -1
- package/src/client/build/logo192.svg +7 -7
- package/src/client/build/logo512.svg +7 -7
- package/src/client/build/manifest.json +24 -24
- package/src/client/build/static/css/main.2d095544.css +5 -0
- package/src/client/build/static/css/main.2d095544.css.map +1 -0
- package/src/client/build/static/js/main.17e17668.js +3 -0
- package/src/client/build/static/js/main.17e17668.js.map +1 -0
- package/dist/server/config/cron-jobs.json +0 -1
- package/dist/server/config/remote-connections.json +0 -3
- package/dist/server/daemon/ezpm2gui.err.log +0 -414
- package/dist/server/daemon/ezpm2gui.exe +0 -0
- package/dist/server/daemon/ezpm2gui.exe.config +0 -6
- package/dist/server/daemon/ezpm2gui.out.log +0 -289
- package/dist/server/daemon/ezpm2gui.wrapper.log +0 -172
- package/dist/server/daemon/ezpm2gui.xml +0 -32
- package/src/client/build/static/css/main.c506cba5.css +0 -5
- package/src/client/build/static/css/main.c506cba5.css.map +0 -1
- package/src/client/build/static/js/main.5278cddd.js +0 -3
- package/src/client/build/static/js/main.5278cddd.js.map +0 -1
- /package/src/client/build/static/js/{main.5278cddd.js.LICENSE.txt → main.17e17668.js.LICENSE.txt} +0 -0
package/dist/server/index.js
CHANGED
|
@@ -4,10 +4,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.createServer = createServer;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
// @group Configuration : Load .env.local first (local overrides), then .env (defaults)
|
|
9
|
+
// Must happen before any code reads process.env
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
11
|
+
const dotenv = require('dotenv');
|
|
12
|
+
dotenv.config({ path: path_1.default.resolve(process.cwd(), '.env.local') });
|
|
13
|
+
dotenv.config({ path: path_1.default.resolve(process.cwd(), '.env') });
|
|
7
14
|
const express_1 = __importDefault(require("express"));
|
|
8
15
|
const http_1 = __importDefault(require("http"));
|
|
9
16
|
const socket_io_1 = require("socket.io");
|
|
10
|
-
const path_1 = __importDefault(require("path"));
|
|
11
17
|
const pm2_1 = __importDefault(require("pm2"));
|
|
12
18
|
const os_1 = __importDefault(require("os"));
|
|
13
19
|
const processConfig_1 = __importDefault(require("./routes/processConfig"));
|
|
@@ -15,6 +21,7 @@ const deployApplication_1 = __importDefault(require("./routes/deployApplication"
|
|
|
15
21
|
const modules_1 = __importDefault(require("./routes/modules"));
|
|
16
22
|
const remoteConnections_1 = __importDefault(require("./routes/remoteConnections"));
|
|
17
23
|
const cronJobs_1 = __importDefault(require("./routes/cronJobs"));
|
|
24
|
+
const updates_1 = __importDefault(require("./routes/updates"));
|
|
18
25
|
const logStreaming_1 = require("./routes/logStreaming");
|
|
19
26
|
const pm2_connection_1 = require("./utils/pm2-connection");
|
|
20
27
|
const remote_connection_1 = require("./utils/remote-connection");
|
|
@@ -58,6 +65,7 @@ function createServer() {
|
|
|
58
65
|
app.use('/api/modules', modules_1.default);
|
|
59
66
|
app.use('/api/remote', remoteConnections_1.default);
|
|
60
67
|
app.use('/api/cron-jobs', cronJobs_1.default);
|
|
68
|
+
app.use('/api/update', updates_1.default);
|
|
61
69
|
// Setup log streaming with Socket.IO
|
|
62
70
|
(0, logStreaming_1.setupLogStreaming)(io); // PM2 API endpoints
|
|
63
71
|
app.get('/api/processes', async (req, res) => {
|
|
@@ -127,45 +135,224 @@ function createServer() {
|
|
|
127
135
|
};
|
|
128
136
|
res.json(metrics);
|
|
129
137
|
});
|
|
130
|
-
//
|
|
138
|
+
// @group LogHistory : Resolve log path from PM2 process descriptor
|
|
139
|
+
const resolveLocalLogPath = async (id, logType) => {
|
|
140
|
+
var _a, _b, _c;
|
|
141
|
+
const processDesc = await (0, pm2_connection_1.executePM2Command)((callback) => {
|
|
142
|
+
pm2_1.default.describe(id, callback);
|
|
143
|
+
});
|
|
144
|
+
if (!processDesc || processDesc.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
return (_c = (_b = (_a = processDesc[0]) === null || _a === void 0 ? void 0 : _a.pm2_env) === null || _b === void 0 ? void 0 : _b[`pm_${logType}_log_path`]) !== null && _c !== void 0 ? _c : null;
|
|
147
|
+
};
|
|
148
|
+
// @group LogHistory : Get log lines — ?lines=N (default 200, 0 = all)
|
|
131
149
|
app.get('/api/logs/:id/:type', async (req, res) => {
|
|
132
|
-
var _a, _b;
|
|
133
150
|
const { id, type } = req.params;
|
|
134
151
|
const logType = type === 'err' ? 'err' : 'out';
|
|
152
|
+
const lines = parseInt(req.query.lines || '200', 10);
|
|
135
153
|
try {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
154
|
+
const logPath = await resolveLocalLogPath(id, logType);
|
|
155
|
+
if (!logPath) {
|
|
156
|
+
res.status(404).json({ error: 'Process not found or log path unavailable' });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const fs = require('fs');
|
|
160
|
+
if (!fs.existsSync(logPath)) {
|
|
161
|
+
res.json({ logs: [], logPath });
|
|
141
162
|
return;
|
|
142
163
|
}
|
|
143
|
-
const
|
|
164
|
+
const { lines: result, total } = await streamTailLines(fs.createReadStream(logPath), lines);
|
|
165
|
+
res.json({ logs: result, logPath, totalLines: total });
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
console.error(`Error reading log file: ${err}`);
|
|
169
|
+
res.status(500).json({ error: 'Failed to read log file' });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
// @group LogHistory : Download full log file
|
|
173
|
+
app.get('/api/logs/:id/:type/download', async (req, res) => {
|
|
174
|
+
const { id, type } = req.params;
|
|
175
|
+
const logType = type === 'err' ? 'err' : 'out';
|
|
176
|
+
try {
|
|
177
|
+
const logPath = await resolveLocalLogPath(id, logType);
|
|
144
178
|
if (!logPath) {
|
|
145
|
-
res.status(404).json({ error:
|
|
179
|
+
res.status(404).json({ error: 'Process not found or log path unavailable' });
|
|
146
180
|
return;
|
|
147
181
|
}
|
|
148
182
|
const fs = require('fs');
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const fileSize = stats.size;
|
|
153
|
-
const readSize = Math.min(fileSize, 10 * 1024); // 10KB max
|
|
154
|
-
const position = Math.max(0, fileSize - readSize);
|
|
155
|
-
const buffer = Buffer.alloc(readSize);
|
|
156
|
-
const fd = fs.openSync(logPath, 'r');
|
|
157
|
-
fs.readSync(fd, buffer, 0, readSize, position);
|
|
158
|
-
fs.closeSync(fd);
|
|
159
|
-
logContent = buffer.toString('utf8');
|
|
183
|
+
if (!fs.existsSync(logPath)) {
|
|
184
|
+
res.status(404).json({ error: 'Log file does not exist yet' });
|
|
185
|
+
return;
|
|
160
186
|
}
|
|
161
|
-
const
|
|
162
|
-
res.
|
|
187
|
+
const fileName = `${id}-${logType}.log`;
|
|
188
|
+
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
|
189
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
190
|
+
fs.createReadStream(logPath).pipe(res);
|
|
163
191
|
}
|
|
164
192
|
catch (err) {
|
|
165
|
-
console.error(`Error
|
|
193
|
+
console.error(`Error downloading log file: ${err}`);
|
|
194
|
+
res.status(500).json({ error: 'Failed to download log file' });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
// @group LogHistory : List all log files (current + rotated) for a process
|
|
198
|
+
// Uses /api/log-files/:id to avoid Express matching /:id/:type with type='files'
|
|
199
|
+
app.get('/api/log-files/:id', async (req, res) => {
|
|
200
|
+
const { id } = req.params;
|
|
201
|
+
try {
|
|
202
|
+
const fs = require('fs');
|
|
203
|
+
const nodePath = require('path');
|
|
204
|
+
const [outPath, errPath] = await Promise.all([
|
|
205
|
+
resolveLocalLogPath(id, 'out'),
|
|
206
|
+
resolveLocalLogPath(id, 'err'),
|
|
207
|
+
]);
|
|
208
|
+
if (!outPath && !errPath) {
|
|
209
|
+
res.status(404).json({ error: 'Process not found or no log paths available' });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// Derive the process base name from the log path (strip -out.log suffix)
|
|
213
|
+
const baseName = outPath
|
|
214
|
+
? nodePath.basename(outPath).replace(/-out\.log.*$/, '')
|
|
215
|
+
: nodePath.basename(errPath).replace(/-(error|err)\.log.*$/, '');
|
|
216
|
+
const logDirs = new Set();
|
|
217
|
+
if (outPath)
|
|
218
|
+
logDirs.add(nodePath.dirname(outPath));
|
|
219
|
+
if (errPath)
|
|
220
|
+
logDirs.add(nodePath.dirname(errPath));
|
|
221
|
+
const files = [];
|
|
222
|
+
for (const dir of logDirs) {
|
|
223
|
+
if (!fs.existsSync(dir))
|
|
224
|
+
continue;
|
|
225
|
+
for (const fileName of fs.readdirSync(dir)) {
|
|
226
|
+
if (!fileName.startsWith(baseName))
|
|
227
|
+
continue;
|
|
228
|
+
const filePath = nodePath.join(dir, fileName);
|
|
229
|
+
const stat = fs.statSync(filePath);
|
|
230
|
+
if (!stat.isFile())
|
|
231
|
+
continue;
|
|
232
|
+
let type = 'unknown';
|
|
233
|
+
if (fileName.includes('-out'))
|
|
234
|
+
type = 'out';
|
|
235
|
+
else if (fileName.includes('-error') || fileName.includes('-err'))
|
|
236
|
+
type = 'err';
|
|
237
|
+
files.push({
|
|
238
|
+
name: fileName,
|
|
239
|
+
path: filePath,
|
|
240
|
+
size: stat.size,
|
|
241
|
+
modified: stat.mtime.toISOString(),
|
|
242
|
+
type,
|
|
243
|
+
compressed: fileName.endsWith('.gz'),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
files.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
248
|
+
res.json({ files });
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
console.error('Error listing log files:', err);
|
|
252
|
+
res.status(500).json({ error: 'Failed to list log files' });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
// @group LogHistory : Security helper — block path traversal; allow any absolute log file path
|
|
256
|
+
const isAllowedLogPath = (filePath) => {
|
|
257
|
+
const nodePath = require('path');
|
|
258
|
+
const norm = nodePath.normalize(filePath);
|
|
259
|
+
// Require absolute path; reject shell-dangerous characters; require log extension
|
|
260
|
+
const SHELL_UNSAFE = /['"`;$|&<>(){}\\\n\r\0]/;
|
|
261
|
+
return (nodePath.isAbsolute(norm) &&
|
|
262
|
+
!norm.includes('..') &&
|
|
263
|
+
!SHELL_UNSAFE.test(norm) &&
|
|
264
|
+
/\.(log|gz)$/i.test(norm));
|
|
265
|
+
};
|
|
266
|
+
// @group LogHistory : Stream last N lines from a readable stream (ring buffer, no full-file load)
|
|
267
|
+
const streamTailLines = (inputStream, maxLines) => {
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const rl = require('readline').createInterface({ input: inputStream, crlfDelay: Infinity });
|
|
270
|
+
const buffer = [];
|
|
271
|
+
let total = 0;
|
|
272
|
+
rl.on('line', (line) => {
|
|
273
|
+
if (line.trim() === '')
|
|
274
|
+
return;
|
|
275
|
+
total++;
|
|
276
|
+
if (maxLines > 0) {
|
|
277
|
+
buffer.push(line);
|
|
278
|
+
if (buffer.length > maxLines)
|
|
279
|
+
buffer.shift();
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
buffer.push(line);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
rl.on('close', () => resolve({ lines: buffer, total }));
|
|
286
|
+
rl.on('error', reject);
|
|
287
|
+
inputStream.on('error', reject);
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
// @group LogHistory : Read a specific log file by path — ?lines=N, supports .gz
|
|
291
|
+
// Uses /api/log-file (singular, top-level) to avoid clashing with /api/logs/:id/:type
|
|
292
|
+
app.get('/api/log-file', async (req, res) => {
|
|
293
|
+
const filePath = req.query.path;
|
|
294
|
+
const lines = parseInt(req.query.lines || '200', 10);
|
|
295
|
+
if (!filePath) {
|
|
296
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!isAllowedLogPath(filePath)) {
|
|
300
|
+
res.status(403).json({ error: 'Access denied: path is outside PM2 log directories' });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const fs = require('fs');
|
|
305
|
+
const zlib = require('zlib');
|
|
306
|
+
if (!fs.existsSync(filePath)) {
|
|
307
|
+
res.status(404).json({ error: 'File not found' });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const inputStream = filePath.endsWith('.gz')
|
|
311
|
+
? fs.createReadStream(filePath).pipe(zlib.createGunzip())
|
|
312
|
+
: fs.createReadStream(filePath);
|
|
313
|
+
const { lines: result, total } = await streamTailLines(inputStream, lines);
|
|
314
|
+
res.json({ logs: result, totalLines: total });
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
console.error('Error reading log file:', err);
|
|
166
318
|
res.status(500).json({ error: 'Failed to read log file' });
|
|
167
319
|
}
|
|
168
320
|
});
|
|
321
|
+
// @group LogHistory : Download a specific log file by path (streams .gz as-is)
|
|
322
|
+
app.get('/api/log-file/download', async (req, res) => {
|
|
323
|
+
const filePath = req.query.path;
|
|
324
|
+
if (!filePath) {
|
|
325
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!isAllowedLogPath(filePath)) {
|
|
329
|
+
res.status(403).json({ error: 'Access denied' });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const fs = require('fs');
|
|
334
|
+
const zlib = require('zlib');
|
|
335
|
+
const nodePath = require('path');
|
|
336
|
+
if (!fs.existsSync(filePath)) {
|
|
337
|
+
res.status(404).json({ error: 'File not found' });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// Decompress .gz server-side so the download is always plain text
|
|
341
|
+
const baseName = nodePath.basename(filePath).replace(/\.gz$/i, '');
|
|
342
|
+
res.setHeader('Content-Disposition', `attachment; filename="${baseName}"`);
|
|
343
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
344
|
+
if (filePath.endsWith('.gz')) {
|
|
345
|
+
fs.createReadStream(filePath).pipe(zlib.createGunzip()).pipe(res);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
fs.createReadStream(filePath).pipe(res);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
console.error('Error downloading log file:', err);
|
|
353
|
+
res.status(500).json({ error: 'Failed to download file' });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
169
356
|
// WebSocket for real-time updates
|
|
170
357
|
io.on('connection', (socket) => {
|
|
171
358
|
console.log('Client connected');
|
|
@@ -219,7 +406,7 @@ function createServer() {
|
|
|
219
406
|
}
|
|
220
407
|
// Only start the server if this file is run directly
|
|
221
408
|
if (require.main === module) {
|
|
222
|
-
const PORT = process.env.PORT ||
|
|
409
|
+
const PORT = process.env.PORT || 3101;
|
|
223
410
|
const HOST = process.env.HOST || 'localhost';
|
|
224
411
|
const server = createServer();
|
|
225
412
|
server.listen(PORT, () => {
|
|
@@ -11,7 +11,7 @@ const ProjectSetupService_1 = require("../services/ProjectSetupService");
|
|
|
11
11
|
const router = (0, express_1.Router)();
|
|
12
12
|
// Deploy a new application
|
|
13
13
|
router.post('/', async (req, res) => {
|
|
14
|
-
const { name, script, cwd, instances, exec_mode, autorestart, watch, max_memory_restart, env, appType, autoSetup = true } = req.body;
|
|
14
|
+
const { name, script, cwd, namespace, instances, exec_mode, autorestart, watch, max_memory_restart, env, appType, autoSetup = true } = req.body;
|
|
15
15
|
// Validate required fields
|
|
16
16
|
if (!name || !script) {
|
|
17
17
|
return res.status(400).json({ error: 'Name and script path are required' });
|
|
@@ -67,6 +67,7 @@ router.post('/', async (req, res) => {
|
|
|
67
67
|
name,
|
|
68
68
|
script,
|
|
69
69
|
cwd: projectPath,
|
|
70
|
+
namespace: namespace || 'default',
|
|
70
71
|
instances: parseInt(instances) || 1,
|
|
71
72
|
exec_mode: exec_mode || 'fork',
|
|
72
73
|
autorestart: autorestart !== undefined ? autorestart : true,
|
|
@@ -158,8 +159,8 @@ router.post('/generate-ecosystem', (req, res) => {
|
|
|
158
159
|
env: pm2Env.env || {}
|
|
159
160
|
};
|
|
160
161
|
});
|
|
161
|
-
const ecosystemConfig = `module.exports = {
|
|
162
|
-
apps: ${JSON.stringify(apps, null, 2)}
|
|
162
|
+
const ecosystemConfig = `module.exports = {
|
|
163
|
+
apps: ${JSON.stringify(apps, null, 2)}
|
|
163
164
|
};`;
|
|
164
165
|
// Create the file (either at specified path or default location)
|
|
165
166
|
const filePath = req.body.path || path_1.default.join(process.cwd(), 'ecosystem.config.js');
|
|
@@ -212,8 +213,8 @@ router.get('/generate-ecosystem-preview', (req, res) => {
|
|
|
212
213
|
env: pm2Env.env || {}
|
|
213
214
|
};
|
|
214
215
|
});
|
|
215
|
-
const ecosystemConfig = `module.exports = {
|
|
216
|
-
apps: ${JSON.stringify(apps, null, 2)}
|
|
216
|
+
const ecosystemConfig = `module.exports = {
|
|
217
|
+
apps: ${JSON.stringify(apps, null, 2)}
|
|
217
218
|
};`;
|
|
218
219
|
pm2_1.default.disconnect();
|
|
219
220
|
res.json({
|
|
@@ -6,6 +6,26 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const express_1 = __importDefault(require("express"));
|
|
7
7
|
const remote_connection_1 = require("../utils/remote-connection");
|
|
8
8
|
const router = express_1.default.Router();
|
|
9
|
+
// @group Security : Validate remote log file paths before shell interpolation
|
|
10
|
+
const SHELL_UNSAFE_CHARS = /['"`;$|&<>(){}\\\n\r\0]/;
|
|
11
|
+
const MAX_LOG_LINES = 10000;
|
|
12
|
+
const validateRemotePath = (filePath) => {
|
|
13
|
+
if (!filePath)
|
|
14
|
+
return false;
|
|
15
|
+
if (filePath.includes('..'))
|
|
16
|
+
return false;
|
|
17
|
+
if (SHELL_UNSAFE_CHARS.test(filePath))
|
|
18
|
+
return false;
|
|
19
|
+
if (!/\.(log|gz)$/i.test(filePath))
|
|
20
|
+
return false;
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
const safeLogLines = (raw) => {
|
|
24
|
+
const n = parseInt(raw || '200', 10);
|
|
25
|
+
if (!Number.isFinite(n) || n < 0)
|
|
26
|
+
return 200;
|
|
27
|
+
return Math.min(n, MAX_LOG_LINES);
|
|
28
|
+
};
|
|
9
29
|
/**
|
|
10
30
|
* Connect to an existing remote server
|
|
11
31
|
* POST /api/remote/:connectionId/connect
|
|
@@ -552,6 +572,246 @@ router.get('/:connectionId/logs/:processId', async (req, res) => {
|
|
|
552
572
|
});
|
|
553
573
|
}
|
|
554
574
|
});
|
|
575
|
+
// @group LogHistory : Helper — resolve log path for a given process on a remote connection
|
|
576
|
+
const resolveRemoteLogPath = async (connection, processId, logType) => {
|
|
577
|
+
var _a, _b;
|
|
578
|
+
const processInfoResult = await connection.executePM2Command('jlist');
|
|
579
|
+
if (processInfoResult.code !== 0)
|
|
580
|
+
return { logPath: null, error: 'Failed to get PM2 process list' };
|
|
581
|
+
try {
|
|
582
|
+
let raw = processInfoResult.stdout.trim();
|
|
583
|
+
const start = raw.indexOf('[');
|
|
584
|
+
const end = raw.lastIndexOf(']') + 1;
|
|
585
|
+
if (start !== -1 && end > 0)
|
|
586
|
+
raw = raw.substring(start, end);
|
|
587
|
+
const processList = JSON.parse(raw);
|
|
588
|
+
const proc = processList.find((p) => p.pm_id === parseInt(processId, 10) || p.name === processId);
|
|
589
|
+
if (!proc)
|
|
590
|
+
return { logPath: null, error: 'Process not found' };
|
|
591
|
+
const key = logType === 'out' ? 'pm_out_log_path' : 'pm_err_log_path';
|
|
592
|
+
return { logPath: (_b = (_a = proc.pm2_env) === null || _a === void 0 ? void 0 : _a[key]) !== null && _b !== void 0 ? _b : null };
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
return { logPath: null, error: 'Failed to parse PM2 process list' };
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
/**
|
|
599
|
+
* Get log lines from a specific log type on a remote process — ?lines=N (default 200, 0 = all)
|
|
600
|
+
* GET /api/remote/:connectionId/logs/:processId/:type
|
|
601
|
+
*/
|
|
602
|
+
router.get('/:connectionId/logs/:processId/:type', async (req, res) => {
|
|
603
|
+
var _a;
|
|
604
|
+
try {
|
|
605
|
+
const { connectionId, processId, type } = req.params;
|
|
606
|
+
const logType = type === 'err' ? 'err' : 'out';
|
|
607
|
+
const lines = safeLogLines(req.query.lines);
|
|
608
|
+
const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
|
|
609
|
+
if (!connection)
|
|
610
|
+
return res.status(404).json({ success: false, error: 'Connection not found' });
|
|
611
|
+
if (!connection.isConnected())
|
|
612
|
+
return res.status(400).json({ success: false, error: 'Not connected' });
|
|
613
|
+
const { logPath, error } = await resolveRemoteLogPath(connection, processId, logType);
|
|
614
|
+
if (!logPath)
|
|
615
|
+
return res.status(404).json({ success: false, error: error || 'Log path not found' });
|
|
616
|
+
// tail -n 0 = all lines; use wc -l to get total count alongside
|
|
617
|
+
const lineArg = lines === 0 ? '+1' : `-${lines}`;
|
|
618
|
+
const cmd = `{ wc -l < "${logPath}" 2>/dev/null || echo 0; } && tail -n ${lineArg} "${logPath}" 2>/dev/null`;
|
|
619
|
+
let result = await connection.executeCommand(cmd);
|
|
620
|
+
// Fallback to sudo if the file is unreadable (root-owned logs)
|
|
621
|
+
if (result.code !== 0 || !result.stdout.trim()) {
|
|
622
|
+
result = await connection.executeCommand(`{ sudo wc -l < "${logPath}" 2>/dev/null || echo 0; } && sudo tail -n ${lineArg} "${logPath}" 2>/dev/null`);
|
|
623
|
+
}
|
|
624
|
+
const outputLines = result.stdout.split('\n');
|
|
625
|
+
const totalLines = parseInt(((_a = outputLines[0]) === null || _a === void 0 ? void 0 : _a.trim()) || '0', 10);
|
|
626
|
+
const logLines = outputLines.slice(1).filter((l) => l.trim() !== '');
|
|
627
|
+
res.json({ logs: logLines, logPath, totalLines });
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
console.error('Error fetching remote log history:', error);
|
|
631
|
+
res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
/**
|
|
635
|
+
* Download full log file from a remote process via SSH
|
|
636
|
+
* GET /api/remote/:connectionId/logs/:processId/:type/download
|
|
637
|
+
*/
|
|
638
|
+
router.get('/:connectionId/logs/:processId/:type/download', async (req, res) => {
|
|
639
|
+
try {
|
|
640
|
+
const { connectionId, processId, type } = req.params;
|
|
641
|
+
const logType = type === 'err' ? 'err' : 'out';
|
|
642
|
+
const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
|
|
643
|
+
if (!connection)
|
|
644
|
+
return res.status(404).json({ success: false, error: 'Connection not found' });
|
|
645
|
+
if (!connection.isConnected())
|
|
646
|
+
return res.status(400).json({ success: false, error: 'Not connected' });
|
|
647
|
+
const { logPath, error } = await resolveRemoteLogPath(connection, processId, logType);
|
|
648
|
+
if (!logPath)
|
|
649
|
+
return res.status(404).json({ success: false, error: error || 'Log path not found' });
|
|
650
|
+
const fileName = `${processId}-${logType}.log`;
|
|
651
|
+
await connection.streamFileToResponse(logPath, res, fileName);
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
console.error('Error downloading remote log:', error);
|
|
655
|
+
if (!res.headersSent) {
|
|
656
|
+
res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
/**
|
|
661
|
+
* List all log files (current + rotated) for a remote process
|
|
662
|
+
* GET /api/remote/:connectionId/log-files/:processId
|
|
663
|
+
* Uses /log-files/ prefix to avoid Express matching /:processId/:type with type='files'
|
|
664
|
+
*/
|
|
665
|
+
router.get('/:connectionId/log-files/:processId', async (req, res) => {
|
|
666
|
+
try {
|
|
667
|
+
const { connectionId, processId } = req.params;
|
|
668
|
+
const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
|
|
669
|
+
if (!connection)
|
|
670
|
+
return res.status(404).json({ success: false, error: 'Connection not found' });
|
|
671
|
+
if (!connection.isConnected())
|
|
672
|
+
return res.status(400).json({ success: false, error: 'Not connected' });
|
|
673
|
+
// Resolve both log paths so we know the directory and base name
|
|
674
|
+
const { logPath: outPath } = await resolveRemoteLogPath(connection, processId, 'out');
|
|
675
|
+
const { logPath: errPath } = await resolveRemoteLogPath(connection, processId, 'err');
|
|
676
|
+
if (!outPath && !errPath) {
|
|
677
|
+
return res.status(404).json({ success: false, error: 'No log paths found for this process' });
|
|
678
|
+
}
|
|
679
|
+
// Derive log directory + base name from the out log path (or err if out missing)
|
|
680
|
+
const refPath = outPath || errPath;
|
|
681
|
+
const logDir = refPath.substring(0, refPath.lastIndexOf('/'));
|
|
682
|
+
const baseName = refPath.split('/').pop().replace(/-out\.log.*$/, '').replace(/-(error|err)\.log.*$/, '');
|
|
683
|
+
// List matching files in the log directory
|
|
684
|
+
const lsCmd = `ls -la "${logDir}" 2>/dev/null`;
|
|
685
|
+
let lsResult = await connection.executeCommand(lsCmd);
|
|
686
|
+
if (lsResult.code !== 0)
|
|
687
|
+
lsResult = await connection.executeCommand(`sudo ${lsCmd}`);
|
|
688
|
+
const files = [];
|
|
689
|
+
if (lsResult.code === 0) {
|
|
690
|
+
for (const line of lsResult.stdout.split('\n')) {
|
|
691
|
+
// ls -la line: permissions links owner group size month day time name
|
|
692
|
+
const parts = line.trim().split(/\s+/);
|
|
693
|
+
if (parts.length < 9)
|
|
694
|
+
continue;
|
|
695
|
+
const fileName = parts.slice(8).join(' ');
|
|
696
|
+
if (!fileName.startsWith(baseName))
|
|
697
|
+
continue;
|
|
698
|
+
const size = parseInt(parts[4], 10) || 0;
|
|
699
|
+
const month = parts[5];
|
|
700
|
+
const day = parts[6];
|
|
701
|
+
const timeOrYr = parts[7];
|
|
702
|
+
const modified = `${month} ${day} ${timeOrYr}`;
|
|
703
|
+
let type = 'unknown';
|
|
704
|
+
if (fileName.includes('-out'))
|
|
705
|
+
type = 'out';
|
|
706
|
+
else if (fileName.includes('-error') || fileName.includes('-err'))
|
|
707
|
+
type = 'err';
|
|
708
|
+
files.push({
|
|
709
|
+
name: fileName,
|
|
710
|
+
path: `${logDir}/${fileName}`,
|
|
711
|
+
size,
|
|
712
|
+
modified,
|
|
713
|
+
type,
|
|
714
|
+
compressed: fileName.endsWith('.gz'),
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Sort: current files first (no date suffix), then rotated newest first
|
|
719
|
+
files.sort((a, b) => {
|
|
720
|
+
const aRot = a.name.includes('__') || /\d{4}-\d{2}/.test(a.name);
|
|
721
|
+
const bRot = b.name.includes('__') || /\d{4}-\d{2}/.test(b.name);
|
|
722
|
+
if (!aRot && bRot)
|
|
723
|
+
return -1;
|
|
724
|
+
if (aRot && !bRot)
|
|
725
|
+
return 1;
|
|
726
|
+
return b.name.localeCompare(a.name);
|
|
727
|
+
});
|
|
728
|
+
res.json({ files });
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
console.error('Error listing remote log files:', error);
|
|
732
|
+
res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
/**
|
|
736
|
+
* Read a specific remote log file by path — ?lines=N, handles .gz via zcat
|
|
737
|
+
* GET /api/remote/:connectionId/log-file?path=...&lines=N
|
|
738
|
+
* Uses /log-file top-level to avoid clashing with /logs/:processId routes
|
|
739
|
+
*/
|
|
740
|
+
router.get('/:connectionId/log-file', async (req, res) => {
|
|
741
|
+
var _a;
|
|
742
|
+
try {
|
|
743
|
+
const { connectionId } = req.params;
|
|
744
|
+
const filePath = req.query.path;
|
|
745
|
+
const lines = safeLogLines(req.query.lines);
|
|
746
|
+
if (!filePath)
|
|
747
|
+
return res.status(400).json({ error: 'path query parameter required' });
|
|
748
|
+
// Strict path validation — blocks traversal, shell metacharacters, and non-log extensions
|
|
749
|
+
if (!validateRemotePath(filePath)) {
|
|
750
|
+
return res.status(403).json({ error: 'Access denied: invalid or unsafe log file path' });
|
|
751
|
+
}
|
|
752
|
+
const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
|
|
753
|
+
if (!connection)
|
|
754
|
+
return res.status(404).json({ success: false, error: 'Connection not found' });
|
|
755
|
+
if (!connection.isConnected())
|
|
756
|
+
return res.status(400).json({ success: false, error: 'Not connected' });
|
|
757
|
+
const isGz = filePath.endsWith('.gz');
|
|
758
|
+
const catCmd = isGz ? `zcat "${filePath}" 2>/dev/null` : `cat "${filePath}" 2>/dev/null`;
|
|
759
|
+
const lineArg = lines === 0 ? '' : `| tail -n ${lines}`;
|
|
760
|
+
const countCmd = isGz
|
|
761
|
+
? `zcat "${filePath}" 2>/dev/null | wc -l`
|
|
762
|
+
: `wc -l < "${filePath}" 2>/dev/null`;
|
|
763
|
+
const [contentResult, countResult] = await Promise.all([
|
|
764
|
+
connection.executeCommand(`${catCmd} ${lineArg}`).catch(() => ({ code: 1, stdout: '', stderr: '' })),
|
|
765
|
+
connection.executeCommand(countCmd).catch(() => ({ code: 1, stdout: '0', stderr: '' })),
|
|
766
|
+
]);
|
|
767
|
+
// Fallback to sudo on permission error
|
|
768
|
+
const content = contentResult.code === 0 && contentResult.stdout.trim()
|
|
769
|
+
? contentResult
|
|
770
|
+
: await connection.executeCommand(`sudo ${catCmd} ${lineArg}`);
|
|
771
|
+
const totalLines = parseInt(((_a = countResult.stdout) === null || _a === void 0 ? void 0 : _a.trim()) || '0', 10);
|
|
772
|
+
const logLines = (content.stdout || '').split('\n').filter((l) => l.trim() !== '');
|
|
773
|
+
res.json({ logs: logLines, totalLines });
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
console.error('Error reading remote log file:', error);
|
|
777
|
+
res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
/**
|
|
781
|
+
* Download a specific remote log file by path via SSH cat/zcat
|
|
782
|
+
* GET /api/remote/:connectionId/log-file/download?path=...
|
|
783
|
+
*/
|
|
784
|
+
router.get('/:connectionId/log-file/download', async (req, res) => {
|
|
785
|
+
try {
|
|
786
|
+
const { connectionId } = req.params;
|
|
787
|
+
const filePath = req.query.path;
|
|
788
|
+
if (!filePath)
|
|
789
|
+
return res.status(400).json({ error: 'path query parameter required' });
|
|
790
|
+
if (!validateRemotePath(filePath)) {
|
|
791
|
+
return res.status(403).json({ error: 'Access denied: invalid or unsafe log file path' });
|
|
792
|
+
}
|
|
793
|
+
const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
|
|
794
|
+
if (!connection)
|
|
795
|
+
return res.status(404).json({ success: false, error: 'Connection not found' });
|
|
796
|
+
if (!connection.isConnected())
|
|
797
|
+
return res.status(400).json({ success: false, error: 'Not connected' });
|
|
798
|
+
// .gz files: stream via SFTP + local gunzip (no server-side memory buffering)
|
|
799
|
+
if (filePath.endsWith('.gz')) {
|
|
800
|
+
const gzName = filePath.split('/').pop().replace(/\.gz$/, '');
|
|
801
|
+
await connection.streamGzFileToResponse(filePath, res, gzName);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
// Plain files: SFTP stream
|
|
805
|
+
const fileName = filePath.split('/').pop();
|
|
806
|
+
await connection.streamFileToResponse(filePath, res, fileName);
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
console.error('Error downloading remote log file:', error);
|
|
810
|
+
if (!res.headersSent) {
|
|
811
|
+
res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
});
|
|
555
815
|
/**
|
|
556
816
|
* Get list of all remote connections with their status
|
|
557
817
|
* GET /api/remote/connections
|