koishi-plugin-docker-control 0.0.4 → 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/compose.d.ts +11 -0
- package/lib/commands/compose.js +138 -0
- package/lib/commands/control.js +36 -1
- package/lib/commands/index.js +2 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +1 -1
- package/lib/service/connector.d.ts +19 -0
- package/lib/service/connector.js +59 -2
- package/lib/service/node.d.ts +71 -21
- package/lib/service/node.js +463 -46
- package/lib/types.d.ts +24 -0
- package/lib/utils/render.d.ts +15 -1
- package/lib/utils/render.js +186 -2
- package/package.json +3 -2
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Compose 命令 - 展示和发送 docker compose 配置
|
|
3
|
+
*/
|
|
4
|
+
import { Context } from 'koishi';
|
|
5
|
+
import type { DockerControlConfig } from '../types';
|
|
6
|
+
type GetService = () => any;
|
|
7
|
+
/**
|
|
8
|
+
* 注册 docker.compose 命令
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerComposeCommand(ctx: Context, getService: GetService, config?: DockerControlConfig): void;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerComposeCommand = registerComposeCommand;
|
|
4
|
+
/**
|
|
5
|
+
* Docker Compose 命令 - 展示和发送 docker compose 配置
|
|
6
|
+
*/
|
|
7
|
+
const koishi_1 = require("koishi");
|
|
8
|
+
const render_1 = require("../utils/render");
|
|
9
|
+
const logger_1 = require("../utils/logger");
|
|
10
|
+
/**
|
|
11
|
+
* 注册 docker.compose 命令
|
|
12
|
+
*/
|
|
13
|
+
function registerComposeCommand(ctx, getService, config) {
|
|
14
|
+
const useImageOutput = config?.imageOutput === true;
|
|
15
|
+
ctx
|
|
16
|
+
.command('docker.compose <node> <container>', '展示 Docker Compose 配置')
|
|
17
|
+
.alias('dockercompose', 'compose', 'docker-compose')
|
|
18
|
+
.option('download', '-d 直接发送 compose 文件(base64编码)')
|
|
19
|
+
.option('image', '-i 强制使用图片展示')
|
|
20
|
+
.option('text', '-t 强制使用文字展示')
|
|
21
|
+
.action(async ({ options, session }, nodeSelector, container) => {
|
|
22
|
+
const service = getService();
|
|
23
|
+
if (!service) {
|
|
24
|
+
return 'Docker 服务未初始化';
|
|
25
|
+
}
|
|
26
|
+
if (!nodeSelector || !container) {
|
|
27
|
+
return '请指定节点和容器: docker.compose <节点> <容器>';
|
|
28
|
+
}
|
|
29
|
+
// 获取节点
|
|
30
|
+
const nodes = service.getNodesBySelector(nodeSelector);
|
|
31
|
+
if (nodes.length === 0) {
|
|
32
|
+
return `未找到节点: ${nodeSelector}`;
|
|
33
|
+
}
|
|
34
|
+
const node = nodes[0];
|
|
35
|
+
if (node.status !== 'connected') {
|
|
36
|
+
return `节点 ${node.name} 未连接`;
|
|
37
|
+
}
|
|
38
|
+
// 查找容器
|
|
39
|
+
let containers;
|
|
40
|
+
try {
|
|
41
|
+
containers = await node.listContainers(true);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return `获取容器列表失败: ${e.message}`;
|
|
45
|
+
}
|
|
46
|
+
// 支持容器名称或 ID 前缀匹配
|
|
47
|
+
const targetContainer = containers.find(c => c.Names[0]?.replace('/', '') === container ||
|
|
48
|
+
c.Id.startsWith(container));
|
|
49
|
+
if (!targetContainer) {
|
|
50
|
+
return `未找到容器: ${container}`;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
// 获取 compose 文件信息
|
|
54
|
+
const composeInfo = await node.getComposeFileInfo(targetContainer.Id);
|
|
55
|
+
if (!composeInfo) {
|
|
56
|
+
return `容器 ${container} 不是由 Docker Compose 启动,或无法找到 compose 文件`;
|
|
57
|
+
}
|
|
58
|
+
// 如果指定了 -d 参数,作为文件发送
|
|
59
|
+
if (options.download) {
|
|
60
|
+
try {
|
|
61
|
+
const filename = `${composeInfo.projectName}-docker-compose.yaml`;
|
|
62
|
+
const buffer = Buffer.from(composeInfo.content, 'utf-8');
|
|
63
|
+
// 1. 构建 Data URI 字符串
|
|
64
|
+
// 使用 application/octet-stream 以避免适配器自作聪明改文件名
|
|
65
|
+
const dataUri = 'data:application/octet-stream;base64,' + buffer.toString('base64');
|
|
66
|
+
// === 方案 A: 优先尝试使用 assets 服务上传 ===
|
|
67
|
+
if (ctx.assets) {
|
|
68
|
+
try {
|
|
69
|
+
logger_1.connectorLogger.debug(`[compose] 正在通过 assets 上传文件: ${filename}`);
|
|
70
|
+
// 传入 Data URI 字符串
|
|
71
|
+
const url = await ctx.assets.upload(dataUri, filename);
|
|
72
|
+
logger_1.connectorLogger.info(`[compose] Assets 上传成功: ${url}`);
|
|
73
|
+
// 直接发送 URL,文件名由 assets 插件的 URL 决定
|
|
74
|
+
return koishi_1.h.file(url);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
logger_1.connectorLogger.warn(`[compose] Assets 上传失败,尝试降级: ${e.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// === 方案 B: 降级方案 ===
|
|
81
|
+
// 失败后直接发送 Data URI
|
|
82
|
+
logger_1.connectorLogger.debug(`[compose] 发送 DataURI (降级): filename=${filename}`);
|
|
83
|
+
return koishi_1.h.file(dataUri, {
|
|
84
|
+
filename: filename,
|
|
85
|
+
name: filename,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
logger_1.connectorLogger.error(`[compose] 生成文件失败: ${e.message}`);
|
|
90
|
+
return `生成文件失败: ${e.message}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// 判断使用图片还是文字展示
|
|
94
|
+
// -i 强制图片,-t 强制文字,都没指定则根据配置
|
|
95
|
+
const forceImage = options.image;
|
|
96
|
+
const forceText = options.text;
|
|
97
|
+
const shouldUseImage = forceImage || (!forceText && useImageOutput && ctx.puppeteer);
|
|
98
|
+
if (shouldUseImage) {
|
|
99
|
+
try {
|
|
100
|
+
const html = (0, render_1.generateComposeHtml)(node.name, container, composeInfo.projectName, composeInfo.originalPath, composeInfo.serviceCount, composeInfo.content);
|
|
101
|
+
return await (0, render_1.renderToImage)(ctx, html);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
// 如果图片渲染失败,回退到文字
|
|
105
|
+
if (e.message?.includes('puppeteer')) {
|
|
106
|
+
return [
|
|
107
|
+
`=== Docker Compose: ${composeInfo.projectName} ===`,
|
|
108
|
+
`节点: ${node.name}`,
|
|
109
|
+
`容器: ${container}`,
|
|
110
|
+
`文件路径: ${composeInfo.originalPath}`,
|
|
111
|
+
`服务数量: ${composeInfo.serviceCount}`,
|
|
112
|
+
'',
|
|
113
|
+
'--- compose.yaml ---',
|
|
114
|
+
'',
|
|
115
|
+
composeInfo.content,
|
|
116
|
+
].join('\n');
|
|
117
|
+
}
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// 文字展示
|
|
122
|
+
return [
|
|
123
|
+
`=== Docker Compose: ${composeInfo.projectName} ===`,
|
|
124
|
+
`节点: ${node.name}`,
|
|
125
|
+
`容器: ${container}`,
|
|
126
|
+
`文件路径: ${composeInfo.originalPath}`,
|
|
127
|
+
`服务数量: ${composeInfo.serviceCount}`,
|
|
128
|
+
'',
|
|
129
|
+
'--- compose.yaml ---',
|
|
130
|
+
'',
|
|
131
|
+
composeInfo.content,
|
|
132
|
+
].join('\n');
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
return `获取 compose 配置失败: ${e.message}`;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
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/commands/index.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.registerCommands = registerCommands;
|
|
|
4
4
|
const list_1 = require("./list");
|
|
5
5
|
const control_1 = require("./control");
|
|
6
6
|
const logs_1 = require("./logs");
|
|
7
|
+
const compose_1 = require("./compose");
|
|
7
8
|
const render_1 = require("../utils/render");
|
|
8
9
|
/**
|
|
9
10
|
* 注册所有指令
|
|
@@ -13,6 +14,7 @@ function registerCommands(ctx, getService, config) {
|
|
|
13
14
|
(0, list_1.registerListCommand)(ctx, getService, config);
|
|
14
15
|
(0, control_1.registerControlCommands)(ctx, getService, config);
|
|
15
16
|
(0, logs_1.registerLogsCommand)(ctx, getService, config);
|
|
17
|
+
(0, compose_1.registerComposeCommand)(ctx, getService, config);
|
|
16
18
|
// 注册辅助指令
|
|
17
19
|
registerHelperCommands(ctx, getService, config);
|
|
18
20
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -23,6 +23,9 @@ declare module 'koishi' {
|
|
|
23
23
|
puppeteer?: {
|
|
24
24
|
render: (html: string, callback?: (page: any, next: (handle?: any) => Promise<string>) => Promise<string>) => Promise<string>;
|
|
25
25
|
};
|
|
26
|
+
assets?: {
|
|
27
|
+
upload: (data: string | Buffer, filename: string) => Promise<string>;
|
|
28
|
+
};
|
|
26
29
|
}
|
|
27
30
|
interface Tables {
|
|
28
31
|
'docker_control_subscriptions': DockerControlSubscription;
|
package/lib/index.js
CHANGED
|
@@ -13,7 +13,7 @@ const commands_1 = require("./commands");
|
|
|
13
13
|
exports.name = 'docker-control';
|
|
14
14
|
exports.inject = {
|
|
15
15
|
required: ['database'],
|
|
16
|
-
optional: ['puppeteer'],
|
|
16
|
+
optional: ['puppeteer', 'assets'],
|
|
17
17
|
};
|
|
18
18
|
// 导出配置 Schema
|
|
19
19
|
exports.Config = koishi_1.Schema.object({
|
|
@@ -74,4 +74,23 @@ export declare class DockerConnector {
|
|
|
74
74
|
* 获取凭证配置
|
|
75
75
|
*/
|
|
76
76
|
private getCredential;
|
|
77
|
+
/**
|
|
78
|
+
* 读取文件内容 (支持 Windows 路径和 WSL 路径自动转换)
|
|
79
|
+
* @param filePath 文件路径 (可能是 Windows 路径如 C:\xxx 或 WSL 路径如 /mnt/c/xxx)
|
|
80
|
+
* @returns 文件内容
|
|
81
|
+
*/
|
|
82
|
+
readFile(filePath: string): Promise<string>;
|
|
83
|
+
/**
|
|
84
|
+
* 内部文件读取方法
|
|
85
|
+
*/
|
|
86
|
+
private readFileInternal;
|
|
87
|
+
/**
|
|
88
|
+
* 将 Windows 路径转换为 WSL 路径
|
|
89
|
+
* 例如: C:\Users\anyul\anyulapp\RSSHub\docker-compose.yml -> /mnt/c/Users/anyul/anyulapp/RSSHub/docker-compose.yml
|
|
90
|
+
*/
|
|
91
|
+
convertWindowsToWslPath(windowsPath: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* 检查文件是否存在
|
|
94
|
+
*/
|
|
95
|
+
fileExists(filePath: string): Promise<boolean>;
|
|
77
96
|
}
|
package/lib/service/connector.js
CHANGED
|
@@ -61,8 +61,10 @@ class DockerConnector {
|
|
|
61
61
|
catch (e) {
|
|
62
62
|
// 可能已经关闭,忽略错误
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// 非零退出码或包含错误信息时抛出异常
|
|
65
|
+
if (code !== 0 || stderr.includes('Error') || stderr.includes('error') || stderr.includes('No such file')) {
|
|
66
|
+
const errorMsg = stderr.trim() || `命令执行失败,退出码: ${code}`;
|
|
67
|
+
reject(new Error(errorMsg));
|
|
66
68
|
}
|
|
67
69
|
else {
|
|
68
70
|
resolve(stdout.trim());
|
|
@@ -300,5 +302,60 @@ class DockerConnector {
|
|
|
300
302
|
getCredential() {
|
|
301
303
|
return (0, config_1.getCredentialById)(this.fullConfig, this.config.credentialId);
|
|
302
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* 读取文件内容 (支持 Windows 路径和 WSL 路径自动转换)
|
|
307
|
+
* @param filePath 文件路径 (可能是 Windows 路径如 C:\xxx 或 WSL 路径如 /mnt/c/xxx)
|
|
308
|
+
* @returns 文件内容
|
|
309
|
+
*/
|
|
310
|
+
async readFile(filePath) {
|
|
311
|
+
// 检测是否是 Windows 路径 (包含盘符如 C:\)
|
|
312
|
+
const isWindowsPath = /^[A-Za-z]:/.test(filePath);
|
|
313
|
+
if (isWindowsPath) {
|
|
314
|
+
// 第一次尝试使用原始路径
|
|
315
|
+
try {
|
|
316
|
+
return await this.readFileInternal(filePath);
|
|
317
|
+
}
|
|
318
|
+
catch (originalError) {
|
|
319
|
+
// 如果失败,尝试转换为 WSL 路径
|
|
320
|
+
const wslPath = this.convertWindowsToWslPath(filePath);
|
|
321
|
+
logger_1.connectorLogger.debug(`[${this.config.name}] 原始路径失败 (${filePath}),尝试 WSL 路径: ${wslPath}`);
|
|
322
|
+
return await this.readFileInternal(wslPath);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// 非 Windows 路径直接读取
|
|
326
|
+
return await this.readFileInternal(filePath);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* 内部文件读取方法
|
|
330
|
+
*/
|
|
331
|
+
async readFileInternal(filePath) {
|
|
332
|
+
// 使用 cat 命令读取文件
|
|
333
|
+
// 对路径进行引号处理以支持包含空格的路径
|
|
334
|
+
const escapedPath = filePath.replace(/"/g, '\\"');
|
|
335
|
+
return this.exec(`cat "${escapedPath}"`);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* 将 Windows 路径转换为 WSL 路径
|
|
339
|
+
* 例如: C:\Users\anyul\anyulapp\RSSHub\docker-compose.yml -> /mnt/c/Users/anyul/anyulapp/RSSHub/docker-compose.yml
|
|
340
|
+
*/
|
|
341
|
+
convertWindowsToWslPath(windowsPath) {
|
|
342
|
+
// 匹配 Windows 盘符路径 (如 C:\xxx 或 C:/xxx)
|
|
343
|
+
const match = windowsPath.match(/^([A-Za-z]):[\\/](.*)$/);
|
|
344
|
+
if (!match) {
|
|
345
|
+
// 如果不是有效的 Windows 路径,返回原路径
|
|
346
|
+
return windowsPath;
|
|
347
|
+
}
|
|
348
|
+
const driveLetter = match[1].toLowerCase();
|
|
349
|
+
const restPath = match[2].replace(/\\/g, '/');
|
|
350
|
+
return `/mnt/${driveLetter}/${restPath}`;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* 检查文件是否存在
|
|
354
|
+
*/
|
|
355
|
+
async fileExists(filePath) {
|
|
356
|
+
const escapedPath = filePath.replace(/"/g, '\\"');
|
|
357
|
+
const result = await this.execWithExitCode(`test -f "${escapedPath}" && echo "exists" || echo "not exists"`);
|
|
358
|
+
return result.output.trim() === 'exists';
|
|
359
|
+
}
|
|
303
360
|
}
|
|
304
361
|
exports.DockerConnector = DockerConnector;
|
package/lib/service/node.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { NodeConfig, ContainerInfo, DockerEvent, NodeStatusType, CredentialConfig } from '../types';
|
|
1
|
+
import type { NodeConfig, ContainerInfo, DockerEvent, NodeStatusType, CredentialConfig, ComposeFileInfo, ContainerComposeInfo } from '../types';
|
|
2
2
|
export declare class DockerNode {
|
|
3
3
|
/** 节点配置 */
|
|
4
4
|
readonly config: NodeConfig;
|
|
@@ -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
|
*/
|
|
@@ -94,10 +78,76 @@ export declare class DockerNode {
|
|
|
94
78
|
* 获取镜像数量
|
|
95
79
|
*/
|
|
96
80
|
getImageCount(): Promise<number>;
|
|
81
|
+
/**
|
|
82
|
+
* 获取容器的 Docker Compose 信息
|
|
83
|
+
* 通过标签 com.docker.compose.project.config_files 获取 compose 文件路径
|
|
84
|
+
*/
|
|
85
|
+
getContainerComposeInfo(containerId: string): Promise<ContainerComposeInfo | null>;
|
|
86
|
+
/**
|
|
87
|
+
* 获取容器的 Docker Compose 文件信息
|
|
88
|
+
* 读取并解析 compose 文件
|
|
89
|
+
*/
|
|
90
|
+
getComposeFileInfo(containerId: string): Promise<ComposeFileInfo | null>;
|
|
91
|
+
/**
|
|
92
|
+
* 统计 compose 文件中的服务数量
|
|
93
|
+
*/
|
|
94
|
+
private countServices;
|
|
97
95
|
/**
|
|
98
96
|
* 获取容器详细信息 (docker inspect)
|
|
99
97
|
*/
|
|
100
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[]>;
|
|
101
151
|
/**
|
|
102
152
|
* 解析 docker ps 输出
|
|
103
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
|
*/
|
|
@@ -185,9 +155,15 @@ class DockerNode {
|
|
|
185
155
|
if (!this.connector)
|
|
186
156
|
return null;
|
|
187
157
|
try {
|
|
188
|
-
//
|
|
189
|
-
const
|
|
190
|
-
|
|
158
|
+
// 使用 execWithExitCode 避免非零退出码抛出异常
|
|
159
|
+
const result = await this.connector.execWithExitCode('docker info --format "{{.NCPU}} {{.MemTotal}} {{.MemAvailable}}"');
|
|
160
|
+
logger_1.nodeLogger.debug(`[${this.name}] docker info 输出: "${result.output}", 退出码: ${result.exitCode}`);
|
|
161
|
+
// docker info 可能返回退出码 1 但仍有输出(权限问题),只要有输出就解析
|
|
162
|
+
if (!result.output.trim()) {
|
|
163
|
+
logger_1.nodeLogger.warn(`[${this.name}] docker info 输出为空`);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const parts = result.output.trim().split(/\s+/);
|
|
191
167
|
if (parts.length >= 2) {
|
|
192
168
|
return {
|
|
193
169
|
NCPU: parseInt(parts[0]) || 0,
|
|
@@ -198,6 +174,7 @@ class DockerNode {
|
|
|
198
174
|
return null;
|
|
199
175
|
}
|
|
200
176
|
catch (e) {
|
|
177
|
+
logger_1.nodeLogger.warn(`[${this.name}] 获取系统信息异常: ${e}`);
|
|
201
178
|
return null;
|
|
202
179
|
}
|
|
203
180
|
}
|
|
@@ -233,6 +210,79 @@ class DockerNode {
|
|
|
233
210
|
return 0;
|
|
234
211
|
}
|
|
235
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* 获取容器的 Docker Compose 信息
|
|
215
|
+
* 通过标签 com.docker.compose.project.config_files 获取 compose 文件路径
|
|
216
|
+
*/
|
|
217
|
+
async getContainerComposeInfo(containerId) {
|
|
218
|
+
if (!this.connector)
|
|
219
|
+
throw new Error('未连接');
|
|
220
|
+
try {
|
|
221
|
+
// 使用 docker inspect 获取容器标签
|
|
222
|
+
const output = await this.connector.exec(`docker inspect ${containerId} --format "{{json .Config.Labels}}"`);
|
|
223
|
+
if (!output.trim()) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const labels = JSON.parse(output);
|
|
227
|
+
// 获取 compose 项目名称和配置文件路径
|
|
228
|
+
const projectName = labels['com.docker.compose.project'] || '';
|
|
229
|
+
const configFiles = labels['com.docker.compose.project.config_files'] || '';
|
|
230
|
+
if (!projectName || !configFiles) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
containerId,
|
|
235
|
+
containerName: labels['com.docker.compose.container-number'] || '',
|
|
236
|
+
projectName,
|
|
237
|
+
composeFilePath: configFiles,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
logger_1.nodeLogger.warn(`[${this.name}] 获取容器 ${containerId} 的 compose 信息失败: ${e}`);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 获取容器的 Docker Compose 文件信息
|
|
247
|
+
* 读取并解析 compose 文件
|
|
248
|
+
*/
|
|
249
|
+
async getComposeFileInfo(containerId) {
|
|
250
|
+
if (!this.connector)
|
|
251
|
+
throw new Error('未连接');
|
|
252
|
+
try {
|
|
253
|
+
const composeInfo = await this.getContainerComposeInfo(containerId);
|
|
254
|
+
if (!composeInfo) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const filePath = composeInfo.composeFilePath;
|
|
258
|
+
const originalPath = filePath;
|
|
259
|
+
// 尝试读取文件 (支持 Windows 路径自动转换)
|
|
260
|
+
const content = await this.connector.readFile(filePath);
|
|
261
|
+
// 统计服务数量 (简单的 yaml 解析)
|
|
262
|
+
const serviceCount = this.countServices(content);
|
|
263
|
+
return {
|
|
264
|
+
originalPath,
|
|
265
|
+
effectivePath: filePath,
|
|
266
|
+
usedWslPath: false, // 实际是否使用了 WSL 路径在内部处理
|
|
267
|
+
content,
|
|
268
|
+
projectName: composeInfo.projectName,
|
|
269
|
+
serviceCount,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
logger_1.nodeLogger.warn(`[${this.name}] 获取 compose 文件信息失败: ${e.message}`);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* 统计 compose 文件中的服务数量
|
|
279
|
+
*/
|
|
280
|
+
countServices(content) {
|
|
281
|
+
// 简单的正则匹配 services: 下面的服务名
|
|
282
|
+
const servicePattern = /^[a-zA-Z0-9_-]+:\s*$/gm;
|
|
283
|
+
const matches = content.match(servicePattern);
|
|
284
|
+
return matches ? matches.length : 0;
|
|
285
|
+
}
|
|
236
286
|
/**
|
|
237
287
|
* 获取容器详细信息 (docker inspect)
|
|
238
288
|
*/
|
|
@@ -243,6 +293,362 @@ class DockerNode {
|
|
|
243
293
|
const info = JSON.parse(output);
|
|
244
294
|
return Array.isArray(info) ? info[0] : info;
|
|
245
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
|
+
}
|
|
246
652
|
/**
|
|
247
653
|
* 解析 docker ps 输出
|
|
248
654
|
*/
|
|
@@ -593,3 +999,14 @@ class DockerNode {
|
|
|
593
999
|
get tags() { return this.config.tags; }
|
|
594
1000
|
}
|
|
595
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/types.d.ts
CHANGED
|
@@ -98,3 +98,27 @@ export interface SubscriptionConfig {
|
|
|
98
98
|
/** 创建时间 */
|
|
99
99
|
createdAt?: number;
|
|
100
100
|
}
|
|
101
|
+
export interface ComposeFileInfo {
|
|
102
|
+
/** 原始路径 (Windows 路径或 WSL 路径) */
|
|
103
|
+
originalPath: string;
|
|
104
|
+
/** 实际使用的路径 (尝试转换后的路径) */
|
|
105
|
+
effectivePath: string;
|
|
106
|
+
/** 是否使用了 WSL 路径转换 */
|
|
107
|
+
usedWslPath: boolean;
|
|
108
|
+
/** Compose 文件内容 */
|
|
109
|
+
content: string;
|
|
110
|
+
/** 所属项目名称 */
|
|
111
|
+
projectName: string;
|
|
112
|
+
/** 容器数量 */
|
|
113
|
+
serviceCount: number;
|
|
114
|
+
}
|
|
115
|
+
export interface ContainerComposeInfo {
|
|
116
|
+
/** 容器 ID */
|
|
117
|
+
containerId: string;
|
|
118
|
+
/** 容器名称 */
|
|
119
|
+
containerName: string;
|
|
120
|
+
/** 所属 Docker Compose 项目 */
|
|
121
|
+
projectName: string;
|
|
122
|
+
/** Compose 文件路径 */
|
|
123
|
+
composeFilePath: string;
|
|
124
|
+
}
|
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
|
*/
|
|
@@ -45,4 +55,8 @@ export declare function generateLogsHtml(nodeName: string, containerName: string
|
|
|
45
55
|
* 生成执行结果 HTML
|
|
46
56
|
*/
|
|
47
57
|
export declare function generateExecHtml(nodeName: string, containerName: string, command: string, output: string, exitCode: number): string;
|
|
58
|
+
/**
|
|
59
|
+
* 生成 Docker Compose 配置 HTML
|
|
60
|
+
*/
|
|
61
|
+
export declare function generateComposeHtml(nodeName: string, containerName: string, projectName: string, filePath: string, serviceCount: number, composeContent: string): string;
|
|
48
62
|
export {};
|
package/lib/utils/render.js
CHANGED
|
@@ -8,6 +8,7 @@ exports.generateNodesHtml = generateNodesHtml;
|
|
|
8
8
|
exports.generateNodeDetailHtml = generateNodeDetailHtml;
|
|
9
9
|
exports.generateLogsHtml = generateLogsHtml;
|
|
10
10
|
exports.generateExecHtml = generateExecHtml;
|
|
11
|
+
exports.generateComposeHtml = generateComposeHtml;
|
|
11
12
|
const koishi_1 = require("koishi");
|
|
12
13
|
// 基础样式
|
|
13
14
|
const STYLE = `
|
|
@@ -299,7 +300,7 @@ function generateResultHtml(results, title) {
|
|
|
299
300
|
/**
|
|
300
301
|
* 生成详情 HTML
|
|
301
302
|
*/
|
|
302
|
-
function generateInspectHtml(nodeName, info) {
|
|
303
|
+
function generateInspectHtml(nodeName, info, stats, ports) {
|
|
303
304
|
const name = info.Name.replace('/', '');
|
|
304
305
|
const shortId = info.Id.slice(0, 12);
|
|
305
306
|
const isRunning = info.State.Running;
|
|
@@ -334,6 +335,67 @@ function generateInspectHtml(nodeName, info) {
|
|
|
334
335
|
return ` ${mount.Source} → ${mount.Destination} (${mount.Type})`;
|
|
335
336
|
}).join('\n')
|
|
336
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>';
|
|
337
399
|
const items = [
|
|
338
400
|
{ label: '容器名称', value: name, span: false },
|
|
339
401
|
{ label: '容器 ID', value: info.Id, span: false },
|
|
@@ -343,6 +405,8 @@ function generateInspectHtml(nodeName, info) {
|
|
|
343
405
|
{ label: '启动时间', value: new Date(info.State.StartedAt).toLocaleString(), span: false },
|
|
344
406
|
{ label: '重启策略', value: restartDisplay, span: false },
|
|
345
407
|
{ label: '重启次数', value: String(info.RestartCount), span: false },
|
|
408
|
+
{ label: '性能监控', value: statsDisplay, span: true, isHtml: true },
|
|
409
|
+
{ label: '端口映射', value: portsDisplay, span: true },
|
|
346
410
|
{ label: '网络', value: networkInfo, span: true },
|
|
347
411
|
{ label: '环境变量', value: envDisplay, span: true },
|
|
348
412
|
{ label: '挂载目录', value: mountsDisplay, span: true },
|
|
@@ -353,7 +417,7 @@ function generateInspectHtml(nodeName, info) {
|
|
|
353
417
|
const gridItems = items.map(item => `
|
|
354
418
|
<div class="detail-item ${item.span ? 'detail-span' : ''}">
|
|
355
419
|
<div class="detail-label">${item.label}</div>
|
|
356
|
-
<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>
|
|
357
421
|
</div>
|
|
358
422
|
`).join('');
|
|
359
423
|
const header = `
|
|
@@ -380,6 +444,49 @@ function generateInspectHtml(nodeName, info) {
|
|
|
380
444
|
`;
|
|
381
445
|
return wrapHtml(header + body);
|
|
382
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
|
+
}
|
|
383
490
|
/**
|
|
384
491
|
* 生成节点列表 HTML
|
|
385
492
|
*/
|
|
@@ -680,3 +787,80 @@ function generateExecHtml(nodeName, containerName, command, output, exitCode) {
|
|
|
680
787
|
`;
|
|
681
788
|
return wrapHtml(header + body);
|
|
682
789
|
}
|
|
790
|
+
/**
|
|
791
|
+
* 生成 Docker Compose 配置 HTML
|
|
792
|
+
*/
|
|
793
|
+
function generateComposeHtml(nodeName, containerName, projectName, filePath, serviceCount, composeContent) {
|
|
794
|
+
// 对内容进行语法高亮
|
|
795
|
+
const highlightedContent = highlightYaml(composeContent);
|
|
796
|
+
const header = `
|
|
797
|
+
<div class="header">
|
|
798
|
+
<div class="header-title">Docker Compose</div>
|
|
799
|
+
<div class="header-badge">${nodeName}/${containerName}</div>
|
|
800
|
+
</div>
|
|
801
|
+
`;
|
|
802
|
+
const body = `
|
|
803
|
+
<div class="content">
|
|
804
|
+
<div class="detail-card" style="margin-bottom: 20px;">
|
|
805
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">
|
|
806
|
+
<div class="detail-item">
|
|
807
|
+
<div class="detail-label">项目名称</div>
|
|
808
|
+
<div class="detail-value highlight">${projectName}</div>
|
|
809
|
+
</div>
|
|
810
|
+
<div class="detail-item">
|
|
811
|
+
<div class="detail-label">服务数量</div>
|
|
812
|
+
<div class="detail-value">${serviceCount} 个</div>
|
|
813
|
+
</div>
|
|
814
|
+
<div class="detail-item" style="grid-column: 1 / -1;">
|
|
815
|
+
<div class="detail-label">文件路径</div>
|
|
816
|
+
<div class="detail-value" style="font-size: 13px;">${filePath}</div>
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
|
|
821
|
+
<div style="
|
|
822
|
+
background: rgba(0, 0, 0, 0.3);
|
|
823
|
+
border-radius: 8px;
|
|
824
|
+
padding: 16px;
|
|
825
|
+
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
|
826
|
+
font-size: 12px;
|
|
827
|
+
line-height: 1.6;
|
|
828
|
+
white-space: pre-wrap;
|
|
829
|
+
word-break: break-all;
|
|
830
|
+
">${highlightedContent}</div>
|
|
831
|
+
</div>
|
|
832
|
+
`;
|
|
833
|
+
// 添加 YAML 高亮样式
|
|
834
|
+
const yamlStyle = `
|
|
835
|
+
.yaml-key { color: #60a5fa; }
|
|
836
|
+
.yaml-string { color: #a5f3fc; }
|
|
837
|
+
.yaml-number { color: #f472b6; }
|
|
838
|
+
.yaml-boolean { color: #fbbf24; }
|
|
839
|
+
.yaml-null { color: #94a3b8; }
|
|
840
|
+
.yaml-comment { color: #64748b; font-style: italic; }
|
|
841
|
+
.yaml-bracket { color: #f87171; }
|
|
842
|
+
`;
|
|
843
|
+
return wrapHtml(header + body, STYLE + yamlStyle);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* 简单的 YAML 语法高亮
|
|
847
|
+
*/
|
|
848
|
+
function highlightYaml(content) {
|
|
849
|
+
// HTML 转义
|
|
850
|
+
let html = escapeHtml(content);
|
|
851
|
+
// 高亮键名 (冒号前的单词)
|
|
852
|
+
html = html.replace(/^([a-zA-Z0-9_-]+):(\s*)$/gm, '<span class="yaml-key">$1</span>:<br>');
|
|
853
|
+
// 高亮带引号的字符串
|
|
854
|
+
html = html.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '<span class="yaml-string">$1</span>');
|
|
855
|
+
// 高亮数字
|
|
856
|
+
html = html.replace(/\b(\d+\.?\d*)\b/g, '<span class="yaml-number">$1</span>');
|
|
857
|
+
// 高亮布尔值
|
|
858
|
+
html = html.replace(/\b(true|false|yes|no|on|off)\b/gi, '<span class="yaml-boolean">$1</span>');
|
|
859
|
+
// 高亮 null
|
|
860
|
+
html = html.replace(/\bnull\b/gi, '<span class="yaml-null">null</span>');
|
|
861
|
+
// 高亮注释
|
|
862
|
+
html = html.replace(/#.*$/gm, '<span class="yaml-comment">$&</span>');
|
|
863
|
+
// 高亮括号
|
|
864
|
+
html = html.replace(/([\[\]{}()])/g, '<span class="yaml-bracket">$1</span>');
|
|
865
|
+
return html;
|
|
866
|
+
}
|
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",
|