@bingzi-233/ssh-mcp 1.4.0 → 1.5.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/README.md +13 -1
- package/dist/index.js +827 -2
- package/dist/ops.js +470 -0
- package/package.json +1 -1
package/dist/ops.js
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { createWriteStream, createReadStream, statSync } from "node:fs";
|
|
2
|
+
import { createConnection, runCommand } from "./ssh.js";
|
|
3
|
+
// reopen: helper because openSftp is not exported from transfer.ts, but we need it here
|
|
4
|
+
function sftpOpen(conn) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
conn.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)));
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
const HEALTH_CMD = [
|
|
10
|
+
'echo "HOSTNAME:$(hostname)"',
|
|
11
|
+
'echo "OS_START"',
|
|
12
|
+
'cat /etc/os-release 2>/dev/null | head -2 || echo "N/A"',
|
|
13
|
+
'echo "OS_END"',
|
|
14
|
+
'echo "UPTIME:$(uptime -p 2>/dev/null || cat /proc/uptime)"',
|
|
15
|
+
'echo "LOAD:$(cat /proc/loadavg)"',
|
|
16
|
+
'echo "MEM:$(free -h 2>/dev/null || head -5 /proc/meminfo)"',
|
|
17
|
+
'echo "DISK_START"',
|
|
18
|
+
'df -h / /tmp /var 2>/dev/null || df -h /',
|
|
19
|
+
'echo "DISK_END"',
|
|
20
|
+
'echo "CPU:$(nproc) cores"',
|
|
21
|
+
].join("; ");
|
|
22
|
+
export async function getHealth(cfg, timeoutMs = 30_000) {
|
|
23
|
+
const r = await runCommand(cfg, HEALTH_CMD, timeoutMs);
|
|
24
|
+
const out = (r.stdout || "") + (r.stderr || "");
|
|
25
|
+
const extract = (label) => {
|
|
26
|
+
const m = out.match(new RegExp(`${label}:(.+?)(?:\\n|$)`));
|
|
27
|
+
return m ? m[1].trim() : "—";
|
|
28
|
+
};
|
|
29
|
+
const section = (start, end) => {
|
|
30
|
+
const i = out.indexOf(start);
|
|
31
|
+
const j = out.indexOf(end, i);
|
|
32
|
+
if (i === -1)
|
|
33
|
+
return "—";
|
|
34
|
+
return out.slice(i + start.length, j === -1 ? undefined : j).trim();
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
server: cfg.name,
|
|
38
|
+
hostname: extract("HOSTNAME"),
|
|
39
|
+
os: section("OS_START", "OS_END"),
|
|
40
|
+
uptime: extract("UPTIME"),
|
|
41
|
+
load: extract("LOAD"),
|
|
42
|
+
memory: extract("MEM"),
|
|
43
|
+
disk: section("DISK_START", "DISK_END"),
|
|
44
|
+
cpuCores: extract("CPU"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export async function getCertInfo(cfg, host, port = 443, timeoutMs = 15_000) {
|
|
48
|
+
const cmd = `echo | openssl s_client -connect '${host}:${port}' -servername '${host}' 2>/dev/null | openssl x509 -noout -subject -issuer -dates -fingerprint -ext subjectAltName`;
|
|
49
|
+
const r = await runCommand(cfg, cmd, timeoutMs);
|
|
50
|
+
const out = (r.stdout || "") + (r.stderr || "");
|
|
51
|
+
const extract = (label) => {
|
|
52
|
+
const m = out.match(new RegExp(`^${label}\\s*=\\s*(.+)$`, "m"));
|
|
53
|
+
return m ? m[1].trim() : "—";
|
|
54
|
+
};
|
|
55
|
+
const sans = [];
|
|
56
|
+
const sanRe = /DNS:([^\s,]+)/g;
|
|
57
|
+
let m;
|
|
58
|
+
while ((m = sanRe.exec(out)))
|
|
59
|
+
sans.push(m[1]);
|
|
60
|
+
const notAfter = extract("notAfter");
|
|
61
|
+
let remainingDays = -1;
|
|
62
|
+
try {
|
|
63
|
+
remainingDays = Math.ceil((Date.parse(notAfter) - Date.now()) / 86_400_000);
|
|
64
|
+
}
|
|
65
|
+
catch { /* */ }
|
|
66
|
+
return {
|
|
67
|
+
subject: extract("subject"),
|
|
68
|
+
issuer: extract("issuer"),
|
|
69
|
+
notBefore: extract("notBefore"),
|
|
70
|
+
notAfter,
|
|
71
|
+
sans,
|
|
72
|
+
fingerprint: extract("fingerprint") || extract("SHA256 Fingerprint") || extract("SHA1 Fingerprint"),
|
|
73
|
+
remainingDays,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export async function copyBetween(srcCfg, destCfg, sourcePath, destPath) {
|
|
77
|
+
const srcConn = await createConnection(srcCfg, 20_000);
|
|
78
|
+
const srcSftp = await sftpOpen(srcConn);
|
|
79
|
+
// stat the source file
|
|
80
|
+
const srcStat = await new Promise((resolve, reject) => {
|
|
81
|
+
srcSftp.stat(sourcePath, (err, s) => (err ? reject(err) : resolve(s)));
|
|
82
|
+
});
|
|
83
|
+
const destConn = await createConnection(destCfg, 20_000);
|
|
84
|
+
const destSftp = await sftpOpen(destConn);
|
|
85
|
+
const startedAt = Date.now();
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
const readStream = srcSftp.createReadStream(sourcePath);
|
|
88
|
+
const writeStream = destSftp.createWriteStream(destPath);
|
|
89
|
+
readStream.on("error", (err) => {
|
|
90
|
+
destConn.end();
|
|
91
|
+
srcConn.end();
|
|
92
|
+
reject(err);
|
|
93
|
+
});
|
|
94
|
+
writeStream.on("error", (err) => {
|
|
95
|
+
destConn.end();
|
|
96
|
+
srcConn.end();
|
|
97
|
+
reject(err);
|
|
98
|
+
});
|
|
99
|
+
writeStream.on("close", () => {
|
|
100
|
+
destConn.end();
|
|
101
|
+
srcConn.end();
|
|
102
|
+
resolve();
|
|
103
|
+
});
|
|
104
|
+
readStream.pipe(writeStream);
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
sourceServer: srcCfg.name,
|
|
108
|
+
destServer: destCfg.name,
|
|
109
|
+
sourcePath,
|
|
110
|
+
destPath,
|
|
111
|
+
size: srcStat.size,
|
|
112
|
+
elapsedMs: Date.now() - startedAt,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export async function diffServers(cfgA, cfgB, path) {
|
|
116
|
+
const read = async (cfg) => {
|
|
117
|
+
const conn = await createConnection(cfg, 20_000);
|
|
118
|
+
const sftp = await sftpOpen(conn);
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
let buf = "";
|
|
121
|
+
const stream = sftp.createReadStream(path, { autoClose: true });
|
|
122
|
+
stream.on("data", (d) => (buf += d.toString("utf8")));
|
|
123
|
+
stream.on("error", reject);
|
|
124
|
+
stream.on("end", () => {
|
|
125
|
+
conn.end();
|
|
126
|
+
resolve(buf);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
const [a, b] = await Promise.all([read(cfgA), read(cfgB)]);
|
|
131
|
+
const linesA = a.split("\n");
|
|
132
|
+
const linesB = b.split("\n");
|
|
133
|
+
// simple unified diff
|
|
134
|
+
const diffLines = [];
|
|
135
|
+
const maxLen = Math.max(linesA.length, linesB.length);
|
|
136
|
+
let added = 0;
|
|
137
|
+
let removed = 0;
|
|
138
|
+
// Very simple line-by-line comparison with context
|
|
139
|
+
let i = 0, j = 0;
|
|
140
|
+
while (i < linesA.length || j < linesB.length) {
|
|
141
|
+
if (i < linesA.length && j < linesB.length && linesA[i] === linesB[j]) {
|
|
142
|
+
diffLines.push(` ${linesA[i]}`);
|
|
143
|
+
i++;
|
|
144
|
+
j++;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// look ahead for sync point
|
|
148
|
+
let syncI = -1, syncJ = -1;
|
|
149
|
+
const lookahead = 10;
|
|
150
|
+
for (let di = 0; di <= lookahead && i + di < linesA.length; di++) {
|
|
151
|
+
for (let dj = 0; dj <= lookahead && j + dj < linesB.length; dj++) {
|
|
152
|
+
if (di === 0 && dj === 0)
|
|
153
|
+
continue;
|
|
154
|
+
if (linesA[i + di] === linesB[j + dj]) {
|
|
155
|
+
if (syncI === -1 || di + dj < syncI + syncJ) {
|
|
156
|
+
syncI = di;
|
|
157
|
+
syncJ = dj;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (syncI >= 0) {
|
|
163
|
+
// show removed lines from A
|
|
164
|
+
for (let di = 0; di < syncI; di++) {
|
|
165
|
+
diffLines.push(`- ${linesA[i + di]}`);
|
|
166
|
+
removed++;
|
|
167
|
+
}
|
|
168
|
+
// show added lines from B
|
|
169
|
+
for (let dj = 0; dj < syncJ; dj++) {
|
|
170
|
+
diffLines.push(`+ ${linesB[j + dj]}`);
|
|
171
|
+
added++;
|
|
172
|
+
}
|
|
173
|
+
i += syncI;
|
|
174
|
+
j += syncJ;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// no sync found, dump remaining
|
|
178
|
+
while (i < linesA.length) {
|
|
179
|
+
diffLines.push(`- ${linesA[i++]}`);
|
|
180
|
+
removed++;
|
|
181
|
+
}
|
|
182
|
+
while (j < linesB.length) {
|
|
183
|
+
diffLines.push(`+ ${linesB[j++]}`);
|
|
184
|
+
added++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
path,
|
|
191
|
+
serverA: cfgA.name,
|
|
192
|
+
serverB: cfgB.name,
|
|
193
|
+
identical: added === 0 && removed === 0,
|
|
194
|
+
added,
|
|
195
|
+
removed,
|
|
196
|
+
diff: diffLines.join("\n"),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export async function execScript(cfg, localScriptPath, remotePath, timeoutMs = 120_000) {
|
|
200
|
+
const conn = await createConnection(cfg, 20_000);
|
|
201
|
+
const sftp = await sftpOpen(conn);
|
|
202
|
+
// upload the script
|
|
203
|
+
await new Promise((resolve, reject) => {
|
|
204
|
+
const read = createReadStream(localScriptPath);
|
|
205
|
+
const write = sftp.createWriteStream(remotePath, { mode: 0o755 });
|
|
206
|
+
read.on("error", reject);
|
|
207
|
+
write.on("error", reject);
|
|
208
|
+
write.on("close", resolve);
|
|
209
|
+
read.pipe(write);
|
|
210
|
+
});
|
|
211
|
+
// run it
|
|
212
|
+
const result = await runCommand(cfg, `chmod +x '${remotePath}' && '${remotePath}'; EC=$?; rm -f '${remotePath}'; exit $EC`, timeoutMs, undefined);
|
|
213
|
+
conn.end();
|
|
214
|
+
return {
|
|
215
|
+
server: cfg.name,
|
|
216
|
+
exitCode: result.code,
|
|
217
|
+
stdout: result.stdout,
|
|
218
|
+
stderr: result.stderr,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export async function snapshot(cfg, remoteDir, localFile, excludes = []) {
|
|
222
|
+
const startedAt = Date.now();
|
|
223
|
+
const excludeArgs = excludes.map((e) => `--exclude='${e}'`).join(" ");
|
|
224
|
+
const cmd = `cd '${remoteDir}' && tar czf - ${excludeArgs} .`;
|
|
225
|
+
const conn = await createConnection(cfg, 20_000);
|
|
226
|
+
await new Promise((resolve, reject) => {
|
|
227
|
+
conn.exec(cmd, (err, stream) => {
|
|
228
|
+
if (err) {
|
|
229
|
+
conn.end();
|
|
230
|
+
return reject(err);
|
|
231
|
+
}
|
|
232
|
+
const write = createWriteStream(localFile);
|
|
233
|
+
stream.on("error", (e) => { conn.end(); reject(e); });
|
|
234
|
+
write.on("error", (e) => { conn.end(); reject(e); });
|
|
235
|
+
stream.on("close", (code) => {
|
|
236
|
+
conn.end();
|
|
237
|
+
if (code !== null && code !== 0) {
|
|
238
|
+
return reject(new Error(`tar 退出码: ${code}`));
|
|
239
|
+
}
|
|
240
|
+
resolve();
|
|
241
|
+
});
|
|
242
|
+
stream.pipe(write);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
const fileSize = statSync(localFile).size;
|
|
246
|
+
return {
|
|
247
|
+
server: cfg.name,
|
|
248
|
+
remotePath: remoteDir,
|
|
249
|
+
localFile,
|
|
250
|
+
fileSize,
|
|
251
|
+
elapsedMs: Date.now() - startedAt,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const tails = new Map();
|
|
255
|
+
let tailCounter = 0;
|
|
256
|
+
export async function startTailFollow(cfg, path, intervalMs = 2000, onData) {
|
|
257
|
+
const conn = await createConnection(cfg, 20_000);
|
|
258
|
+
const sftp = await sftpOpen(conn);
|
|
259
|
+
const currentSize = await new Promise((resolve, reject) => {
|
|
260
|
+
sftp.stat(path, (err, s) => (err ? reject(err) : resolve(s)));
|
|
261
|
+
});
|
|
262
|
+
const id = `tail${++tailCounter}`;
|
|
263
|
+
let offset = currentSize.size;
|
|
264
|
+
const tf = {
|
|
265
|
+
id,
|
|
266
|
+
server: cfg.name,
|
|
267
|
+
path,
|
|
268
|
+
state: "following",
|
|
269
|
+
seenBytes: 0,
|
|
270
|
+
createdAt: Date.now(),
|
|
271
|
+
};
|
|
272
|
+
const timer = setInterval(() => {
|
|
273
|
+
if (tf.state === "stopped") {
|
|
274
|
+
clearInterval(timer);
|
|
275
|
+
conn.end();
|
|
276
|
+
tails.delete(id);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
sftp.stat(path, (err, s) => {
|
|
280
|
+
if (err)
|
|
281
|
+
return;
|
|
282
|
+
if (s.size > offset) {
|
|
283
|
+
const read = sftp.createReadStream(path, { start: offset, end: s.size - 1 });
|
|
284
|
+
let chunk = "";
|
|
285
|
+
read.on("data", (d) => (chunk += d.toString("utf8")));
|
|
286
|
+
read.on("end", () => {
|
|
287
|
+
tf.seenBytes += chunk.length;
|
|
288
|
+
offset = s.size;
|
|
289
|
+
onData(id, chunk);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
else if (s.size < offset) {
|
|
293
|
+
// file truncated
|
|
294
|
+
offset = 0;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}, intervalMs);
|
|
298
|
+
tails.set(id, { ...tf, timer });
|
|
299
|
+
conn.on("close", () => { tf.state = "stopped"; clearInterval(timer); tails.delete(id); });
|
|
300
|
+
conn.on("error", () => { tf.state = "stopped"; clearInterval(timer); tails.delete(id); });
|
|
301
|
+
return tf;
|
|
302
|
+
}
|
|
303
|
+
export function stopTailFollow(id) {
|
|
304
|
+
const t = tails.get(id);
|
|
305
|
+
if (!t)
|
|
306
|
+
return false;
|
|
307
|
+
t.state = "stopped";
|
|
308
|
+
clearInterval(t.timer);
|
|
309
|
+
tails.delete(id);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
export function getTailFollow(id) {
|
|
313
|
+
const t = tails.get(id);
|
|
314
|
+
if (!t)
|
|
315
|
+
return undefined;
|
|
316
|
+
return { id: t.id, server: t.server, path: t.path, state: t.state, seenBytes: t.seenBytes, createdAt: t.createdAt };
|
|
317
|
+
}
|
|
318
|
+
export function listTailFollows() {
|
|
319
|
+
return [...tails.values()].map((t) => ({
|
|
320
|
+
id: t.id, server: t.server, path: t.path, state: t.state, seenBytes: t.seenBytes, createdAt: t.createdAt,
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
export async function httpRequest(cfg, url, method = "GET", headers = {}, body, timeoutMs = 30_000) {
|
|
324
|
+
const headerArgs = Object.entries(headers)
|
|
325
|
+
.map(([k, v]) => `-H '${k}: ${v.replace(/'/g, "'\\''")}'`)
|
|
326
|
+
.join(" ");
|
|
327
|
+
const dataArg = body ? `--data '${body.replace(/'/g, "'\\''")}'` : "";
|
|
328
|
+
const urlEscaped = url.replace(/'/g, "'\\''");
|
|
329
|
+
const cmd = `curl -sS -w '\\n<<HTTP_CODE>>%{http_code}<<TIME>>%{time_total}' -X ${method} ${headerArgs} ${dataArg} '${urlEscaped}'`;
|
|
330
|
+
const r = await runCommand(cfg, cmd, timeoutMs);
|
|
331
|
+
const out = (r.stdout || "") + (r.stderr || "");
|
|
332
|
+
let httpCode = "—";
|
|
333
|
+
let duration = "—";
|
|
334
|
+
const codeM = out.match(/<<HTTP_CODE>>(\d+)/);
|
|
335
|
+
const timeM = out.match(/<<TIME>>([\d.]+)/);
|
|
336
|
+
if (codeM)
|
|
337
|
+
httpCode = codeM[1];
|
|
338
|
+
if (timeM)
|
|
339
|
+
duration = `${timeM[1]}s`;
|
|
340
|
+
const bodyContent = out.replace(/<<HTTP_CODE>>\d+.*$/, "").trim();
|
|
341
|
+
return {
|
|
342
|
+
server: cfg.name,
|
|
343
|
+
exitCode: r.code,
|
|
344
|
+
httpCode,
|
|
345
|
+
body: bodyContent,
|
|
346
|
+
headers: "",
|
|
347
|
+
duration,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
export async function getRemoteEnv(cfg, processName, timeoutMs = 20_000) {
|
|
351
|
+
const cmds = [
|
|
352
|
+
"echo '<<ENV>>'" + "&& env | head -60",
|
|
353
|
+
"echo '<<USERS>>'" + "&& who 2>/dev/null || echo 'N/A'",
|
|
354
|
+
"echo '<<OPENFILES>>'" + "&& lsof -u $(whoami) 2>/dev/null | tail -20 || echo 'N/A'",
|
|
355
|
+
"echo '<<NETWORK>>'" + "&& ss -tlnp 2>/dev/null | head -20 || netstat -tlnp 2>/dev/null | head -20 || echo 'N/A'",
|
|
356
|
+
];
|
|
357
|
+
if (processName) {
|
|
358
|
+
cmds.push(`echo '<<PROC>>' && ps aux | grep '${processName}' | grep -v grep | head -5`);
|
|
359
|
+
}
|
|
360
|
+
const cmd = cmds.join("; ");
|
|
361
|
+
const r = await runCommand(cfg, cmd, timeoutMs);
|
|
362
|
+
const out = (r.stdout || "") + (r.stderr || "");
|
|
363
|
+
const section = (label) => {
|
|
364
|
+
const i = out.indexOf(`<<${label}>>`);
|
|
365
|
+
if (i === -1)
|
|
366
|
+
return "";
|
|
367
|
+
const start = i + label.length + 7; // <<LABEL>>\n
|
|
368
|
+
const rest = out.slice(start);
|
|
369
|
+
const next = rest.search(/<<[A-Z]+>>/);
|
|
370
|
+
return next === -1 ? rest.trim() : rest.slice(0, next).trim();
|
|
371
|
+
};
|
|
372
|
+
const envStr = section("ENV");
|
|
373
|
+
const envVars = {};
|
|
374
|
+
for (const line of envStr.split("\n")) {
|
|
375
|
+
const eq = line.indexOf("=");
|
|
376
|
+
if (eq > 0)
|
|
377
|
+
envVars[line.slice(0, eq)] = line.slice(eq + 1);
|
|
378
|
+
}
|
|
379
|
+
let procInfo = null;
|
|
380
|
+
const procSection = section("PROC");
|
|
381
|
+
if (procSection) {
|
|
382
|
+
const fields = procSection.split(/\s+/);
|
|
383
|
+
if (fields.length >= 11) {
|
|
384
|
+
procInfo = {
|
|
385
|
+
pid: parseInt(fields[1], 10),
|
|
386
|
+
ppid: parseInt(fields[2], 10),
|
|
387
|
+
cmdline: fields.slice(10).join(" "),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
server: cfg.name,
|
|
393
|
+
envVars,
|
|
394
|
+
procInfo,
|
|
395
|
+
users: section("USERS"),
|
|
396
|
+
openFiles: section("OPENFILES"),
|
|
397
|
+
network: section("NETWORK"),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const watches = new Map();
|
|
401
|
+
let watchCounter = 0;
|
|
402
|
+
export function startWatch(cfg, command, intervalMs, onIteration, timeoutMs = 10_000) {
|
|
403
|
+
const id = `w${++watchCounter}`;
|
|
404
|
+
const wh = { id, command, server: cfg.name, intervalMs, state: "running" };
|
|
405
|
+
let prevOut = "";
|
|
406
|
+
const tick = async () => {
|
|
407
|
+
if (wh.state === "stopped") {
|
|
408
|
+
clearInterval(timer);
|
|
409
|
+
watches.delete(id);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const r = await runCommand(cfg, command, timeoutMs);
|
|
414
|
+
const out = r.stdout + r.stderr;
|
|
415
|
+
const changed = out !== prevOut;
|
|
416
|
+
const diff = changed ? computeDiff(prevOut, out) : "";
|
|
417
|
+
prevOut = out;
|
|
418
|
+
onIteration(id, {
|
|
419
|
+
timestamp: Date.now(),
|
|
420
|
+
stdout: r.stdout,
|
|
421
|
+
stderr: r.stderr,
|
|
422
|
+
exitCode: r.code,
|
|
423
|
+
changed,
|
|
424
|
+
diff,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// skip failed iterations
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
const timer = setInterval(tick, intervalMs);
|
|
432
|
+
watches.set(id, { ...wh, timer });
|
|
433
|
+
// run first iteration immediately
|
|
434
|
+
tick();
|
|
435
|
+
return wh;
|
|
436
|
+
}
|
|
437
|
+
export function stopWatch(id) {
|
|
438
|
+
const w = watches.get(id);
|
|
439
|
+
if (!w)
|
|
440
|
+
return false;
|
|
441
|
+
w.state = "stopped";
|
|
442
|
+
clearInterval(w.timer);
|
|
443
|
+
watches.delete(id);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
export function getWatch(id) {
|
|
447
|
+
return watches.get(id);
|
|
448
|
+
}
|
|
449
|
+
export function listWatches() {
|
|
450
|
+
return [...watches.values()].map((w) => ({
|
|
451
|
+
id: w.id, command: w.command, server: w.server, intervalMs: w.intervalMs, state: w.state,
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
function computeDiff(prev, curr) {
|
|
455
|
+
const pa = prev.split("\n");
|
|
456
|
+
const ca = curr.split("\n");
|
|
457
|
+
const lines = [];
|
|
458
|
+
const max = Math.max(pa.length, ca.length);
|
|
459
|
+
for (let i = 0; i < max; i++) {
|
|
460
|
+
const p = pa[i] ?? "";
|
|
461
|
+
const c = ca[i] ?? "";
|
|
462
|
+
if (p !== c) {
|
|
463
|
+
if (p)
|
|
464
|
+
lines.push(`- ${p}`);
|
|
465
|
+
if (c)
|
|
466
|
+
lines.push(`+ ${c}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return lines.slice(0, 40).join("\n");
|
|
470
|
+
}
|