koishi-plugin-docker-control 0.0.5 → 0.0.6
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/lib/commands/control.js +36 -1
- package/lib/service/node.d.ts +56 -20
- package/lib/service/node.js +380 -43
- package/lib/utils/render.d.ts +11 -1
- package/lib/utils/render.js +108 -2
- package/package.json +3 -2
package/lib/commands/control.js
CHANGED
|
@@ -3,6 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.registerControlCommands = registerControlCommands;
|
|
4
4
|
const logger_1 = require("../utils/logger");
|
|
5
5
|
const render_1 = require("../utils/render");
|
|
6
|
+
/**
|
|
7
|
+
* 格式化网络流量显示
|
|
8
|
+
*/
|
|
9
|
+
function formatNet(bytes) {
|
|
10
|
+
const num = parseFloat(bytes);
|
|
11
|
+
if (isNaN(num))
|
|
12
|
+
return '-';
|
|
13
|
+
if (num < 1024)
|
|
14
|
+
return bytes + 'B';
|
|
15
|
+
if (num < 1024 * 1024)
|
|
16
|
+
return (num / 1024).toFixed(1) + 'KB';
|
|
17
|
+
return (num / 1024 / 1024).toFixed(2) + 'MB';
|
|
18
|
+
}
|
|
6
19
|
/**
|
|
7
20
|
* 格式化容器搜索结果
|
|
8
21
|
*/
|
|
@@ -179,8 +192,13 @@ function registerControlCommands(ctx, getService, config) {
|
|
|
179
192
|
try {
|
|
180
193
|
const { node, container: found } = await service.findContainer(selector, container);
|
|
181
194
|
const info = await node.getContainer(found.Id);
|
|
195
|
+
// 获取性能数据和端口映射
|
|
196
|
+
const [stats, ports] = await Promise.all([
|
|
197
|
+
node.getContainerStats(found.Id),
|
|
198
|
+
node.getContainerPorts(found.Id),
|
|
199
|
+
]);
|
|
182
200
|
if (useImageOutput && ctx.puppeteer) {
|
|
183
|
-
const html = (0, render_1.generateInspectHtml)(node.name, info);
|
|
201
|
+
const html = (0, render_1.generateInspectHtml)(node.name, info, stats, ports);
|
|
184
202
|
return await (0, render_1.renderToImage)(ctx, html);
|
|
185
203
|
}
|
|
186
204
|
const lines = [
|
|
@@ -192,6 +210,23 @@ function registerControlCommands(ctx, getService, config) {
|
|
|
192
210
|
`启动时间: ${info.State.StartedAt}`,
|
|
193
211
|
`重启次数: ${info.RestartCount || 0}`,
|
|
194
212
|
];
|
|
213
|
+
// 添加性能数据
|
|
214
|
+
if (stats) {
|
|
215
|
+
lines.push('');
|
|
216
|
+
lines.push('性能监控:');
|
|
217
|
+
lines.push(` CPU: ${stats.cpuPercent}`);
|
|
218
|
+
lines.push(` 内存: ${stats.memoryPercent} (${stats.memoryUsage} / ${stats.memoryLimit})`);
|
|
219
|
+
lines.push(` 网络: ${formatNet(stats.networkIn)} / ${formatNet(stats.networkOut)}`);
|
|
220
|
+
lines.push(` 进程: ${stats.pids}`);
|
|
221
|
+
}
|
|
222
|
+
// 添加端口映射
|
|
223
|
+
if (ports && ports.length > 0) {
|
|
224
|
+
lines.push('');
|
|
225
|
+
lines.push('端口映射:');
|
|
226
|
+
for (const port of ports) {
|
|
227
|
+
lines.push(` ${port}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
195
230
|
if (info.State.Health) {
|
|
196
231
|
lines.push(`健康状态: ${info.State.Health.Status}`);
|
|
197
232
|
}
|
package/lib/service/node.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export declare class DockerNode {
|
|
|
6
6
|
status: NodeStatusType;
|
|
7
7
|
/** SSH 连接器 */
|
|
8
8
|
private connector;
|
|
9
|
+
/** Dockerode 实例 (用于 API 调用) */
|
|
10
|
+
private dockerode;
|
|
11
|
+
/** Docker API 是否可用 */
|
|
12
|
+
private dockerApiAvailable;
|
|
9
13
|
/** 监控定时器 (容器状态轮询) */
|
|
10
14
|
private monitorTimer;
|
|
11
15
|
/** 事件监控定时器 (docker events) */
|
|
@@ -38,26 +42,6 @@ export declare class DockerNode {
|
|
|
38
42
|
* 重新连接
|
|
39
43
|
*/
|
|
40
44
|
reconnect(): Promise<void>;
|
|
41
|
-
/**
|
|
42
|
-
* 列出容器
|
|
43
|
-
*/
|
|
44
|
-
listContainers(all?: boolean): Promise<ContainerInfo[]>;
|
|
45
|
-
/**
|
|
46
|
-
* 启动容器
|
|
47
|
-
*/
|
|
48
|
-
startContainer(containerId: string): Promise<void>;
|
|
49
|
-
/**
|
|
50
|
-
* 停止容器
|
|
51
|
-
*/
|
|
52
|
-
stopContainer(containerId: string, timeout?: number): Promise<void>;
|
|
53
|
-
/**
|
|
54
|
-
* 重启容器
|
|
55
|
-
*/
|
|
56
|
-
restartContainer(containerId: string, timeout?: number): Promise<void>;
|
|
57
|
-
/**
|
|
58
|
-
* 获取容器日志
|
|
59
|
-
*/
|
|
60
|
-
getContainerLogs(containerId: string, tail?: number): Promise<string>;
|
|
61
45
|
/**
|
|
62
46
|
* 执行容器内命令
|
|
63
47
|
*/
|
|
@@ -112,6 +96,58 @@ export declare class DockerNode {
|
|
|
112
96
|
* 获取容器详细信息 (docker inspect)
|
|
113
97
|
*/
|
|
114
98
|
getContainer(containerId: string): Promise<any>;
|
|
99
|
+
/**
|
|
100
|
+
* 初始化 Dockerode
|
|
101
|
+
* 根据配置决定连接本地 Socket 还是通过 SSH 连接远程
|
|
102
|
+
*/
|
|
103
|
+
private initDockerode;
|
|
104
|
+
/**
|
|
105
|
+
* 列出容器 (优先使用 API)
|
|
106
|
+
*/
|
|
107
|
+
listContainers(all?: boolean): Promise<ContainerInfo[]>;
|
|
108
|
+
/**
|
|
109
|
+
* 启动容器
|
|
110
|
+
*/
|
|
111
|
+
startContainer(containerId: string): Promise<void>;
|
|
112
|
+
/**
|
|
113
|
+
* 停止容器
|
|
114
|
+
*/
|
|
115
|
+
stopContainer(containerId: string, timeout?: number): Promise<void>;
|
|
116
|
+
/**
|
|
117
|
+
* 重启容器
|
|
118
|
+
*/
|
|
119
|
+
restartContainer(containerId: string, timeout?: number): Promise<void>;
|
|
120
|
+
/**
|
|
121
|
+
* 获取容器日志 (优先使用 API)
|
|
122
|
+
*/
|
|
123
|
+
getContainerLogs(containerId: string, tail?: number): Promise<string>;
|
|
124
|
+
/**
|
|
125
|
+
* 清洗 Docker 日志流 (去除 8 字节头部)
|
|
126
|
+
*/
|
|
127
|
+
private cleanDockerLogStream;
|
|
128
|
+
/**
|
|
129
|
+
* 使用 Docker API 获取容器性能数据
|
|
130
|
+
*/
|
|
131
|
+
private getContainerStatsByApi;
|
|
132
|
+
/**
|
|
133
|
+
* 获取容器性能数据 (CPU、内存使用率)
|
|
134
|
+
* 优先使用 Docker API,失败则降级到 SSH 命令
|
|
135
|
+
*/
|
|
136
|
+
getContainerStats(containerId: string): Promise<{
|
|
137
|
+
cpuPercent: string;
|
|
138
|
+
memoryUsage: string;
|
|
139
|
+
memoryLimit: string;
|
|
140
|
+
memoryPercent: string;
|
|
141
|
+
networkIn: string;
|
|
142
|
+
networkOut: string;
|
|
143
|
+
blockIn: string;
|
|
144
|
+
blockOut: string;
|
|
145
|
+
pids: string;
|
|
146
|
+
} | null>;
|
|
147
|
+
/**
|
|
148
|
+
* 获取容器端口映射
|
|
149
|
+
*/
|
|
150
|
+
getContainerPorts(containerId: string): Promise<string[]>;
|
|
115
151
|
/**
|
|
116
152
|
* 解析 docker ps 输出
|
|
117
153
|
*/
|
package/lib/service/node.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.DockerNode = void 0;
|
|
4
7
|
/**
|
|
5
8
|
* Docker 节点类 - 通过 SSH 执行 docker 命令
|
|
6
9
|
*/
|
|
7
10
|
const koishi_1 = require("koishi");
|
|
11
|
+
const dockerode_1 = __importDefault(require("dockerode"));
|
|
8
12
|
const constants_1 = require("../constants");
|
|
9
13
|
const connector_1 = require("./connector");
|
|
10
14
|
const logger_1 = require("../utils/logger");
|
|
@@ -16,6 +20,10 @@ class DockerNode {
|
|
|
16
20
|
this.status = constants_1.NodeStatus.DISCONNECTED;
|
|
17
21
|
/** SSH 连接器 */
|
|
18
22
|
this.connector = null;
|
|
23
|
+
/** Dockerode 实例 (用于 API 调用) */
|
|
24
|
+
this.dockerode = null;
|
|
25
|
+
/** Docker API 是否可用 */
|
|
26
|
+
this.dockerApiAvailable = false;
|
|
19
27
|
/** 监控定时器 (容器状态轮询) */
|
|
20
28
|
this.monitorTimer = null;
|
|
21
29
|
/** 事件监控定时器 (docker events) */
|
|
@@ -67,8 +75,10 @@ class DockerNode {
|
|
|
67
75
|
await connector.exec('docker version --format "{{.Server.Version}}"');
|
|
68
76
|
// 标记连接可用,允许事件流自动重连
|
|
69
77
|
connector.setConnected(true);
|
|
78
|
+
// 初始化 Dockerode (用于 API 调用)
|
|
79
|
+
this.initDockerode();
|
|
70
80
|
this.status = constants_1.NodeStatus.CONNECTED;
|
|
71
|
-
logger_1.nodeLogger.info(`[${this.name}]
|
|
81
|
+
logger_1.nodeLogger.info(`[${this.name}] 连接成功 (SSH + ${this.dockerApiAvailable ? 'Docker API' : 'SSH 命令模式'})`);
|
|
72
82
|
// 启动监控
|
|
73
83
|
this.startMonitoring();
|
|
74
84
|
// 触发上线事件
|
|
@@ -102,6 +112,8 @@ class DockerNode {
|
|
|
102
112
|
this.clearTimers();
|
|
103
113
|
this.connector?.dispose();
|
|
104
114
|
this.connector = null;
|
|
115
|
+
this.dockerode = null;
|
|
116
|
+
this.dockerApiAvailable = false;
|
|
105
117
|
this.status = constants_1.NodeStatus.DISCONNECTED;
|
|
106
118
|
logger_1.nodeLogger.info(`[${this.name}] 已断开连接`);
|
|
107
119
|
}
|
|
@@ -112,48 +124,6 @@ class DockerNode {
|
|
|
112
124
|
await this.disconnect();
|
|
113
125
|
await this.connect();
|
|
114
126
|
}
|
|
115
|
-
/**
|
|
116
|
-
* 列出容器
|
|
117
|
-
*/
|
|
118
|
-
async listContainers(all = true) {
|
|
119
|
-
if (!this.connector || this.status !== constants_1.NodeStatus.CONNECTED) {
|
|
120
|
-
throw new Error(`节点 ${this.name} 未连接`);
|
|
121
|
-
}
|
|
122
|
-
const output = await this.connector.listContainers(all);
|
|
123
|
-
return this.parseContainerList(output);
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* 启动容器
|
|
127
|
-
*/
|
|
128
|
-
async startContainer(containerId) {
|
|
129
|
-
if (!this.connector)
|
|
130
|
-
throw new Error('未连接');
|
|
131
|
-
await this.connector.startContainer(containerId);
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* 停止容器
|
|
135
|
-
*/
|
|
136
|
-
async stopContainer(containerId, timeout = 10) {
|
|
137
|
-
if (!this.connector)
|
|
138
|
-
throw new Error('未连接');
|
|
139
|
-
await this.connector.stopContainer(containerId, timeout);
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* 重启容器
|
|
143
|
-
*/
|
|
144
|
-
async restartContainer(containerId, timeout = 10) {
|
|
145
|
-
if (!this.connector)
|
|
146
|
-
throw new Error('未连接');
|
|
147
|
-
await this.connector.restartContainer(containerId, timeout);
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* 获取容器日志
|
|
151
|
-
*/
|
|
152
|
-
async getContainerLogs(containerId, tail = 100) {
|
|
153
|
-
if (!this.connector)
|
|
154
|
-
throw new Error('未连接');
|
|
155
|
-
return this.connector.getLogs(containerId, tail);
|
|
156
|
-
}
|
|
157
127
|
/**
|
|
158
128
|
* 执行容器内命令
|
|
159
129
|
*/
|
|
@@ -323,6 +293,362 @@ class DockerNode {
|
|
|
323
293
|
const info = JSON.parse(output);
|
|
324
294
|
return Array.isArray(info) ? info[0] : info;
|
|
325
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* 初始化 Dockerode
|
|
298
|
+
* 根据配置决定连接本地 Socket 还是通过 SSH 连接远程
|
|
299
|
+
*/
|
|
300
|
+
initDockerode() {
|
|
301
|
+
try {
|
|
302
|
+
let dockerOptions;
|
|
303
|
+
// 判断是否是本地节点
|
|
304
|
+
const isLocal = this.config.host === '127.0.0.1' || this.config.host === 'localhost';
|
|
305
|
+
if (isLocal) {
|
|
306
|
+
// 本地连接
|
|
307
|
+
dockerOptions = {
|
|
308
|
+
socketPath: '/var/run/docker.sock',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
// === 远程 SSH 连接配置 ===
|
|
313
|
+
// 1. 构建 ssh2 的连接参数
|
|
314
|
+
const sshOpts = {
|
|
315
|
+
host: this.config.host,
|
|
316
|
+
port: this.config.port || 22,
|
|
317
|
+
username: this.credential.username,
|
|
318
|
+
readyTimeout: 20000, // 连接超时
|
|
319
|
+
};
|
|
320
|
+
// 2. 根据认证类型注入凭证
|
|
321
|
+
if (this.credential.authType === 'password' && this.credential.password) {
|
|
322
|
+
sshOpts.password = this.credential.password;
|
|
323
|
+
}
|
|
324
|
+
else if (this.credential.privateKey) {
|
|
325
|
+
// 关键:私钥通常需要去掉首尾多余空白,否则 ssh2 解析会失败
|
|
326
|
+
sshOpts.privateKey = this.credential.privateKey.trim();
|
|
327
|
+
if (this.credential.passphrase) {
|
|
328
|
+
sshOpts.passphrase = this.credential.passphrase;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// 3. 构建 Dockerode 配置
|
|
332
|
+
// 重点:必须使用 sshOptions 属性包裹 ssh 配置,否则 dockerode 可能无法正确透传凭证
|
|
333
|
+
dockerOptions = {
|
|
334
|
+
protocol: 'ssh',
|
|
335
|
+
host: this.config.host,
|
|
336
|
+
port: this.config.port || 22,
|
|
337
|
+
username: this.credential.username,
|
|
338
|
+
sshOptions: sshOpts, // <--- 这里是修复的关键
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
this.dockerode = new dockerode_1.default(dockerOptions);
|
|
342
|
+
// 测试连接是否真正可用
|
|
343
|
+
this.dockerode.ping().then(() => {
|
|
344
|
+
this.dockerApiAvailable = true;
|
|
345
|
+
logger_1.nodeLogger.debug(`[${this.name}] Docker API 连接成功 (${isLocal ? 'Local' : 'SSH'})`);
|
|
346
|
+
}).catch((e) => {
|
|
347
|
+
this.dockerApiAvailable = false;
|
|
348
|
+
// 详细记录错误原因,方便排查
|
|
349
|
+
logger_1.nodeLogger.warn(`[${this.name}] Docker API 连接失败: ${e.message} (将降级使用 SSH 命令)`);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
this.dockerode = null;
|
|
354
|
+
this.dockerApiAvailable = false;
|
|
355
|
+
logger_1.nodeLogger.debug(`[${this.name}] Dockerode 初始化异常: ${e}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* 列出容器 (优先使用 API)
|
|
360
|
+
*/
|
|
361
|
+
async listContainers(all = true) {
|
|
362
|
+
// 方式 1: 尝试使用 Docker API
|
|
363
|
+
if (this.dockerode && this.dockerApiAvailable) {
|
|
364
|
+
try {
|
|
365
|
+
const containers = await this.dockerode.listContainers({ all });
|
|
366
|
+
// 转换 Dockerode 的返回格式
|
|
367
|
+
return containers.map(c => ({
|
|
368
|
+
Id: c.Id,
|
|
369
|
+
Names: c.Names,
|
|
370
|
+
Image: c.Image,
|
|
371
|
+
ImageID: c.ImageID,
|
|
372
|
+
Command: c.Command,
|
|
373
|
+
Created: c.Created,
|
|
374
|
+
Ports: c.Ports,
|
|
375
|
+
Labels: c.Labels,
|
|
376
|
+
State: c.State,
|
|
377
|
+
Status: c.Status,
|
|
378
|
+
HostConfig: { NetworkMode: c.HostConfig?.NetworkMode || '' },
|
|
379
|
+
NetworkSettings: { Networks: c.NetworkSettings?.Networks || {} },
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
logger_1.nodeLogger.warn(`[${this.name}] API listContainers 失败,降级到 SSH: ${e.message}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// 方式 2: SSH 命令行回退
|
|
387
|
+
if (!this.connector || this.status !== constants_1.NodeStatus.CONNECTED) {
|
|
388
|
+
throw new Error(`节点 ${this.name} 未连接`);
|
|
389
|
+
}
|
|
390
|
+
const output = await this.connector.listContainers(all);
|
|
391
|
+
return this.parseContainerList(output);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* 启动容器
|
|
395
|
+
*/
|
|
396
|
+
async startContainer(containerId) {
|
|
397
|
+
if (this.dockerode && this.dockerApiAvailable) {
|
|
398
|
+
try {
|
|
399
|
+
const container = this.dockerode.getContainer(containerId);
|
|
400
|
+
await container.start();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
logger_1.nodeLogger.warn(`[${this.name}] API startContainer 失败: ${e.message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Fallback
|
|
408
|
+
if (!this.connector)
|
|
409
|
+
throw new Error('未连接');
|
|
410
|
+
await this.connector.startContainer(containerId);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* 停止容器
|
|
414
|
+
*/
|
|
415
|
+
async stopContainer(containerId, timeout = 10) {
|
|
416
|
+
if (this.dockerode && this.dockerApiAvailable) {
|
|
417
|
+
try {
|
|
418
|
+
const container = this.dockerode.getContainer(containerId);
|
|
419
|
+
await container.stop({ t: timeout });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
catch (e) {
|
|
423
|
+
logger_1.nodeLogger.warn(`[${this.name}] API stopContainer 失败: ${e.message}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Fallback
|
|
427
|
+
if (!this.connector)
|
|
428
|
+
throw new Error('未连接');
|
|
429
|
+
await this.connector.stopContainer(containerId, timeout);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* 重启容器
|
|
433
|
+
*/
|
|
434
|
+
async restartContainer(containerId, timeout = 10) {
|
|
435
|
+
if (this.dockerode && this.dockerApiAvailable) {
|
|
436
|
+
try {
|
|
437
|
+
const container = this.dockerode.getContainer(containerId);
|
|
438
|
+
await container.restart({ t: timeout });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
catch (e) {
|
|
442
|
+
logger_1.nodeLogger.warn(`[${this.name}] API restartContainer 失败: ${e.message}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Fallback
|
|
446
|
+
if (!this.connector)
|
|
447
|
+
throw new Error('未连接');
|
|
448
|
+
await this.connector.restartContainer(containerId, timeout);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* 获取容器日志 (优先使用 API)
|
|
452
|
+
*/
|
|
453
|
+
async getContainerLogs(containerId, tail = 100) {
|
|
454
|
+
if (this.dockerode && this.dockerApiAvailable) {
|
|
455
|
+
try {
|
|
456
|
+
const container = this.dockerode.getContainer(containerId);
|
|
457
|
+
const buffer = await container.logs({
|
|
458
|
+
follow: false,
|
|
459
|
+
stdout: true,
|
|
460
|
+
stderr: true,
|
|
461
|
+
tail: tail,
|
|
462
|
+
timestamps: false,
|
|
463
|
+
});
|
|
464
|
+
return this.cleanDockerLogStream(buffer);
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
logger_1.nodeLogger.warn(`[${this.name}] API getLogs 失败: ${e.message}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// Fallback
|
|
471
|
+
if (!this.connector)
|
|
472
|
+
throw new Error('未连接');
|
|
473
|
+
return this.connector.getLogs(containerId, tail);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* 清洗 Docker 日志流 (去除 8 字节头部)
|
|
477
|
+
*/
|
|
478
|
+
cleanDockerLogStream(buffer) {
|
|
479
|
+
let offset = 0;
|
|
480
|
+
let output = '';
|
|
481
|
+
while (offset < buffer.length) {
|
|
482
|
+
// 头部结构: [STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4]
|
|
483
|
+
if (offset + 8 > buffer.length)
|
|
484
|
+
break;
|
|
485
|
+
// 读取 payload 大小 (大端序)
|
|
486
|
+
const size = buffer.readUInt32BE(offset + 4);
|
|
487
|
+
// 移动到 payload 开始
|
|
488
|
+
offset += 8;
|
|
489
|
+
if (offset + size > buffer.length)
|
|
490
|
+
break;
|
|
491
|
+
// 读取实际内容
|
|
492
|
+
output += buffer.subarray(offset, offset + size).toString('utf-8');
|
|
493
|
+
offset += size;
|
|
494
|
+
}
|
|
495
|
+
// 如果解析失败,直接转 string
|
|
496
|
+
if (!output && buffer.length > 0)
|
|
497
|
+
return buffer.toString('utf-8');
|
|
498
|
+
return output;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* 使用 Docker API 获取容器性能数据
|
|
502
|
+
*/
|
|
503
|
+
async getContainerStatsByApi(containerId) {
|
|
504
|
+
if (!this.dockerode || !this.dockerApiAvailable) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const container = this.dockerode.getContainer(containerId);
|
|
509
|
+
// stream: false 时,dockerode 直接返回解析好的 Object,而不是 Buffer 或 Stream
|
|
510
|
+
const data = await container.stats({ stream: false });
|
|
511
|
+
// 内存使用量 (bytes)
|
|
512
|
+
const memoryUsage = data.memory_stats?.usage || 0;
|
|
513
|
+
const memoryLimit = data.memory_stats?.limit || 0;
|
|
514
|
+
const memoryPercent = memoryLimit > 0 ? ((memoryUsage / memoryLimit) * 100).toFixed(2) + '%' : '0%';
|
|
515
|
+
// CPU 使用率计算 (基于 cpu_delta / system_cpu_delta)
|
|
516
|
+
const cpuUsage = data.cpu_stats?.cpu_usage?.total_usage || 0;
|
|
517
|
+
const systemUsage = data.cpu_stats?.system_cpu_usage || 0;
|
|
518
|
+
// 有些环境 online_cpus 不存在,回退到 percpu_usage 的长度
|
|
519
|
+
const cpuCount = data.cpu_stats?.online_cpus || data.cpu_stats?.cpu_usage?.percpu_usage?.length || 1;
|
|
520
|
+
let cpuPercent = '0.00%';
|
|
521
|
+
// 需要前一次的数据 (precpu_stats) 来计算差值
|
|
522
|
+
if (data.precpu_stats?.cpu_usage?.total_usage !== undefined && data.precpu_stats?.system_cpu_usage !== undefined) {
|
|
523
|
+
const cpuDelta = cpuUsage - data.precpu_stats.cpu_usage.total_usage;
|
|
524
|
+
const systemDelta = systemUsage - data.precpu_stats.system_cpu_usage;
|
|
525
|
+
if (systemDelta > 0 && cpuDelta > 0) {
|
|
526
|
+
// 公式: (cpuDelta / systemDelta) * cpuCount * 100
|
|
527
|
+
cpuPercent = ((cpuDelta / systemDelta) * cpuCount * 100).toFixed(2) + '%';
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// 网络流量 (bytes)
|
|
531
|
+
const networks = data.networks || {};
|
|
532
|
+
let networkIn = 0;
|
|
533
|
+
let networkOut = 0;
|
|
534
|
+
// 累加所有网卡的流量
|
|
535
|
+
for (const net of Object.values(networks)) {
|
|
536
|
+
networkIn += net.rx_bytes || 0;
|
|
537
|
+
networkOut += net.tx_bytes || 0;
|
|
538
|
+
}
|
|
539
|
+
// Block IO (bytes)
|
|
540
|
+
const blkioStats = data.blkio_stats || {};
|
|
541
|
+
const ioServiceBytes = blkioStats.io_service_bytes_recursive || [];
|
|
542
|
+
let blockIn = 0;
|
|
543
|
+
let blockOut = 0;
|
|
544
|
+
for (const io of ioServiceBytes) {
|
|
545
|
+
if (io.op === 'Read')
|
|
546
|
+
blockIn += io.value || 0;
|
|
547
|
+
if (io.op === 'Write')
|
|
548
|
+
blockOut += io.value || 0;
|
|
549
|
+
}
|
|
550
|
+
// 进程数
|
|
551
|
+
const pids = data.pids_stats?.current || '-';
|
|
552
|
+
return {
|
|
553
|
+
cpuPercent,
|
|
554
|
+
memoryUsage: formatBytes(memoryUsage),
|
|
555
|
+
memoryLimit: formatBytes(memoryLimit),
|
|
556
|
+
memoryPercent,
|
|
557
|
+
networkIn: formatBytes(networkIn),
|
|
558
|
+
networkOut: formatBytes(networkOut),
|
|
559
|
+
blockIn: formatBytes(blockIn),
|
|
560
|
+
blockOut: formatBytes(blockOut),
|
|
561
|
+
pids: String(pids),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
565
|
+
// 只有在调试模式下打印详细错误,防止刷屏
|
|
566
|
+
if (this.debug) {
|
|
567
|
+
logger_1.nodeLogger.warn(`[${this.name}] Docker API 获取性能数据失败: ${e}`);
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* 获取容器性能数据 (CPU、内存使用率)
|
|
574
|
+
* 优先使用 Docker API,失败则降级到 SSH 命令
|
|
575
|
+
*/
|
|
576
|
+
async getContainerStats(containerId) {
|
|
577
|
+
if (!this.connector)
|
|
578
|
+
return null;
|
|
579
|
+
// 优先尝试 Docker API
|
|
580
|
+
if (this.dockerApiAvailable) {
|
|
581
|
+
const apiResult = await this.getContainerStatsByApi(containerId);
|
|
582
|
+
if (apiResult) {
|
|
583
|
+
logger_1.nodeLogger.debug(`[${this.name}] Docker API 获取容器 ${containerId} 性能数据成功`);
|
|
584
|
+
return apiResult;
|
|
585
|
+
}
|
|
586
|
+
logger_1.nodeLogger.debug(`[${this.name}] Docker API 获取容器 ${containerId} 性能数据失败,降级到 SSH`);
|
|
587
|
+
}
|
|
588
|
+
// 降级到 SSH 命令
|
|
589
|
+
try {
|
|
590
|
+
// 使用 execWithExitCode,因为停止的容器返回退出码 1
|
|
591
|
+
const result = await this.connector.execWithExitCode(`docker stats --no-stream --no-trunc ${containerId} --format "{{.CPUPerc}}|{{.MemPerc}}|{{.MemUsage}}|{{.NetIn}}|{{.NetOut}}|{{.BlockIn}}|{{.BlockOut}}|{{.PIDs}}"`);
|
|
592
|
+
logger_1.nodeLogger.debug(`[${this.name}] SSH docker stats 输出: "${result.output}", 退出码: ${result.exitCode}`);
|
|
593
|
+
// 如果没有输出(容器可能不存在或已停止),返回 null
|
|
594
|
+
if (!result.output.trim()) {
|
|
595
|
+
logger_1.nodeLogger.debug(`[${this.name}] 容器 ${containerId} 性能数据为空,可能已停止`);
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
const parts = result.output.split('|');
|
|
599
|
+
if (parts.length < 8) {
|
|
600
|
+
logger_1.nodeLogger.warn(`[${this.name}] 容器 ${containerId} 性能数据格式异常: "${result.output}"`);
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
// MemUsage 格式: "123.4MiB / 2GiB",解析内存使用量和限制
|
|
604
|
+
const memUsageParts = parts[2]?.split(' / ') || ['-', '-'];
|
|
605
|
+
return {
|
|
606
|
+
cpuPercent: parts[0]?.trim() || '-',
|
|
607
|
+
memoryPercent: parts[1]?.trim() || '-',
|
|
608
|
+
memoryUsage: memUsageParts[0]?.trim() || '-',
|
|
609
|
+
memoryLimit: memUsageParts[1]?.trim() || '-',
|
|
610
|
+
networkIn: parts[3]?.trim() || '-',
|
|
611
|
+
networkOut: parts[4]?.trim() || '-',
|
|
612
|
+
blockIn: parts[5]?.trim() || '-',
|
|
613
|
+
blockOut: parts[6]?.trim() || '-',
|
|
614
|
+
pids: parts[7]?.trim() || '-',
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
catch (e) {
|
|
618
|
+
logger_1.nodeLogger.warn(`[${this.name}] 获取容器 ${containerId} 性能数据失败: ${e}`);
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* 获取容器端口映射
|
|
624
|
+
*/
|
|
625
|
+
async getContainerPorts(containerId) {
|
|
626
|
+
if (!this.connector)
|
|
627
|
+
return [];
|
|
628
|
+
try {
|
|
629
|
+
const output = await this.connector.exec(`docker inspect ${containerId} --format "{{json .HostConfig.PortBindings}}"`);
|
|
630
|
+
if (!output.trim() || output === 'null') {
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
const portBindings = JSON.parse(output);
|
|
634
|
+
const portStrings = [];
|
|
635
|
+
for (const [containerPort, bindings] of Object.entries(portBindings)) {
|
|
636
|
+
for (const binding of bindings) {
|
|
637
|
+
if (binding.HostIp === '0.0.0.0' || binding.HostIp === '::') {
|
|
638
|
+
portStrings.push(`${binding.HostPort}->${containerPort}`);
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
portStrings.push(`${binding.HostIp}:${binding.HostPort}->${containerPort}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return portStrings.sort();
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
logger_1.nodeLogger.warn(`[${this.name}] 获取容器 ${containerId} 端口映射失败: ${e}`);
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
}
|
|
326
652
|
/**
|
|
327
653
|
* 解析 docker ps 输出
|
|
328
654
|
*/
|
|
@@ -673,3 +999,14 @@ class DockerNode {
|
|
|
673
999
|
get tags() { return this.config.tags; }
|
|
674
1000
|
}
|
|
675
1001
|
exports.DockerNode = DockerNode;
|
|
1002
|
+
/**
|
|
1003
|
+
* 格式化字节为可读格式
|
|
1004
|
+
*/
|
|
1005
|
+
function formatBytes(bytes) {
|
|
1006
|
+
if (!bytes || bytes < 0)
|
|
1007
|
+
return '-';
|
|
1008
|
+
const k = 1024;
|
|
1009
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
1010
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1011
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
1012
|
+
}
|
package/lib/utils/render.d.ts
CHANGED
|
@@ -28,7 +28,17 @@ export declare function generateResultHtml(results: Array<{
|
|
|
28
28
|
/**
|
|
29
29
|
* 生成详情 HTML
|
|
30
30
|
*/
|
|
31
|
-
export declare function generateInspectHtml(nodeName: string, info: any
|
|
31
|
+
export declare function generateInspectHtml(nodeName: string, info: any, stats?: {
|
|
32
|
+
cpuPercent: string;
|
|
33
|
+
memoryUsage: string;
|
|
34
|
+
memoryLimit: string;
|
|
35
|
+
memoryPercent: string;
|
|
36
|
+
networkIn: string;
|
|
37
|
+
networkOut: string;
|
|
38
|
+
blockIn: string;
|
|
39
|
+
blockOut: string;
|
|
40
|
+
pids: string;
|
|
41
|
+
} | null, ports?: string[]): string;
|
|
32
42
|
/**
|
|
33
43
|
* 生成节点列表 HTML
|
|
34
44
|
*/
|
package/lib/utils/render.js
CHANGED
|
@@ -300,7 +300,7 @@ function generateResultHtml(results, title) {
|
|
|
300
300
|
/**
|
|
301
301
|
* 生成详情 HTML
|
|
302
302
|
*/
|
|
303
|
-
function generateInspectHtml(nodeName, info) {
|
|
303
|
+
function generateInspectHtml(nodeName, info, stats, ports) {
|
|
304
304
|
const name = info.Name.replace('/', '');
|
|
305
305
|
const shortId = info.Id.slice(0, 12);
|
|
306
306
|
const isRunning = info.State.Running;
|
|
@@ -335,6 +335,67 @@ function generateInspectHtml(nodeName, info) {
|
|
|
335
335
|
return ` ${mount.Source} → ${mount.Destination} (${mount.Type})`;
|
|
336
336
|
}).join('\n')
|
|
337
337
|
: '-';
|
|
338
|
+
// 端口映射
|
|
339
|
+
const portsDisplay = ports && ports.length > 0
|
|
340
|
+
? ports.join('\n')
|
|
341
|
+
: '-';
|
|
342
|
+
// 判断容器是否运行
|
|
343
|
+
const containerRunning = info.State.Running;
|
|
344
|
+
// 性能数据
|
|
345
|
+
const statsDisplay = stats
|
|
346
|
+
? containerRunning
|
|
347
|
+
? `
|
|
348
|
+
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin-top: 8px;">
|
|
349
|
+
<div style="background: rgba(0,0,0,0.15); padding: 6px 4px; border-radius: 6px; text-align: center;">
|
|
350
|
+
<div style="font-size: 9px; color: #cbd5e1; margin-bottom: 2px;">CPU</div>
|
|
351
|
+
<div style="font-size: 13px; font-weight: 600; color: ${parseCpuColor(stats.cpuPercent)}">${stats.cpuPercent}</div>
|
|
352
|
+
</div>
|
|
353
|
+
<div style="background: rgba(0,0,0,0.15); padding: 6px 4px; border-radius: 6px; text-align: center;">
|
|
354
|
+
<div style="font-size: 9px; color: #cbd5e1; margin-bottom: 2px;">内存</div>
|
|
355
|
+
<div style="font-size: 13px; font-weight: 600; color: #60a5fa">${stats.memoryUsage}</div>
|
|
356
|
+
<div style="font-size: 9px; color: #cbd5e1;">/ ${stats.memoryLimit}</div>
|
|
357
|
+
</div>
|
|
358
|
+
<div style="background: rgba(0,0,0,0.15); padding: 6px 4px; border-radius: 6px; text-align: center;">
|
|
359
|
+
<div style="font-size: 9px; color: #cbd5e1; margin-bottom: 2px;">网络</div>
|
|
360
|
+
<div style="font-size: 13px; font-weight: 600; color: #60a5fa">${stats.networkIn ? formatNetwork(stats.networkIn) : '-'}</div>
|
|
361
|
+
</div>
|
|
362
|
+
<div style="background: rgba(0,0,0,0.15); padding: 6px 4px; border-radius: 6px; text-align: center;">
|
|
363
|
+
<div style="font-size: 9px; color: #cbd5e1; margin-bottom: 2px;">IO</div>
|
|
364
|
+
<div style="font-size: 13px; font-weight: 600; color: #f472b6">${stats.blockIn}</div>
|
|
365
|
+
<div style="font-size: 9px; color: #cbd5e1;">↓ ${stats.blockOut}↑</div>
|
|
366
|
+
</div>
|
|
367
|
+
<div style="background: rgba(0,0,0,0.15); padding: 6px 4px; border-radius: 6px; text-align: center;">
|
|
368
|
+
<div style="font-size: 9px; color: #94a3b8; margin-bottom: 2px;">进程</div>
|
|
369
|
+
<div style="font-size: 13px; font-weight: 600; color: #a78bfa">${stats.pids}</div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
`
|
|
373
|
+
: `
|
|
374
|
+
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin-top: 8px;">
|
|
375
|
+
<div style="background: rgba(0,0,0,0.1); padding: 6px 4px; border-radius: 6px; text-align: center; opacity: 0.6;">
|
|
376
|
+
<div style="font-size: 9px; color: #cbd5e1; margin-bottom: 2px;">CPU</div>
|
|
377
|
+
<div style="font-size: 13px; font-weight: 600; color: #94a3b8;">-</div>
|
|
378
|
+
</div>
|
|
379
|
+
<div style="background: rgba(0,0,0,0.1); padding: 6px 4px; border-radius: 6px; text-align: center; opacity: 0.6;">
|
|
380
|
+
<div style="font-size: 9px; color: #cbd5e1; margin-bottom: 2px;">内存</div>
|
|
381
|
+
<div style="font-size: 13px; font-weight: 600; color: #94a3b8;">-</div>
|
|
382
|
+
</div>
|
|
383
|
+
<div style="background: rgba(0,0,0,0.1); padding: 6px 4px; border-radius: 6px; text-align: center; opacity: 0.6;">
|
|
384
|
+
<div style="font-size: 9px; color: #cbd5e1; margin-bottom: 2px;">网络</div>
|
|
385
|
+
<div style="font-size: 13px; font-weight: 600; color: #94a3b8;">-</div>
|
|
386
|
+
</div>
|
|
387
|
+
<div style="background: rgba(0,0,0,0.1); padding: 6px 4px; border-radius: 6px; text-align: center; opacity: 0.6;">
|
|
388
|
+
<div style="font-size: 9px; color: #cbd5e1; margin-bottom: 2px;">IO</div>
|
|
389
|
+
<div style="font-size: 13px; font-weight: 600; color: #94a3b8;">-</div>
|
|
390
|
+
</div>
|
|
391
|
+
<div style="background: rgba(0,0,0,0.1); padding: 6px 4px; border-radius: 6px; text-align: center; opacity: 0.6;">
|
|
392
|
+
<div style="font-size: 9px; color: #cbd5e1; margin-bottom: 2px;">进程</div>
|
|
393
|
+
<div style="font-size: 13px; font-weight: 600; color: #a78bfa">${stats.pids}</div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
<div style="font-size: 9px; color: #f59e0b; margin-top: 6px;">⚠ 容器已停止,无法获取实时监控数据</div>
|
|
397
|
+
`
|
|
398
|
+
: '<span style="color: #64748b; font-size: 11px;">(获取失败)</span>';
|
|
338
399
|
const items = [
|
|
339
400
|
{ label: '容器名称', value: name, span: false },
|
|
340
401
|
{ label: '容器 ID', value: info.Id, span: false },
|
|
@@ -344,6 +405,8 @@ function generateInspectHtml(nodeName, info) {
|
|
|
344
405
|
{ label: '启动时间', value: new Date(info.State.StartedAt).toLocaleString(), span: false },
|
|
345
406
|
{ label: '重启策略', value: restartDisplay, span: false },
|
|
346
407
|
{ label: '重启次数', value: String(info.RestartCount), span: false },
|
|
408
|
+
{ label: '性能监控', value: statsDisplay, span: true, isHtml: true },
|
|
409
|
+
{ label: '端口映射', value: portsDisplay, span: true },
|
|
347
410
|
{ label: '网络', value: networkInfo, span: true },
|
|
348
411
|
{ label: '环境变量', value: envDisplay, span: true },
|
|
349
412
|
{ label: '挂载目录', value: mountsDisplay, span: true },
|
|
@@ -354,7 +417,7 @@ function generateInspectHtml(nodeName, info) {
|
|
|
354
417
|
const gridItems = items.map(item => `
|
|
355
418
|
<div class="detail-item ${item.span ? 'detail-span' : ''}">
|
|
356
419
|
<div class="detail-label">${item.label}</div>
|
|
357
|
-
<div class="detail-value ${item.highlight ? 'highlight' : ''}">${item.value.replace(/\n/g, '<br>')}</div>
|
|
420
|
+
<div class="detail-value ${item.highlight ? 'highlight' : ''}">${item.isHtml ? item.value : item.value.replace(/\n/g, '<br>')}</div>
|
|
358
421
|
</div>
|
|
359
422
|
`).join('');
|
|
360
423
|
const header = `
|
|
@@ -381,6 +444,49 @@ function generateInspectHtml(nodeName, info) {
|
|
|
381
444
|
`;
|
|
382
445
|
return wrapHtml(header + body);
|
|
383
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* 根据 CPU 使用率返回颜色
|
|
449
|
+
*/
|
|
450
|
+
function parseCpuColor(cpuPercent) {
|
|
451
|
+
const value = parseFloat(cpuPercent.replace('%', ''));
|
|
452
|
+
if (isNaN(value))
|
|
453
|
+
return '#94a3b8';
|
|
454
|
+
if (value < 30)
|
|
455
|
+
return '#4ade80';
|
|
456
|
+
if (value < 60)
|
|
457
|
+
return '#facc15';
|
|
458
|
+
if (value < 80)
|
|
459
|
+
return '#fb923c';
|
|
460
|
+
return '#f87171';
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* 根据内存使用率返回颜色
|
|
464
|
+
*/
|
|
465
|
+
function parseMemColor(memPercent) {
|
|
466
|
+
const value = parseFloat(memPercent.replace('%', ''));
|
|
467
|
+
if (isNaN(value))
|
|
468
|
+
return '#94a3b8';
|
|
469
|
+
if (value < 50)
|
|
470
|
+
return '#60a5fa';
|
|
471
|
+
if (value < 70)
|
|
472
|
+
return '#facc15';
|
|
473
|
+
if (value < 85)
|
|
474
|
+
return '#fb923c';
|
|
475
|
+
return '#f87171';
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* 格式化网络流量显示
|
|
479
|
+
*/
|
|
480
|
+
function formatNetwork(bytes) {
|
|
481
|
+
const num = parseFloat(bytes);
|
|
482
|
+
if (isNaN(num))
|
|
483
|
+
return '-';
|
|
484
|
+
if (num < 1024)
|
|
485
|
+
return bytes + 'B/s';
|
|
486
|
+
if (num < 1024 * 1024)
|
|
487
|
+
return (num / 1024).toFixed(1) + 'KB/s';
|
|
488
|
+
return (num / 1024 / 1024).toFixed(2) + 'MB/s';
|
|
489
|
+
}
|
|
384
490
|
/**
|
|
385
491
|
* 生成节点列表 HTML
|
|
386
492
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-docker-control",
|
|
3
3
|
"description": "Koishi 插件 - 通过 SSH 控制 Docker 容器",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.6",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"ssh2": "^1.17.0",
|
|
29
|
-
"puppeteer": "^21.0.0"
|
|
29
|
+
"puppeteer": "^21.0.0",
|
|
30
|
+
"dockerode": "^4.0.2"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@types/node": "^20.19.27",
|