evolclaw-web 1.0.1 → 1.2.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/dist/index.js +9 -9
- package/dist/process-utils.js +20 -12
- package/dist/server.js +236 -16
- package/dist/sources/aid.js +21 -2
- package/dist/sources/cache.js +43 -0
- package/dist/sources/monitor.js +96 -0
- package/dist/sources/session.js +10 -1
- package/dist/sources/stats.js +348 -0
- package/dist/sources/system.js +51 -0
- package/dist/sources/triggers.js +54 -0
- package/dist/sources/types.js +1 -1
- package/dist/static/app.js +1457 -41
- package/dist/static/index.html +124 -3
- package/dist/static/style.css +419 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -67,14 +67,14 @@ const fileLog = (line) => {
|
|
|
67
67
|
};
|
|
68
68
|
const log = (line) => { logLine(line); fileLog(line); };
|
|
69
69
|
// 单实例保护:
|
|
70
|
-
// 1) 按 instance 文件杀掉登记在册的旧
|
|
71
|
-
const {
|
|
72
|
-
const killedWebs =
|
|
70
|
+
// 1) 按 instance 文件杀掉登记在册的旧 ecweb 进程
|
|
71
|
+
const { writeEcweb, removeEcweb, cleanupEcwebs, cleanupEcwebByPort } = await import('./process-utils.js');
|
|
72
|
+
const killedWebs = cleanupEcwebs();
|
|
73
73
|
for (const r of killedWebs)
|
|
74
|
-
logLine(`${YELLOW}↺ 已清理旧
|
|
74
|
+
logLine(`${YELLOW}↺ 已清理旧 ecweb 进程 PID ${r.pid}(端口 ${r.port})${RST}`);
|
|
75
75
|
// 2) 兜底:按端口杀掉 instance 文件已丢失的孤儿进程(杀不掉的僵尸)
|
|
76
76
|
const WATCH_WEB_PORT = port ?? 42705;
|
|
77
|
-
const killedByPort =
|
|
77
|
+
const killedByPort = cleanupEcwebByPort(WATCH_WEB_PORT);
|
|
78
78
|
for (const pid of killedByPort)
|
|
79
79
|
logLine(`${YELLOW}↺ 已强占端口 ${WATCH_WEB_PORT}:杀掉占用进程 PID ${pid}${RST}`);
|
|
80
80
|
if (killedWebs.length > 0 || killedByPort.length > 0) {
|
|
@@ -90,7 +90,7 @@ catch (e) {
|
|
|
90
90
|
process.exit(1);
|
|
91
91
|
}
|
|
92
92
|
// 注册 instance 文件
|
|
93
|
-
|
|
93
|
+
writeEcweb(handle.port);
|
|
94
94
|
// 列出访问地址
|
|
95
95
|
const ifaces = os.networkInterfaces();
|
|
96
96
|
const lanIps = [];
|
|
@@ -101,7 +101,7 @@ for (const list of Object.values(ifaces)) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
process.stdout.write(`\n${BOLD}${CYAN}🔭 EvolClaw Watch${RST} ${DIM}(home: ${p.root})${RST}\n\n`);
|
|
104
|
-
process.stdout.write(` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(5 分钟内有效,配对后 token 缓存
|
|
104
|
+
process.stdout.write(` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(5 分钟内有效,配对后 token 缓存 30 天,有访问自动续期)${RST}\n\n`);
|
|
105
105
|
process.stdout.write(` ${BOLD}本机:${RST} http://localhost:${handle.port}\n`);
|
|
106
106
|
for (const ip of lanIps)
|
|
107
107
|
process.stdout.write(` ${BOLD}局域网:${RST} http://${ip}:${handle.port}\n`);
|
|
@@ -116,13 +116,13 @@ const cleanup = () => {
|
|
|
116
116
|
return; // 幂等:raw 模式下连按 Ctrl-C/q 不应重复触发
|
|
117
117
|
cleaningUp = true;
|
|
118
118
|
logLine(`${YELLOW}退出中…${RST}`);
|
|
119
|
-
|
|
119
|
+
removeEcweb();
|
|
120
120
|
// 兜底:close() 万一卡住也强制退出,避免进程挂死
|
|
121
121
|
const force = setTimeout(() => process.exit(0), 2000);
|
|
122
122
|
force.unref();
|
|
123
123
|
handle.close().finally(() => process.exit(0));
|
|
124
124
|
};
|
|
125
|
-
process.on('exit', () =>
|
|
125
|
+
process.on('exit', () => removeEcweb());
|
|
126
126
|
process.on('SIGINT', cleanup);
|
|
127
127
|
process.on('SIGTERM', cleanup);
|
|
128
128
|
if (process.stdin.isTTY) {
|
package/dist/process-utils.js
CHANGED
|
@@ -151,25 +151,32 @@ function readCmdline(pid) {
|
|
|
151
151
|
function instanceDir() {
|
|
152
152
|
return resolvePaths().instanceDir;
|
|
153
153
|
}
|
|
154
|
-
function
|
|
155
|
-
return
|
|
154
|
+
function isEcwebInstanceFile(file) {
|
|
155
|
+
return /^(ecweb|watch-web)-\d+\.json$/.test(file);
|
|
156
156
|
}
|
|
157
|
-
|
|
157
|
+
function ecwebFile(pid) {
|
|
158
|
+
return path.join(instanceDir(), `ecweb-${pid}.json`);
|
|
159
|
+
}
|
|
160
|
+
export function writeEcweb(port) {
|
|
158
161
|
const dir = instanceDir();
|
|
159
162
|
fs.mkdirSync(dir, { recursive: true });
|
|
160
163
|
const startedAt = getProcessStartTime(process.pid) ?? Date.now();
|
|
161
164
|
const record = { pid: process.pid, startedAt, startedAtIso: new Date(startedAt).toISOString(), port };
|
|
162
|
-
const filePath =
|
|
165
|
+
const filePath = ecwebFile(process.pid);
|
|
163
166
|
const tmp = filePath + '.tmp';
|
|
164
167
|
fs.writeFileSync(tmp, JSON.stringify(record, null, 2));
|
|
165
168
|
fs.renameSync(tmp, filePath);
|
|
166
169
|
return filePath;
|
|
167
170
|
}
|
|
168
|
-
export function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
+
export function removeEcweb(pid) {
|
|
172
|
+
const target = pid ?? process.pid;
|
|
173
|
+
const dir = instanceDir();
|
|
174
|
+
for (const prefix of ['ecweb', 'watch-web']) {
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(path.join(dir, `${prefix}-${target}.json`));
|
|
177
|
+
}
|
|
178
|
+
catch { }
|
|
171
179
|
}
|
|
172
|
-
catch { }
|
|
173
180
|
}
|
|
174
181
|
function safeParseJson(filePath) {
|
|
175
182
|
try {
|
|
@@ -180,10 +187,11 @@ function safeParseJson(filePath) {
|
|
|
180
187
|
}
|
|
181
188
|
}
|
|
182
189
|
/**
|
|
183
|
-
* 杀掉所有非自己 PID 的存活
|
|
190
|
+
* 杀掉所有非自己 PID 的存活 ecweb 进程并清理文件。
|
|
191
|
+
* 兼容清理迁移前遗留的 watch-web-*.json。
|
|
184
192
|
* 用启动时间比对防 PID 复用。返回被杀的记录列表。
|
|
185
193
|
*/
|
|
186
|
-
export function
|
|
194
|
+
export function cleanupEcwebs() {
|
|
187
195
|
const dir = instanceDir();
|
|
188
196
|
if (!fs.existsSync(dir))
|
|
189
197
|
return [];
|
|
@@ -196,7 +204,7 @@ export function cleanupWatchWebs() {
|
|
|
196
204
|
}
|
|
197
205
|
const killed = [];
|
|
198
206
|
for (const file of files) {
|
|
199
|
-
if (!
|
|
207
|
+
if (!isEcwebInstanceFile(file))
|
|
200
208
|
continue;
|
|
201
209
|
const filePath = path.join(dir, file);
|
|
202
210
|
const record = safeParseJson(filePath);
|
|
@@ -217,7 +225,7 @@ export function cleanupWatchWebs() {
|
|
|
217
225
|
* 兜底:按端口找占用进程,确认是 ecweb 进程后 SIGKILL。
|
|
218
226
|
* 用于清理 instance 文件已丢失的孤儿进程(杀不掉的僵尸)。返回被杀的 PID 列表。
|
|
219
227
|
*/
|
|
220
|
-
export function
|
|
228
|
+
export function cleanupEcwebByPort(port) {
|
|
221
229
|
const killed = [];
|
|
222
230
|
for (const pid of findPidByPort(port)) {
|
|
223
231
|
if (pid === process.pid || !isProcessRunning(pid))
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - HTTP: 静态资源 + 配对 API
|
|
5
5
|
* - WebSocket: 订阅式实时推送(aid / msg / session)
|
|
6
|
-
* - 鉴权: 6 位配对码(5 分钟有效)→ token(
|
|
6
|
+
* - 鉴权: 6 位配对码(5 分钟有效)→ token(30 天,有访问自动续期),持久化到磁盘
|
|
7
7
|
* - 安全: 绑定 0.0.0.0(支持远程访问),token 校验,只读
|
|
8
8
|
*
|
|
9
9
|
* 与 evolclaw 的唯一通信:启动时发 ping 检查 protocolVersion(soft 校验)。
|
|
@@ -12,6 +12,7 @@ import http from 'http';
|
|
|
12
12
|
import fs from 'fs';
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import crypto from 'crypto';
|
|
15
|
+
import { spawnSync } from 'child_process';
|
|
15
16
|
import { fileURLToPath } from 'url';
|
|
16
17
|
import { WebSocketServer } from 'ws';
|
|
17
18
|
import { resolvePaths } from './paths.js';
|
|
@@ -20,13 +21,31 @@ import { setDebugLog, dlog } from './debug-log.js';
|
|
|
20
21
|
import { aidSource } from './sources/aid.js';
|
|
21
22
|
import { msgSource } from './sources/msg.js';
|
|
22
23
|
import { sessionSource } from './sources/session.js';
|
|
24
|
+
import { cacheSource } from './sources/cache.js';
|
|
25
|
+
import { systemSource } from './sources/system.js';
|
|
26
|
+
import { triggersSource } from './sources/triggers.js';
|
|
27
|
+
import { monitorSource } from './sources/monitor.js';
|
|
28
|
+
import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsByAgent, queryStatsOverview } from './sources/stats.js';
|
|
29
|
+
import { getSessionsAunDir, listLocalAids, listPeers, readMessages } from './fs-utils.js';
|
|
30
|
+
import { ccProjectsDir } from './paths.js';
|
|
23
31
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
32
|
const STATIC_DIR = path.join(__dirname, 'static');
|
|
25
|
-
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; //
|
|
33
|
+
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
|
|
26
34
|
const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
|
|
27
35
|
const DEFAULT_PORT = 42705;
|
|
28
36
|
const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
|
|
29
|
-
const SOURCES = {
|
|
37
|
+
const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource };
|
|
38
|
+
// ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
|
|
39
|
+
function readEcwebVersion() {
|
|
40
|
+
try {
|
|
41
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
42
|
+
return pkg?.version ?? '0.0.0';
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return '0.0.0';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const ECWEB_VERSION = readEcwebVersion();
|
|
30
49
|
const MIME = {
|
|
31
50
|
'.html': 'text/html; charset=utf-8',
|
|
32
51
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -35,7 +54,16 @@ const MIME = {
|
|
|
35
54
|
'.svg': 'image/svg+xml',
|
|
36
55
|
};
|
|
37
56
|
function tokenStorePath() {
|
|
38
|
-
|
|
57
|
+
const dir = resolvePaths().instanceDir;
|
|
58
|
+
const current = path.join(dir, 'ecweb-tokens.json');
|
|
59
|
+
const legacy = path.join(dir, 'watch-web-tokens.json');
|
|
60
|
+
if (!fs.existsSync(current) && fs.existsSync(legacy)) {
|
|
61
|
+
try {
|
|
62
|
+
fs.renameSync(legacy, current);
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
}
|
|
66
|
+
return current;
|
|
39
67
|
}
|
|
40
68
|
function loadTokens() {
|
|
41
69
|
try {
|
|
@@ -108,12 +136,116 @@ function parseUrl(rawUrl) {
|
|
|
108
136
|
}
|
|
109
137
|
return { path: rawUrl.slice(0, qIdx), query };
|
|
110
138
|
}
|
|
139
|
+
function handleStatsApi(req, res) {
|
|
140
|
+
const { path: urlPath, query } = parseUrl(req.url || '');
|
|
141
|
+
res.setHeader('Content-Type', 'application/json');
|
|
142
|
+
if (urlPath === '/api/stats/dashboard') {
|
|
143
|
+
const data = queryStatsForDashboard();
|
|
144
|
+
if (!data) {
|
|
145
|
+
res.writeHead(503);
|
|
146
|
+
res.end(JSON.stringify({ error: 'stats unavailable (node:sqlite missing or no data)' }));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
res.writeHead(200);
|
|
150
|
+
res.end(JSON.stringify(data));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else if (urlPath === '/api/stats/explorer') {
|
|
154
|
+
const params = {};
|
|
155
|
+
if (query.from)
|
|
156
|
+
params.from_ts = Number(query.from);
|
|
157
|
+
if (query.to)
|
|
158
|
+
params.to_ts = Number(query.to);
|
|
159
|
+
if (query.agent)
|
|
160
|
+
params.agent_aid = query.agent;
|
|
161
|
+
if (query.peer)
|
|
162
|
+
params.peer_key = query.peer;
|
|
163
|
+
if (query.model)
|
|
164
|
+
params.model = query.model;
|
|
165
|
+
if (query.granularity)
|
|
166
|
+
params.granularity = query.granularity;
|
|
167
|
+
const data = queryStatsExplorer(params);
|
|
168
|
+
res.writeHead(200);
|
|
169
|
+
res.end(JSON.stringify(data));
|
|
170
|
+
}
|
|
171
|
+
else if (urlPath === '/api/stats/peers') {
|
|
172
|
+
const params = {};
|
|
173
|
+
if (query.from)
|
|
174
|
+
params.from_ts = Number(query.from);
|
|
175
|
+
if (query.to)
|
|
176
|
+
params.to_ts = Number(query.to);
|
|
177
|
+
if (query.agent)
|
|
178
|
+
params.agent_aid = query.agent;
|
|
179
|
+
if (query.limit)
|
|
180
|
+
params.limit = Number(query.limit);
|
|
181
|
+
const data = queryStatsByPeer(params);
|
|
182
|
+
res.writeHead(200);
|
|
183
|
+
res.end(JSON.stringify(data));
|
|
184
|
+
}
|
|
185
|
+
else if (urlPath === '/api/stats/agents') {
|
|
186
|
+
const params = {};
|
|
187
|
+
if (query.from)
|
|
188
|
+
params.from_ts = Number(query.from);
|
|
189
|
+
if (query.to)
|
|
190
|
+
params.to_ts = Number(query.to);
|
|
191
|
+
if (query.limit)
|
|
192
|
+
params.limit = Number(query.limit);
|
|
193
|
+
const data = queryStatsByAgent(params);
|
|
194
|
+
res.writeHead(200);
|
|
195
|
+
res.end(JSON.stringify(data));
|
|
196
|
+
}
|
|
197
|
+
else if (urlPath === '/api/stats/overview') {
|
|
198
|
+
const tokenStats = queryStatsOverview();
|
|
199
|
+
// session count: scan all CC project dirs
|
|
200
|
+
let sessionCount = 0;
|
|
201
|
+
try {
|
|
202
|
+
const base = ccProjectsDir();
|
|
203
|
+
for (const d of fs.readdirSync(base, { withFileTypes: true })) {
|
|
204
|
+
if (!d.isDirectory())
|
|
205
|
+
continue;
|
|
206
|
+
try {
|
|
207
|
+
sessionCount += fs.readdirSync(path.join(base, d.name)).filter(f => f.endsWith('.jsonl')).length;
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch { }
|
|
213
|
+
// message counts: scan aun dir
|
|
214
|
+
let msgIn = 0, msgOut = 0;
|
|
215
|
+
try {
|
|
216
|
+
const aunDir = getSessionsAunDir();
|
|
217
|
+
for (const aid of listLocalAids(aunDir)) {
|
|
218
|
+
for (const peer of listPeers(aunDir, aid)) {
|
|
219
|
+
for (const m of readMessages(aunDir, aid, peer)) {
|
|
220
|
+
if (m.dir === 'in')
|
|
221
|
+
msgIn++;
|
|
222
|
+
else
|
|
223
|
+
msgOut++;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch { }
|
|
229
|
+
res.writeHead(200);
|
|
230
|
+
res.end(JSON.stringify({ token_stats: tokenStats, session_count: sessionCount, msg_in: msgIn, msg_out: msgOut }));
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
res.writeHead(404);
|
|
234
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
111
237
|
function clientIp(req) {
|
|
112
238
|
const fwd = req.headers['x-forwarded-for'];
|
|
113
239
|
if (typeof fwd === 'string' && fwd)
|
|
114
240
|
return fwd.split(',')[0].trim();
|
|
115
241
|
return req.socket.remoteAddress || '?';
|
|
116
242
|
}
|
|
243
|
+
// 仅 localhost 可访问(取配对码):直连 socket 地址必须是回环。
|
|
244
|
+
// 不信任 x-forwarded-for(代理头可伪造),只看真实 TCP 来源。
|
|
245
|
+
function isLocalhost(req) {
|
|
246
|
+
const addr = req.socket.remoteAddress || '';
|
|
247
|
+
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
|
|
248
|
+
}
|
|
117
249
|
function genPairingCode() {
|
|
118
250
|
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
|
119
251
|
}
|
|
@@ -146,7 +278,7 @@ function handlePair(req, res, pairingCode, pairingExpiry, log) {
|
|
|
146
278
|
store.tokens.push({ token, createdAt: now, lastActive: now, label: ip });
|
|
147
279
|
saveTokens(store);
|
|
148
280
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, token }));
|
|
149
|
-
log(`✓ 配对成功 from ${ip}(token 缓存
|
|
281
|
+
log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期)`);
|
|
150
282
|
});
|
|
151
283
|
}
|
|
152
284
|
// ── WebSocket connection ──
|
|
@@ -173,15 +305,17 @@ function handleConnection(ws, req, log) {
|
|
|
173
305
|
send({ type: 'error', message: `unknown view: ${view}` });
|
|
174
306
|
return;
|
|
175
307
|
}
|
|
308
|
+
// System 视图:把 ECWeb 自身版本合并进快照(前端版本卡用)
|
|
309
|
+
const decorate = (data) => (view === 'system' && data ? { ...data, ecwebVersion: ECWEB_VERSION } : data);
|
|
176
310
|
try {
|
|
177
|
-
send({ type: 'snapshot', view, data: await source.snapshot(params) });
|
|
311
|
+
send({ type: 'snapshot', view, data: decorate(await source.snapshot(params)) });
|
|
178
312
|
}
|
|
179
313
|
catch (e) {
|
|
180
314
|
send({ type: 'error', message: `snapshot failed: ${e?.message || e}` });
|
|
181
315
|
}
|
|
182
316
|
unsubscribe = source.subscribe(params, (data) => {
|
|
183
317
|
if (currentView === view)
|
|
184
|
-
send({ type: 'delta', view, data });
|
|
318
|
+
send({ type: 'delta', view, data: decorate(data) });
|
|
185
319
|
});
|
|
186
320
|
};
|
|
187
321
|
ws.on('message', async (raw) => {
|
|
@@ -206,8 +340,25 @@ function handleConnection(ws, req, log) {
|
|
|
206
340
|
params.sessionId = msg.sessionId;
|
|
207
341
|
if (msg.project)
|
|
208
342
|
params.project = msg.project;
|
|
343
|
+
if (msg.agent)
|
|
344
|
+
params.agent = msg.agent;
|
|
209
345
|
dlog(`▸ 订阅 ${msg.view}${msg.aid ? ` aid=${String(msg.aid).split('.')[0]}` : ''}${msg.peer ? ` peer=${String(msg.peer).split('.')[0]}` : ''}${msg.sessionId ? ` session=${String(msg.sessionId).slice(0, 8)}` : ''} from ${ip}`);
|
|
210
346
|
await switchSubscription(msg.view, params);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (msg.type === 'menu' && msg.payload) {
|
|
350
|
+
const p = resolvePaths();
|
|
351
|
+
const resp = await ipcQuery(p.socket, { type: 'menu.exec', payload: msg.payload }, 5000);
|
|
352
|
+
if (resp?.ok) {
|
|
353
|
+
send({ type: 'menu.response', requestId: msg.requestId, data: resp.response });
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
send({ type: 'menu.response', requestId: msg.requestId, data: {
|
|
357
|
+
type: 'menu.response', id: msg.payload?.id ?? '',
|
|
358
|
+
error: { code: 'INTERNAL', message: resp?.error ?? 'daemon unreachable' },
|
|
359
|
+
} });
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
211
362
|
}
|
|
212
363
|
});
|
|
213
364
|
// NAT keepalive: ping every 25s to prevent middlebox from cutting the connection
|
|
@@ -232,19 +383,69 @@ function handleConnection(ws, req, log) {
|
|
|
232
383
|
ws.on('error', () => { });
|
|
233
384
|
}
|
|
234
385
|
// ── Port binding ──
|
|
235
|
-
|
|
386
|
+
/** 跨平台:查端口上的 LISTENING 进程 PID(清理被占端口用)。 */
|
|
387
|
+
function findPidsOnPort(port) {
|
|
388
|
+
const pids = new Set();
|
|
389
|
+
try {
|
|
390
|
+
if (process.platform === 'win32') {
|
|
391
|
+
const out = spawnSync('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf-8', windowsHide: true }).stdout || '';
|
|
392
|
+
for (const line of out.split('\n')) {
|
|
393
|
+
if (!/LISTENING/i.test(line))
|
|
394
|
+
continue;
|
|
395
|
+
const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
|
|
396
|
+
if (m && Number(m[1]) === port) {
|
|
397
|
+
const pid = Number(m[2]);
|
|
398
|
+
if (pid && pid !== process.pid)
|
|
399
|
+
pids.add(pid);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
const out = spawnSync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf-8' }).stdout || '';
|
|
405
|
+
for (const l of out.split('\n')) {
|
|
406
|
+
const pid = parseInt(l.trim(), 10);
|
|
407
|
+
if (pid && pid !== process.pid)
|
|
408
|
+
pids.add(pid);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch { /* netstat/lsof 缺失或无匹配 */ }
|
|
413
|
+
return [...pids];
|
|
414
|
+
}
|
|
415
|
+
/** 杀掉占用目标端口的旧进程(强制)。 */
|
|
416
|
+
function killPidsOnPort(port, log) {
|
|
417
|
+
let killed = false;
|
|
418
|
+
for (const pid of findPidsOnPort(port)) {
|
|
419
|
+
try {
|
|
420
|
+
if (process.platform === 'win32')
|
|
421
|
+
spawnSync('taskkill', ['/PID', String(pid), '/F'], { windowsHide: true });
|
|
422
|
+
else
|
|
423
|
+
process.kill(pid, 'SIGKILL');
|
|
424
|
+
log(`⚠ 端口 ${port} 被旧进程 PID ${pid} 占用,已终止`);
|
|
425
|
+
killed = true;
|
|
426
|
+
}
|
|
427
|
+
catch { /* 已退出或无权限 */ }
|
|
428
|
+
}
|
|
429
|
+
return killed;
|
|
430
|
+
}
|
|
431
|
+
function sleepMs(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
432
|
+
function bindPort(server, preferred, log) {
|
|
236
433
|
return new Promise((resolve, reject) => {
|
|
237
|
-
let
|
|
434
|
+
let killedOnce = false;
|
|
238
435
|
const tryBind = (port) => {
|
|
239
|
-
server.once('error', (err) => {
|
|
240
|
-
if (err.code === 'EADDRINUSE' &&
|
|
241
|
-
|
|
242
|
-
|
|
436
|
+
server.once('error', async (err) => {
|
|
437
|
+
if (err.code === 'EADDRINUSE' && !killedOnce) {
|
|
438
|
+
// 默认行为:杀掉占用端口的旧进程并重试本端口(而非 +1 漂移),避免端口被占。
|
|
439
|
+
killedOnce = true;
|
|
440
|
+
killPidsOnPort(port, log);
|
|
441
|
+
await sleepMs(600);
|
|
442
|
+
tryBind(port);
|
|
243
443
|
}
|
|
244
|
-
else
|
|
444
|
+
else {
|
|
245
445
|
reject(err);
|
|
446
|
+
}
|
|
246
447
|
});
|
|
247
|
-
server.listen(port, '0.0.0.0', () => resolve({ port, displaced:
|
|
448
|
+
server.listen(port, '0.0.0.0', () => resolve({ port, displaced: false }));
|
|
248
449
|
};
|
|
249
450
|
tryBind(preferred);
|
|
250
451
|
});
|
|
@@ -270,6 +471,12 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
270
471
|
}
|
|
271
472
|
const server = http.createServer((req, res) => {
|
|
272
473
|
if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
|
|
474
|
+
// 仅 localhost 可取码:远程浏览器拿不到,必须由同机的 `ec watch web` 显示给用户
|
|
475
|
+
if (!isLocalhost(req)) {
|
|
476
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
477
|
+
res.end(JSON.stringify({ error: 'forbidden' }));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
273
480
|
const { code, expiresAt } = freshPairing();
|
|
274
481
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
275
482
|
res.end(JSON.stringify({ code, expiresAt }));
|
|
@@ -277,6 +484,19 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
277
484
|
else if (req.method === 'POST' && (req.url || '').startsWith('/api/pair')) {
|
|
278
485
|
handlePair(req, res, pairingCode, pairingExpiry, log);
|
|
279
486
|
}
|
|
487
|
+
else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
|
|
488
|
+
// Stats API — requires auth
|
|
489
|
+
const authHeader = req.headers.authorization || '';
|
|
490
|
+
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
491
|
+
const { query } = parseUrl(req.url || '');
|
|
492
|
+
const token = bearerToken || query.token || '';
|
|
493
|
+
if (!validateAndRenew(token, Date.now())) {
|
|
494
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
495
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
handleStatsApi(req, res);
|
|
499
|
+
}
|
|
280
500
|
else {
|
|
281
501
|
serveStatic(req, res);
|
|
282
502
|
}
|
|
@@ -294,7 +514,7 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
294
514
|
handleConnection(ws, req, log);
|
|
295
515
|
});
|
|
296
516
|
});
|
|
297
|
-
const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT);
|
|
517
|
+
const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT, log);
|
|
298
518
|
return {
|
|
299
519
|
url: `http://0.0.0.0:${port}`,
|
|
300
520
|
port,
|
package/dist/sources/aid.js
CHANGED
|
@@ -64,13 +64,31 @@ function scanAidActivity() {
|
|
|
64
64
|
}
|
|
65
65
|
return result;
|
|
66
66
|
}
|
|
67
|
+
// evolclaw 版本:与 watch aid 状态栏一致。daemon 进程级、整轮 watch 不变,
|
|
68
|
+
// 故缓存一次(首次 menu.query name=system 拿到后复用),避免每秒多发一次 IPC。
|
|
69
|
+
let _cachedVersion = null;
|
|
70
|
+
async function fetchVersion(socket) {
|
|
71
|
+
if (_cachedVersion)
|
|
72
|
+
return _cachedVersion;
|
|
73
|
+
try {
|
|
74
|
+
const r = await ipcQuery(socket, { type: 'menu.exec', payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system' } }, 4000);
|
|
75
|
+
const v = r?.ok ? r.response?.data?.version : null;
|
|
76
|
+
if (v)
|
|
77
|
+
_cachedVersion = v;
|
|
78
|
+
return v ?? null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
67
84
|
async function buildSnapshot() {
|
|
68
85
|
const p = resolvePaths();
|
|
69
|
-
const [aidsResp, statsResp, statusResp, agentsResp] = await Promise.all([
|
|
86
|
+
const [aidsResp, statsResp, statusResp, agentsResp, version] = await Promise.all([
|
|
70
87
|
ipcQuery(p.socket, { type: 'aun-aids' }),
|
|
71
88
|
ipcQuery(p.socket, { type: 'aun-aid-stats' }),
|
|
72
89
|
ipcQuery(p.socket, { type: 'status' }),
|
|
73
90
|
ipcQuery(p.socket, { type: 'evolagent.list' }),
|
|
91
|
+
fetchVersion(p.socket),
|
|
74
92
|
]);
|
|
75
93
|
const daemonRunning = aidsResp !== null || statusResp !== null;
|
|
76
94
|
if (!daemonRunning) {
|
|
@@ -89,10 +107,11 @@ async function buildSnapshot() {
|
|
|
89
107
|
stats: statsResp?.stats ?? [],
|
|
90
108
|
status: statusResp ?? null,
|
|
91
109
|
agents: agentsResp?.agents ?? [],
|
|
110
|
+
version: version ?? null,
|
|
92
111
|
};
|
|
93
112
|
}
|
|
94
113
|
export const aidSource = {
|
|
95
|
-
kind: '
|
|
114
|
+
kind: 'agents',
|
|
96
115
|
async snapshot() {
|
|
97
116
|
return buildSnapshot();
|
|
98
117
|
},
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache 数据源 — daemon 统一 FileCache 的运行统计(命中率/读盘/驱逐/失效)。
|
|
3
|
+
*
|
|
4
|
+
* 复用 daemon 的 IPC socket:拉 `cache-stats`(只读)。IPC 无推送,
|
|
5
|
+
* 故 1s 轮询 + JSON diff,仅在变化时 push(与 aidSource 同款)。
|
|
6
|
+
* daemon 未运行(ipcQuery 返回 null)→ { daemonRunning:false, stats:null }。
|
|
7
|
+
*/
|
|
8
|
+
import { resolvePaths } from '../paths.js';
|
|
9
|
+
import { ipcQuery } from '../ipc-client.js';
|
|
10
|
+
async function buildSnapshot() {
|
|
11
|
+
const p = resolvePaths();
|
|
12
|
+
const resp = await ipcQuery(p.socket, { type: 'cache-stats' });
|
|
13
|
+
if (resp === null || !resp.ok) {
|
|
14
|
+
// daemon 离线,或旧 daemon 不认识 cache-stats(回 {error:...},ok 为 undefined)
|
|
15
|
+
return { daemonRunning: resp !== null, supported: !!resp?.ok, stats: null };
|
|
16
|
+
}
|
|
17
|
+
return { daemonRunning: true, supported: true, stats: resp.stats };
|
|
18
|
+
}
|
|
19
|
+
export const cacheSource = {
|
|
20
|
+
kind: 'cache',
|
|
21
|
+
async snapshot() {
|
|
22
|
+
return buildSnapshot();
|
|
23
|
+
},
|
|
24
|
+
subscribe(_params, push) {
|
|
25
|
+
let lastJson = '';
|
|
26
|
+
let stopped = false;
|
|
27
|
+
const tick = async () => {
|
|
28
|
+
if (stopped)
|
|
29
|
+
return;
|
|
30
|
+
try {
|
|
31
|
+
const snap = await buildSnapshot();
|
|
32
|
+
const json = JSON.stringify(snap);
|
|
33
|
+
if (json !== lastJson) {
|
|
34
|
+
lastJson = json;
|
|
35
|
+
push(snap);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { /* ignore transient IPC errors */ }
|
|
39
|
+
};
|
|
40
|
+
const timer = setInterval(tick, 1000);
|
|
41
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monitor 数据源 — 复用 daemon 的 IPC socket(`monitor-snapshot` 命令)。
|
|
3
|
+
*
|
|
4
|
+
* 拉进程级 + 系统级运行指标(CPU/内存/uptime/loadAvg)+ 全局 stats(含最近错误)
|
|
5
|
+
* + per-agent 汇总。IPC 无推送能力,故 2s 轮询 + JSON diff,仅在变化时 push。
|
|
6
|
+
*
|
|
7
|
+
* 时序数据在源侧维护「三档分辨率」滚动缓冲区,随快照一并下发,前端按所选周期切换:
|
|
8
|
+
* - fine : 2s 桶 × 60 ≈ 近 2 分钟
|
|
9
|
+
* - mid : 10s 桶 × 60 ≈ 近 10 分钟
|
|
10
|
+
* - coarse : 60s 桶 × 60 ≈ 近 1 小时
|
|
11
|
+
* 每个桶内对 CPU/内存做均值聚合,避免长周期下点数爆炸。
|
|
12
|
+
*/
|
|
13
|
+
import { resolvePaths } from '../paths.js';
|
|
14
|
+
import { ipcQuery } from '../ipc-client.js';
|
|
15
|
+
// 模块级缓冲区——所有订阅者共享同一份累积时序(单一事实源)。
|
|
16
|
+
const resolutions = {
|
|
17
|
+
fine: { bucketMs: 2_000, cap: 60, buf: [] },
|
|
18
|
+
mid: { bucketMs: 10_000, cap: 60, buf: [] },
|
|
19
|
+
coarse: { bucketMs: 60_000, cap: 60, buf: [] },
|
|
20
|
+
};
|
|
21
|
+
/** 把一个原始样本并入指定分辨率的桶(同桶内做增量均值,跨桶则新建)。 */
|
|
22
|
+
function ingest(res, sample) {
|
|
23
|
+
const bucketTs = Math.floor(sample.ts / res.bucketMs) * res.bucketMs;
|
|
24
|
+
const last = res.buf[res.buf.length - 1];
|
|
25
|
+
if (last && last.ts === bucketTs) {
|
|
26
|
+
const n = last._n + 1;
|
|
27
|
+
last.procCpu = last.procCpu + (sample.procCpu - last.procCpu) / n;
|
|
28
|
+
last.sysCpu = last.sysCpu + (sample.sysCpu - last.sysCpu) / n;
|
|
29
|
+
last.procRss = last.procRss + (sample.procRss - last.procRss) / n;
|
|
30
|
+
last.sysMemUsed = last.sysMemUsed + (sample.sysMemUsed - last.sysMemUsed) / n;
|
|
31
|
+
last._n = n;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
res.buf.push({ ts: bucketTs, procCpu: sample.procCpu, sysCpu: sample.sysCpu, procRss: sample.procRss, sysMemUsed: sample.sysMemUsed, _n: 1 });
|
|
35
|
+
if (res.buf.length > res.cap)
|
|
36
|
+
res.buf.shift();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** 导出某分辨率的纯数据点(剥掉内部 _n 字段)。 */
|
|
40
|
+
function exportBuf(res) {
|
|
41
|
+
return res.buf.map((b) => ({ ts: b.ts, procCpu: round1(b.procCpu), sysCpu: round1(b.sysCpu), procRss: Math.round(b.procRss), sysMemUsed: Math.round(b.sysMemUsed) }));
|
|
42
|
+
}
|
|
43
|
+
function round1(n) { return Math.round(n * 10) / 10; }
|
|
44
|
+
async function fetchSnapshot() {
|
|
45
|
+
const p = resolvePaths();
|
|
46
|
+
const resp = await ipcQuery(p.socket, { type: 'monitor-snapshot' }, 3000);
|
|
47
|
+
// resp === null → daemon 离线或 socket 不可达;resp.ok === false → 命令不受支持(旧 daemon)
|
|
48
|
+
if (!resp?.ok) {
|
|
49
|
+
return { daemonRunning: resp !== null, snapshot: null, history: { fine: [], mid: [], coarse: [] } };
|
|
50
|
+
}
|
|
51
|
+
const s = resp.snapshot;
|
|
52
|
+
const sample = {
|
|
53
|
+
ts: s.ts,
|
|
54
|
+
procCpu: s.cpuPercent ?? 0,
|
|
55
|
+
sysCpu: s.system?.cpuPercent ?? 0,
|
|
56
|
+
procRss: s.memory?.rss ?? 0,
|
|
57
|
+
sysMemUsed: s.system?.memUsed ?? 0,
|
|
58
|
+
};
|
|
59
|
+
ingest(resolutions.fine, sample);
|
|
60
|
+
ingest(resolutions.mid, sample);
|
|
61
|
+
ingest(resolutions.coarse, sample);
|
|
62
|
+
return {
|
|
63
|
+
daemonRunning: true,
|
|
64
|
+
snapshot: s,
|
|
65
|
+
history: {
|
|
66
|
+
fine: exportBuf(resolutions.fine),
|
|
67
|
+
mid: exportBuf(resolutions.mid),
|
|
68
|
+
coarse: exportBuf(resolutions.coarse),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export const monitorSource = {
|
|
73
|
+
kind: 'monitor',
|
|
74
|
+
async snapshot() {
|
|
75
|
+
return fetchSnapshot();
|
|
76
|
+
},
|
|
77
|
+
subscribe(_params, push) {
|
|
78
|
+
let lastJson = '';
|
|
79
|
+
let stopped = false;
|
|
80
|
+
const tick = async () => {
|
|
81
|
+
if (stopped)
|
|
82
|
+
return;
|
|
83
|
+
try {
|
|
84
|
+
const snap = await fetchSnapshot();
|
|
85
|
+
const json = JSON.stringify(snap);
|
|
86
|
+
if (json !== lastJson) {
|
|
87
|
+
lastJson = json;
|
|
88
|
+
push(snap);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch { /* ignore transient IPC errors */ }
|
|
92
|
+
};
|
|
93
|
+
const timer = setInterval(tick, 2000);
|
|
94
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
95
|
+
},
|
|
96
|
+
};
|