openclaw-agent-dashboard 1.0.23 → 1.0.25
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/dashboard/status/change_tracker.py +1 -1
- package/index.js +113 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/install-python-deps.js +9 -6
package/index.js
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
* 2. ~/.openclaw-agent-dashboard/config.json(或 OPENCLAW_AGENT_DASHBOARD_DATA/config.json)
|
|
10
10
|
* 3. openclaw.json 中 plugins.entries.openclaw-agent-dashboard.config.port
|
|
11
11
|
* 4. 默认 38271
|
|
12
|
+
*
|
|
13
|
+
* 启动前:若首选端口被占用且响应为本插件的 /api/version,则在 Unix 上 SIGTERM 释放端口(避免重启后落到 38272)。
|
|
12
14
|
*/
|
|
13
15
|
const path = require('path');
|
|
14
16
|
const os = require('os');
|
|
15
17
|
const fs = require('fs');
|
|
16
18
|
const net = require('net');
|
|
19
|
+
const http = require('http');
|
|
17
20
|
const { spawn, execFileSync } = require('child_process');
|
|
18
21
|
|
|
19
22
|
let dashboardProcess = null;
|
|
@@ -145,6 +148,112 @@ async function findAvailablePort(basePort, maxAttempts = 10) {
|
|
|
145
148
|
return basePort;
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
/** 与 Python 端 VersionInfo.name 一致(来自 openclaw.plugin.json / package.json) */
|
|
152
|
+
function getExpectedDashboardApiName() {
|
|
153
|
+
try {
|
|
154
|
+
const manifestPath = path.join(__dirname, 'openclaw.plugin.json');
|
|
155
|
+
if (fs.existsSync(manifestPath)) {
|
|
156
|
+
const j = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
157
|
+
if (j && typeof j.name === 'string' && j.name.trim()) return j.name.trim();
|
|
158
|
+
if (j && typeof j.id === 'string' && j.id.trim()) return j.id.trim();
|
|
159
|
+
}
|
|
160
|
+
} catch (_) {}
|
|
161
|
+
return 'openclaw-agent-dashboard';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 探测本机 port 上是否为当前插件的 Dashboard(FastAPI /api/version)
|
|
166
|
+
* @returns {Promise<boolean>}
|
|
167
|
+
*/
|
|
168
|
+
function probeOurDashboardListening(port) {
|
|
169
|
+
const expected = getExpectedDashboardApiName();
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
const req = http.get(
|
|
172
|
+
`http://127.0.0.1:${port}/api/version`,
|
|
173
|
+
{ timeout: 2500 },
|
|
174
|
+
(res) => {
|
|
175
|
+
let raw = '';
|
|
176
|
+
res.on('data', (c) => { raw += c; });
|
|
177
|
+
res.on('end', () => {
|
|
178
|
+
try {
|
|
179
|
+
const data = JSON.parse(raw);
|
|
180
|
+
resolve(typeof data.name === 'string' && data.name.trim() === expected);
|
|
181
|
+
} catch (_) {
|
|
182
|
+
resolve(false);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
req.on('error', () => resolve(false));
|
|
188
|
+
req.on('timeout', () => {
|
|
189
|
+
req.destroy();
|
|
190
|
+
resolve(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 获取在 port 上 LISTEN 的进程 PID(Unix)。Windows 暂不支持,返回空数组。
|
|
197
|
+
* @returns {number[]}
|
|
198
|
+
*/
|
|
199
|
+
function getListenPidsOnPort(port) {
|
|
200
|
+
if (process.platform === 'win32') {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
const tryLsof = (args) => {
|
|
204
|
+
try {
|
|
205
|
+
const out = execFileSync('lsof', args, { encoding: 'utf8', timeout: 5000 });
|
|
206
|
+
return out
|
|
207
|
+
.trim()
|
|
208
|
+
.split(/\s+/)
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
.map((s) => parseInt(s, 10))
|
|
211
|
+
.filter((n) => !Number.isNaN(n) && n > 0);
|
|
212
|
+
} catch (_) {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
let pids = tryLsof(['-iTCP:' + port, '-sTCP:LISTEN', '-t']);
|
|
217
|
+
if (pids.length === 0) {
|
|
218
|
+
pids = tryLsof(['-ti', ':' + port]);
|
|
219
|
+
}
|
|
220
|
+
return [...new Set(pids)];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sleepMs(ms) {
|
|
224
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Gateway 重启后旧 Dashboard 子进程常仍占用首选端口,导致新实例落到 38272。
|
|
229
|
+
* 若占用者为本插件的 Dashboard,则 SIGTERM 释放端口后再启动(仅 Unix;Windows 行为不变)。
|
|
230
|
+
*/
|
|
231
|
+
async function reclaimStaleOurDashboardPort(port) {
|
|
232
|
+
if (await isPortAvailable(port)) return;
|
|
233
|
+
const ours = await probeOurDashboardListening(port);
|
|
234
|
+
if (!ours) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const pids = getListenPidsOnPort(port);
|
|
238
|
+
if (pids.length === 0) {
|
|
239
|
+
console.log(
|
|
240
|
+
`[OpenClaw-Dashboard] 端口 ${port} 上检测到本插件 Dashboard,但无法解析占用进程(可安装 lsof),仍尝试后续端口`
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
console.log(
|
|
245
|
+
`[OpenClaw-Dashboard] 端口 ${port} 被上一实例 Dashboard 占用 (PID: ${pids.join(', ')}),正在结束以便固定端口`
|
|
246
|
+
);
|
|
247
|
+
for (const pid of pids) {
|
|
248
|
+
try {
|
|
249
|
+
process.kill(pid, 'SIGTERM');
|
|
250
|
+
} catch (e) {
|
|
251
|
+
console.warn(`[OpenClaw-Dashboard] 无法向 PID ${pid} 发送 SIGTERM:`, e.message || e);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
await sleepMs(900);
|
|
255
|
+
}
|
|
256
|
+
|
|
148
257
|
function startDashboard(config = {}) {
|
|
149
258
|
// 仅在 Gateway 进程内自动启动(OPENCLAW_GATEWAY_PORT 由 Gateway 设置)
|
|
150
259
|
// CLI 命令(status、health 等)加载插件时不会设置此变量,故不启动
|
|
@@ -173,9 +282,10 @@ function startDashboard(config = {}) {
|
|
|
173
282
|
return;
|
|
174
283
|
}
|
|
175
284
|
|
|
176
|
-
const portPromise =
|
|
177
|
-
|
|
178
|
-
: findAvailablePort(basePort);
|
|
285
|
+
const portPromise = (async () => {
|
|
286
|
+
await reclaimStaleOurDashboardPort(basePort);
|
|
287
|
+
return isExplicitPort ? basePort : await findAvailablePort(basePort);
|
|
288
|
+
})();
|
|
179
289
|
|
|
180
290
|
portPromise.then(async (port) => {
|
|
181
291
|
if (dashboardProcess) return;
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -280,9 +280,9 @@ async function installWithPipUser(reqFile, silent) {
|
|
|
280
280
|
* @param {object} options
|
|
281
281
|
* @param {boolean} [options.verbose]
|
|
282
282
|
* @param {boolean} [options.venvOnly]
|
|
283
|
-
* @returns {boolean}
|
|
283
|
+
* @returns {Promise<boolean>}
|
|
284
284
|
*/
|
|
285
|
-
function installPythonDeps(pluginDir, options = {}) {
|
|
285
|
+
async function installPythonDeps(pluginDir, options = {}) {
|
|
286
286
|
const { verbose = false, venvOnly = false } = options;
|
|
287
287
|
const reqFile = path.join(pluginDir, 'dashboard', 'requirements.txt');
|
|
288
288
|
const venvDir = path.join(pluginDir, 'dashboard', '.venv');
|
|
@@ -309,7 +309,7 @@ function installPythonDeps(pluginDir, options = {}) {
|
|
|
309
309
|
|
|
310
310
|
// 策略 2: pip --user 兜底
|
|
311
311
|
if (!success && !venvOnly) {
|
|
312
|
-
const result = installWithPipUser(reqFile, silent);
|
|
312
|
+
const result = await installWithPipUser(reqFile, silent);
|
|
313
313
|
if (result.success) {
|
|
314
314
|
success = true;
|
|
315
315
|
method = result.method;
|
|
@@ -403,7 +403,7 @@ function printPythonDepsHelp(reqFile) {
|
|
|
403
403
|
// 主函数
|
|
404
404
|
// ============================================
|
|
405
405
|
|
|
406
|
-
function main() {
|
|
406
|
+
async function main() {
|
|
407
407
|
const { pluginDir, verbose, venvOnly } = parseArgs();
|
|
408
408
|
|
|
409
409
|
// 检查参数
|
|
@@ -426,8 +426,11 @@ function main() {
|
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
// 执行安装
|
|
429
|
-
const success = installPythonDeps(pluginDir, { verbose, venvOnly });
|
|
429
|
+
const success = await installPythonDeps(pluginDir, { verbose, venvOnly });
|
|
430
430
|
process.exit(success ? 0 : 1);
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
-
main()
|
|
433
|
+
main().catch((err) => {
|
|
434
|
+
logError(err && err.message ? err.message : String(err));
|
|
435
|
+
process.exit(1);
|
|
436
|
+
});
|