koishi-plugin-docker-control 0.0.3 → 0.0.4
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 +6 -5
- package/lib/commands/index.js +35 -57
- package/lib/commands/list.js +10 -2
- package/lib/commands/logs.d.ts +1 -1
- package/lib/commands/logs.js +29 -39
- package/lib/service/connector.d.ts +11 -1
- package/lib/service/connector.js +38 -1
- package/lib/service/index.d.ts +7 -0
- package/lib/service/index.js +17 -0
- package/lib/service/node.d.ts +24 -1
- package/lib/service/node.js +71 -11
- package/lib/utils/render.d.ts +9 -1
- package/lib/utils/render.js +313 -47
- package/package.json +1 -1
package/lib/commands/control.js
CHANGED
|
@@ -219,12 +219,13 @@ function registerControlCommands(ctx, getService, config) {
|
|
|
219
219
|
logger_1.commandLogger.info(`[${selector}] 执行命令: "${cmd}"`);
|
|
220
220
|
try {
|
|
221
221
|
const { node, container: found } = await service.findContainer(selector, container);
|
|
222
|
-
const result = await node.execContainer(found.Id,
|
|
223
|
-
'/bin/sh',
|
|
224
|
-
'-c',
|
|
225
|
-
cmd,
|
|
226
|
-
]);
|
|
222
|
+
const result = await node.execContainer(found.Id, cmd);
|
|
227
223
|
const name = found.Names[0]?.replace('/', '') || found.Id.slice(0, 8);
|
|
224
|
+
// 图片渲染模式
|
|
225
|
+
if (useImageOutput && ctx.puppeteer) {
|
|
226
|
+
const html = (0, render_1.generateExecHtml)(node.name, name, cmd, result.output, result.exitCode);
|
|
227
|
+
return await (0, render_1.renderToImage)(ctx, html);
|
|
228
|
+
}
|
|
228
229
|
if (result.output.trim()) {
|
|
229
230
|
return `=== ${node.name}: ${name} ===\n${result.output}`;
|
|
230
231
|
}
|
package/lib/commands/index.js
CHANGED
|
@@ -68,16 +68,36 @@ function registerHelperCommands(ctx, getService, config) {
|
|
|
68
68
|
}
|
|
69
69
|
const node = nodes[0];
|
|
70
70
|
try {
|
|
71
|
-
const version = await
|
|
71
|
+
const [version, systemInfo, containerCount, imageCount] = await Promise.all([
|
|
72
|
+
node.getVersion(),
|
|
73
|
+
node.getSystemInfo(),
|
|
74
|
+
node.getContainerCount(),
|
|
75
|
+
node.getImageCount(),
|
|
76
|
+
]);
|
|
77
|
+
// 将容器和镜像数量添加到节点对象
|
|
78
|
+
const nodeData = {
|
|
79
|
+
...node,
|
|
80
|
+
containerCount: containerCount.total,
|
|
81
|
+
imageCount: imageCount,
|
|
82
|
+
};
|
|
72
83
|
if (useImageOutput && ctx.puppeteer) {
|
|
73
|
-
const html = (0, render_1.generateNodeDetailHtml)(
|
|
84
|
+
const html = (0, render_1.generateNodeDetailHtml)(nodeData, version, systemInfo);
|
|
74
85
|
return await (0, render_1.renderToImage)(ctx, html);
|
|
75
86
|
}
|
|
87
|
+
const memoryUsed = systemInfo?.MemTotal && systemInfo?.MemAvailable !== undefined
|
|
88
|
+
? `${Math.round((1 - systemInfo.MemAvailable / systemInfo.MemTotal) * 100)}%`
|
|
89
|
+
: '-';
|
|
90
|
+
const nodeName = node.config?.name || node.name || node.Name || 'Unknown';
|
|
91
|
+
const nodeId = node.id || node.ID || node.Id || node.config?.id || '-';
|
|
76
92
|
const lines = [
|
|
77
|
-
`=== ${
|
|
78
|
-
`ID: ${
|
|
79
|
-
`状态: ${node.status}`,
|
|
80
|
-
`标签: ${node.tags.join(', ') || '无'}`,
|
|
93
|
+
`=== ${nodeName} ===`,
|
|
94
|
+
`ID: ${nodeId}`,
|
|
95
|
+
`状态: ${node.status || node.Status || 'unknown'}`,
|
|
96
|
+
`标签: ${node.tags?.join(', ') || node.config?.tags?.join(', ') || '无'}`,
|
|
97
|
+
`CPU: ${systemInfo?.NCPU || '-'} 核心`,
|
|
98
|
+
`内存: ${memoryUsed} (可用: ${systemInfo?.MemAvailable ? Math.round(systemInfo.MemAvailable / 1024 / 1024) + ' MB' : '-'})`,
|
|
99
|
+
`容器: ${containerCount.running}/${containerCount.total} 运行中`,
|
|
100
|
+
`镜像: ${imageCount} 个`,
|
|
81
101
|
`Docker 版本: ${version.Version}`,
|
|
82
102
|
`API 版本: ${version.ApiVersion}`,
|
|
83
103
|
`操作系统: ${version.Os} (${version.Arch})`,
|
|
@@ -146,7 +166,7 @@ function registerHelperCommands(ctx, getService, config) {
|
|
|
146
166
|
if (c.State !== 'running') {
|
|
147
167
|
return `容器 ${container} 未运行`;
|
|
148
168
|
}
|
|
149
|
-
const result = await node.execContainer(c.Id, cmd
|
|
169
|
+
const result = await node.execContainer(c.Id, cmd);
|
|
150
170
|
return [
|
|
151
171
|
`=== 执行结果 ===`,
|
|
152
172
|
`退出码: ${result.exitCode}`,
|
|
@@ -158,47 +178,6 @@ function registerHelperCommands(ctx, getService, config) {
|
|
|
158
178
|
return `执行失败: ${e.message}`;
|
|
159
179
|
}
|
|
160
180
|
});
|
|
161
|
-
/**
|
|
162
|
-
* 交互式执行 (返回结果,不支持实时交互)
|
|
163
|
-
*/
|
|
164
|
-
ctx
|
|
165
|
-
.command('docker.shell <container> <cmd>', '在容器中执行命令(交互式)')
|
|
166
|
-
.alias('dockershell', '容器shell')
|
|
167
|
-
.option('node', '-n <node> 指定节点', { fallback: '' })
|
|
168
|
-
.option('timeout', '-t <seconds> 超时时间', { fallback: 30 })
|
|
169
|
-
.action(async ({ options }, container, cmd) => {
|
|
170
|
-
const service = getService();
|
|
171
|
-
if (!service) {
|
|
172
|
-
return 'Docker 服务未初始化';
|
|
173
|
-
}
|
|
174
|
-
const nodeSelector = options.node || 'all';
|
|
175
|
-
try {
|
|
176
|
-
const nodes = service.getNodesBySelector(nodeSelector);
|
|
177
|
-
if (nodes.length === 0) {
|
|
178
|
-
return `未找到节点: ${nodeSelector}`;
|
|
179
|
-
}
|
|
180
|
-
const results = await service.findContainerGlobal(container);
|
|
181
|
-
if (results.length === 0) {
|
|
182
|
-
return `未找到容器: ${container}`;
|
|
183
|
-
}
|
|
184
|
-
const { node, container: c } = results[0];
|
|
185
|
-
if (c.State !== 'running') {
|
|
186
|
-
return `容器 ${container} 未运行`;
|
|
187
|
-
}
|
|
188
|
-
const result = await node.execContainer(c.Id, cmd.split(' '));
|
|
189
|
-
return [
|
|
190
|
-
`=== ${node.name}/${c.Names[0]?.replace('/', '') || c.Id.slice(0, 8)} ===`,
|
|
191
|
-
`> ${cmd}`,
|
|
192
|
-
``,
|
|
193
|
-
result.output || '(无输出)',
|
|
194
|
-
``,
|
|
195
|
-
`[退出码: ${result.exitCode}]`,
|
|
196
|
-
].join('\n');
|
|
197
|
-
}
|
|
198
|
-
catch (e) {
|
|
199
|
-
return `执行失败: ${e.message}`;
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
181
|
/**
|
|
203
182
|
* 查看帮助
|
|
204
183
|
*/
|
|
@@ -210,15 +189,14 @@ function registerHelperCommands(ctx, getService, config) {
|
|
|
210
189
|
' docker.nodes - 查看节点列表',
|
|
211
190
|
' docker.node <节点> - 查看节点详情',
|
|
212
191
|
'',
|
|
213
|
-
'【容器操作】',
|
|
214
|
-
' docker.ls
|
|
215
|
-
' docker.start <容器>
|
|
216
|
-
' docker.stop <容器>
|
|
217
|
-
' docker.restart <容器>
|
|
218
|
-
' docker.logs <容器> [-
|
|
219
|
-
' docker.
|
|
220
|
-
' docker.exec <容器> <命令>
|
|
221
|
-
' docker.shell <容器> <命令> - 交互式执行',
|
|
192
|
+
'【容器操作】(参数顺序: 节点 容器)',
|
|
193
|
+
' docker.ls <节点> - 列出容器',
|
|
194
|
+
' docker.start <节点> <容器> - 启动容器',
|
|
195
|
+
' docker.stop <节点> <容器> - 停止容器',
|
|
196
|
+
' docker.restart <节点> <容器> - 重启容器',
|
|
197
|
+
' docker.logs <节点> <容器> [-n 行数] - 查看日志',
|
|
198
|
+
' docker.inspect <节点> <容器> - 查看容器详情',
|
|
199
|
+
' docker.exec <节点> <容器> <命令> - 在容器内执行命令',
|
|
222
200
|
'',
|
|
223
201
|
'【节点选择器】',
|
|
224
202
|
' all - 所有节点',
|
package/lib/commands/list.js
CHANGED
|
@@ -30,6 +30,10 @@ function registerListCommand(ctx, getService, config) {
|
|
|
30
30
|
if (!ctx.puppeteer) {
|
|
31
31
|
return '错误: 未安装 koishi-plugin-puppeteer 插件,无法使用图片渲染';
|
|
32
32
|
}
|
|
33
|
+
// 如果未指定节点,提示用户
|
|
34
|
+
if (!selector) {
|
|
35
|
+
return '请指定节点名称、ID 或标签,或使用 "all" 列出全部容器\n例如: docker.ls @web -f image 或 docker.ls all -f image';
|
|
36
|
+
}
|
|
33
37
|
try {
|
|
34
38
|
// 获取容器数据
|
|
35
39
|
logger_1.commandLogger.debug('获取容器数据...');
|
|
@@ -39,7 +43,7 @@ function registerListCommand(ctx, getService, config) {
|
|
|
39
43
|
return '未发现任何容器';
|
|
40
44
|
}
|
|
41
45
|
// 生成并渲染
|
|
42
|
-
const html = (0, render_1.generateListHtml)(results,
|
|
46
|
+
const html = (0, render_1.generateListHtml)(results, `容器列表 (${selector})`);
|
|
43
47
|
return await (0, render_1.renderToImage)(ctx, html);
|
|
44
48
|
}
|
|
45
49
|
catch (e) {
|
|
@@ -49,9 +53,13 @@ function registerListCommand(ctx, getService, config) {
|
|
|
49
53
|
}
|
|
50
54
|
// 文字模式
|
|
51
55
|
try {
|
|
56
|
+
// 如果未指定节点,提示用户
|
|
57
|
+
if (!selector) {
|
|
58
|
+
return '请指定节点名称、ID 或标签,或使用 "all" 列出全部容器\n例如: docker.ls @web 或 docker.ls all';
|
|
59
|
+
}
|
|
52
60
|
const results = await getContainerResults(service, selector, all);
|
|
53
61
|
if (results.length === 0) {
|
|
54
|
-
return
|
|
62
|
+
return '所有指定节点均未连接';
|
|
55
63
|
}
|
|
56
64
|
const lines = [];
|
|
57
65
|
for (const { node, containers } of results) {
|
package/lib/commands/logs.d.ts
CHANGED
package/lib/commands/logs.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.registerLogsCommand = registerLogsCommand;
|
|
4
4
|
const logger_1 = require("../utils/logger");
|
|
5
|
+
const render_1 = require("../utils/render");
|
|
5
6
|
/**
|
|
6
7
|
* 获取容器名称
|
|
7
8
|
*/
|
|
@@ -12,74 +13,63 @@ function getContainerName(c) {
|
|
|
12
13
|
* 注册日志指令
|
|
13
14
|
*/
|
|
14
15
|
function registerLogsCommand(ctx, getService, config) {
|
|
16
|
+
const useImageOutput = config?.imageOutput === true;
|
|
15
17
|
ctx
|
|
16
|
-
.command('docker.logs <container>
|
|
18
|
+
.command('docker.logs <node> <container>', '查看容器日志')
|
|
17
19
|
.alias('docker日志', '容器日志', 'docker查看日志', '容器查看日志', 'dockerlogs')
|
|
18
20
|
.option('lines', '-n <lines:number> 显示最后 N 行')
|
|
19
21
|
.option('timestamp', '-t 显示时间戳')
|
|
20
|
-
.
|
|
21
|
-
|
|
22
|
+
.option('all', '-a 显示全部(不截断)')
|
|
23
|
+
.action(async ({ options }, node, container) => {
|
|
24
|
+
logger_1.commandLogger.debug(`docker.logs 被调用: node=${node}, container=${container}, lines=${options.lines}, timestamp=${options.timestamp}`);
|
|
22
25
|
const service = getService();
|
|
23
26
|
if (!service) {
|
|
24
27
|
logger_1.commandLogger.debug('服务未初始化');
|
|
25
28
|
return 'Docker 服务未初始化';
|
|
26
29
|
}
|
|
27
30
|
// 参数校验
|
|
28
|
-
if (!container) {
|
|
29
|
-
return '
|
|
31
|
+
if (!node || !container) {
|
|
32
|
+
return '请指定节点和容器\n用法示例:\n docker.logs @web my-app -n 50\n docker.logs all my-app';
|
|
30
33
|
}
|
|
31
34
|
// 确定日志行数 (优先级: 命令行参数 > 全局配置 > 默认值)
|
|
32
35
|
const tail = options.lines || config?.defaultLogLines || 100;
|
|
33
36
|
const showTimestamp = options.timestamp || false;
|
|
34
|
-
|
|
37
|
+
const showAll = options.all || false;
|
|
38
|
+
logger_1.commandLogger.debug(`日志参数: tail=${tail}, showTimestamp=${showTimestamp}, showAll=${showAll}`);
|
|
35
39
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const nodes = service.getNodesBySelector(node);
|
|
42
|
-
if (nodes.length === 0) {
|
|
43
|
-
logger_1.commandLogger.debug(`找不到节点: ${node}`);
|
|
44
|
-
return `❌ 找不到节点: ${node}`;
|
|
45
|
-
}
|
|
46
|
-
targetNode = nodes[0];
|
|
47
|
-
logger_1.commandLogger.debug(`在节点 ${targetNode.name} 中查找容器...`);
|
|
48
|
-
const result = await service.findContainer(targetNode.id, container);
|
|
49
|
-
containerInfo = result.container;
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
// 全局模糊查找
|
|
53
|
-
logger_1.commandLogger.debug(`全局搜索容器: ${container}`);
|
|
54
|
-
const results = await service.findContainerGlobal(container);
|
|
55
|
-
if (results.length === 0) {
|
|
56
|
-
logger_1.commandLogger.debug(`找不到容器: ${container}`);
|
|
57
|
-
return `❌ 找不到容器: ${container}`;
|
|
58
|
-
}
|
|
59
|
-
// 优先返回 Running 的,如果没有则返回第一个
|
|
60
|
-
const running = results.find(r => r.container.State === 'running');
|
|
61
|
-
const target = running || results[0];
|
|
62
|
-
targetNode = target.node;
|
|
63
|
-
containerInfo = target.container;
|
|
40
|
+
// 查找节点
|
|
41
|
+
const nodes = service.getNodesBySelector(node);
|
|
42
|
+
if (nodes.length === 0) {
|
|
43
|
+
logger_1.commandLogger.debug(`找不到节点: ${node}`);
|
|
44
|
+
return `找不到节点: ${node}`;
|
|
64
45
|
}
|
|
46
|
+
// 在节点上查找容器
|
|
47
|
+
const { node: targetNode, container: containerInfo } = await service.findContainer(nodes[0].id, container);
|
|
65
48
|
if (!targetNode || !containerInfo) {
|
|
66
|
-
return '
|
|
49
|
+
return '未能获取容器信息';
|
|
67
50
|
}
|
|
68
51
|
// 获取日志
|
|
69
|
-
const logs = await targetNode.getContainerLogs(containerInfo.Id, tail);
|
|
52
|
+
const logs = await targetNode.getContainerLogs(containerInfo.Id, showAll ? 10000 : tail);
|
|
70
53
|
if (!logs || !logs.trim()) {
|
|
71
54
|
return `${targetNode.name}: ${getContainerName(containerInfo)} - 无日志`;
|
|
72
55
|
}
|
|
73
56
|
// 格式化输出
|
|
74
57
|
const lines = logs.split('\n').filter(l => l.length > 0);
|
|
75
|
-
const displayLines = lines.length > tail ? lines.slice(-tail) : lines;
|
|
58
|
+
const displayLines = !showAll && lines.length > tail ? lines.slice(-tail) : lines;
|
|
76
59
|
// 移除 ANSI 颜色代码
|
|
77
60
|
const cleanLogs = displayLines.map(line => line.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')).join('\n');
|
|
78
|
-
|
|
61
|
+
// 图片渲染模式
|
|
62
|
+
if (useImageOutput && ctx.puppeteer && !showAll) {
|
|
63
|
+
const html = (0, render_1.generateLogsHtml)(targetNode.name, getContainerName(containerInfo), cleanLogs, displayLines.length);
|
|
64
|
+
// 根据行数动态计算高度:header 80px + header 80px + 每行 25px + padding
|
|
65
|
+
const estimatedHeight = 200 + displayLines.length * 25;
|
|
66
|
+
return await (0, render_1.renderToImage)(ctx, html, { height: Math.max(estimatedHeight, 800) });
|
|
67
|
+
}
|
|
68
|
+
return `=== ${targetNode.name}: ${getContainerName(containerInfo)} (${showAll ? '全部' : `最后 ${displayLines.length} 行`}) ===\n${cleanLogs}`;
|
|
79
69
|
}
|
|
80
70
|
catch (e) {
|
|
81
71
|
logger_1.commandLogger.error(e);
|
|
82
|
-
return
|
|
72
|
+
return `获取日志失败: ${e.message}`;
|
|
83
73
|
}
|
|
84
74
|
});
|
|
85
75
|
}
|
|
@@ -9,6 +9,13 @@ export declare class DockerConnector {
|
|
|
9
9
|
*/
|
|
10
10
|
exec(command: string): Promise<string>;
|
|
11
11
|
private execInternal;
|
|
12
|
+
/**
|
|
13
|
+
* 执行命令并返回输出和退出码
|
|
14
|
+
*/
|
|
15
|
+
execWithExitCode(command: string): Promise<{
|
|
16
|
+
output: string;
|
|
17
|
+
exitCode: number;
|
|
18
|
+
}>;
|
|
12
19
|
/**
|
|
13
20
|
* 执行 docker ps 获取容器列表
|
|
14
21
|
*/
|
|
@@ -32,7 +39,10 @@ export declare class DockerConnector {
|
|
|
32
39
|
/**
|
|
33
40
|
* 执行容器内命令
|
|
34
41
|
*/
|
|
35
|
-
execContainer(containerId: string, cmd: string): Promise<
|
|
42
|
+
execContainer(containerId: string, cmd: string): Promise<{
|
|
43
|
+
output: string;
|
|
44
|
+
exitCode: number;
|
|
45
|
+
}>;
|
|
36
46
|
/**
|
|
37
47
|
* 监听 Docker 事件流
|
|
38
48
|
* @param callback 每行事件数据的回调
|
package/lib/service/connector.js
CHANGED
|
@@ -77,6 +77,43 @@ class DockerConnector {
|
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* 执行命令并返回输出和退出码
|
|
82
|
+
*/
|
|
83
|
+
async execWithExitCode(command) {
|
|
84
|
+
const client = await this.getConnection();
|
|
85
|
+
logger_1.connectorLogger.debug(`[${this.config.name}] 执行命令: ${command}`);
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
client.exec(command, (err, stream) => {
|
|
88
|
+
if (err) {
|
|
89
|
+
logger_1.connectorLogger.debug(`[${this.config.name}] 命令执行错误: ${err.message}`);
|
|
90
|
+
reject(err);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let stdout = '';
|
|
94
|
+
let stderr = '';
|
|
95
|
+
let exitCode = null;
|
|
96
|
+
stream.on('close', (code, signal) => {
|
|
97
|
+
logger_1.connectorLogger.debug(`[${this.config.name}] 命令完成: code=${code}, signal=${signal}`);
|
|
98
|
+
exitCode = code ?? 0;
|
|
99
|
+
// 显式结束 stream 防止 channel 泄露
|
|
100
|
+
try {
|
|
101
|
+
stream.end();
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
// 可能已经关闭,忽略错误
|
|
105
|
+
}
|
|
106
|
+
resolve({ output: stdout.trim(), exitCode });
|
|
107
|
+
});
|
|
108
|
+
stream.on('data', (data) => {
|
|
109
|
+
stdout += data.toString();
|
|
110
|
+
});
|
|
111
|
+
stream.on('err', (data) => {
|
|
112
|
+
stderr += data.toString();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
80
117
|
/**
|
|
81
118
|
* 执行 docker ps 获取容器列表
|
|
82
119
|
*/
|
|
@@ -115,7 +152,7 @@ class DockerConnector {
|
|
|
115
152
|
async execContainer(containerId, cmd) {
|
|
116
153
|
// 使用 docker exec 需要处理引号
|
|
117
154
|
const escapedCmd = cmd.replace(/'/g, "'\\''");
|
|
118
|
-
return this.
|
|
155
|
+
return this.execWithExitCode(`docker exec ${containerId} sh -c '${escapedCmd}'`);
|
|
119
156
|
}
|
|
120
157
|
/**
|
|
121
158
|
* 监听 Docker 事件流
|
package/lib/service/index.d.ts
CHANGED
|
@@ -62,4 +62,11 @@ export declare class DockerService {
|
|
|
62
62
|
*/
|
|
63
63
|
onNodeEvent(callback: (event: DockerEvent, nodeId: string) => void): () => void;
|
|
64
64
|
stopAll(): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* 获取所有在线节点的容器聚合
|
|
67
|
+
*/
|
|
68
|
+
getAggregatedContainers(all?: boolean): Promise<Array<{
|
|
69
|
+
node: DockerNode;
|
|
70
|
+
containers: ContainerInfo[];
|
|
71
|
+
}>>;
|
|
65
72
|
}
|
package/lib/service/index.js
CHANGED
|
@@ -198,5 +198,22 @@ class DockerService {
|
|
|
198
198
|
this.eventCallbacks.clear();
|
|
199
199
|
logger_1.logger.info('Docker 服务已停止');
|
|
200
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* 获取所有在线节点的容器聚合
|
|
203
|
+
*/
|
|
204
|
+
async getAggregatedContainers(all = true) {
|
|
205
|
+
const results = [];
|
|
206
|
+
for (const node of this.getOnlineNodes()) {
|
|
207
|
+
try {
|
|
208
|
+
const containers = await node.listContainers(all);
|
|
209
|
+
results.push({ node, containers });
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
logger_1.logger.warn(`[${node.name}] 获取容器列表失败: ${e}`);
|
|
213
|
+
results.push({ node, containers: [] });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
201
218
|
}
|
|
202
219
|
exports.DockerService = DockerService;
|
package/lib/service/node.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare class DockerNode {
|
|
|
27
27
|
constructor(config: NodeConfig, credential: CredentialConfig, debug?: boolean);
|
|
28
28
|
/**
|
|
29
29
|
* 连接到 Docker (带重试)
|
|
30
|
+
* 前 3 次失败后每 1 分钟重试一次,直到成功
|
|
30
31
|
*/
|
|
31
32
|
connect(): Promise<void>;
|
|
32
33
|
/**
|
|
@@ -60,7 +61,10 @@ export declare class DockerNode {
|
|
|
60
61
|
/**
|
|
61
62
|
* 执行容器内命令
|
|
62
63
|
*/
|
|
63
|
-
execContainer(containerId: string, cmd: string): Promise<
|
|
64
|
+
execContainer(containerId: string, cmd: string): Promise<{
|
|
65
|
+
output: string;
|
|
66
|
+
exitCode: number;
|
|
67
|
+
}>;
|
|
64
68
|
/**
|
|
65
69
|
* 获取 Docker 版本信息
|
|
66
70
|
*/
|
|
@@ -71,6 +75,25 @@ export declare class DockerNode {
|
|
|
71
75
|
Arch: string;
|
|
72
76
|
KernelVersion: string;
|
|
73
77
|
}>;
|
|
78
|
+
/**
|
|
79
|
+
* 获取系统信息 (CPU、内存)
|
|
80
|
+
*/
|
|
81
|
+
getSystemInfo(): Promise<{
|
|
82
|
+
NCPU: number;
|
|
83
|
+
MemTotal: number;
|
|
84
|
+
MemAvailable?: number;
|
|
85
|
+
} | null>;
|
|
86
|
+
/**
|
|
87
|
+
* 获取容器数量
|
|
88
|
+
*/
|
|
89
|
+
getContainerCount(): Promise<{
|
|
90
|
+
running: number;
|
|
91
|
+
total: number;
|
|
92
|
+
}>;
|
|
93
|
+
/**
|
|
94
|
+
* 获取镜像数量
|
|
95
|
+
*/
|
|
96
|
+
getImageCount(): Promise<number>;
|
|
74
97
|
/**
|
|
75
98
|
* 获取容器详细信息 (docker inspect)
|
|
76
99
|
*/
|
package/lib/service/node.js
CHANGED
|
@@ -38,6 +38,7 @@ class DockerNode {
|
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
40
|
* 连接到 Docker (带重试)
|
|
41
|
+
* 前 3 次失败后每 1 分钟重试一次,直到成功
|
|
41
42
|
*/
|
|
42
43
|
async connect() {
|
|
43
44
|
if (this.status === constants_1.NodeStatus.CONNECTING) {
|
|
@@ -46,10 +47,18 @@ class DockerNode {
|
|
|
46
47
|
}
|
|
47
48
|
this.status = constants_1.NodeStatus.CONNECTING;
|
|
48
49
|
let attempt = 0;
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const MAX_INITIAL_ATTEMPTS = 3; // 前 3 次快速重试
|
|
51
|
+
const LONG_RETRY_INTERVAL = 60000; // 1 分钟
|
|
52
|
+
while (true) {
|
|
51
53
|
attempt++;
|
|
52
|
-
|
|
54
|
+
const isInitialAttempts = attempt <= MAX_INITIAL_ATTEMPTS;
|
|
55
|
+
const currentInterval = isInitialAttempts ? constants_1.RETRY_INTERVAL : LONG_RETRY_INTERVAL;
|
|
56
|
+
if (isInitialAttempts) {
|
|
57
|
+
logger_1.nodeLogger.info(`[${this.name}] 连接尝试 ${attempt}/${MAX_INITIAL_ATTEMPTS}...`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
logger_1.nodeLogger.info(`[${this.name}] 连接尝试 ${attempt} (每 ${LONG_RETRY_INTERVAL / 1000} 秒重试)...`);
|
|
61
|
+
}
|
|
53
62
|
try {
|
|
54
63
|
// 创建 connector
|
|
55
64
|
const connector = new connector_1.DockerConnector(this.config, { credentials: [this.credential], nodes: [this.config] });
|
|
@@ -74,20 +83,16 @@ class DockerNode {
|
|
|
74
83
|
return;
|
|
75
84
|
}
|
|
76
85
|
catch (error) {
|
|
77
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
86
|
+
const lastError = error instanceof Error ? error : new Error(String(error));
|
|
78
87
|
logger_1.nodeLogger.warn(`[${this.name}] 连接失败: ${lastError.message}`);
|
|
79
88
|
// 清理连接
|
|
80
89
|
this.connector?.dispose();
|
|
81
90
|
this.connector = null;
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
91
|
+
// 等待后重试
|
|
92
|
+
logger_1.nodeLogger.info(`[${this.name}] ${currentInterval / 1000} 秒后重试...`);
|
|
93
|
+
await new Promise(resolve => setTimeout(resolve, currentInterval));
|
|
86
94
|
}
|
|
87
95
|
}
|
|
88
|
-
// 所有重试都失败
|
|
89
|
-
this.status = constants_1.NodeStatus.ERROR;
|
|
90
|
-
logger_1.nodeLogger.error(`[${this.name}] 连接失败,已重试 ${constants_1.MAX_RETRY_COUNT} 次`);
|
|
91
96
|
}
|
|
92
97
|
/**
|
|
93
98
|
* 断开连接
|
|
@@ -173,6 +178,61 @@ class DockerNode {
|
|
|
173
178
|
KernelVersion: info.KernelVersion || 'unknown',
|
|
174
179
|
};
|
|
175
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* 获取系统信息 (CPU、内存)
|
|
183
|
+
*/
|
|
184
|
+
async getSystemInfo() {
|
|
185
|
+
if (!this.connector)
|
|
186
|
+
return null;
|
|
187
|
+
try {
|
|
188
|
+
// 使用更可靠的格式
|
|
189
|
+
const output = await this.connector.exec('docker info --format "{{.NCPU}} {{.MemTotal}} {{.MemAvailable}}"');
|
|
190
|
+
const parts = output.trim().split(/\s+/);
|
|
191
|
+
if (parts.length >= 2) {
|
|
192
|
+
return {
|
|
193
|
+
NCPU: parseInt(parts[0]) || 0,
|
|
194
|
+
MemTotal: parseInt(parts[1]) || 0,
|
|
195
|
+
MemAvailable: parts[2] ? parseInt(parts[2]) : undefined,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* 获取容器数量
|
|
206
|
+
*/
|
|
207
|
+
async getContainerCount() {
|
|
208
|
+
if (!this.connector)
|
|
209
|
+
throw new Error('未连接');
|
|
210
|
+
try {
|
|
211
|
+
const running = await this.connector.exec('docker ps -q | wc -l');
|
|
212
|
+
const total = await this.connector.exec('docker ps -aq | wc -l');
|
|
213
|
+
return {
|
|
214
|
+
running: parseInt(running.trim()) || 0,
|
|
215
|
+
total: parseInt(total.trim()) || 0,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return { running: 0, total: 0 };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 获取镜像数量
|
|
224
|
+
*/
|
|
225
|
+
async getImageCount() {
|
|
226
|
+
if (!this.connector)
|
|
227
|
+
throw new Error('未连接');
|
|
228
|
+
try {
|
|
229
|
+
const output = await this.connector.exec('docker images -q | wc -l');
|
|
230
|
+
return parseInt(output.trim()) || 0;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
176
236
|
/**
|
|
177
237
|
* 获取容器详细信息 (docker inspect)
|
|
178
238
|
*/
|
package/lib/utils/render.d.ts
CHANGED
|
@@ -36,5 +36,13 @@ export declare function generateNodesHtml(nodes: any[]): string;
|
|
|
36
36
|
/**
|
|
37
37
|
* 生成节点详情 HTML
|
|
38
38
|
*/
|
|
39
|
-
export declare function generateNodeDetailHtml(node: any, version: any): string;
|
|
39
|
+
export declare function generateNodeDetailHtml(node: any, version: any, systemInfo?: any): string;
|
|
40
|
+
/**
|
|
41
|
+
* 生成日志 HTML
|
|
42
|
+
*/
|
|
43
|
+
export declare function generateLogsHtml(nodeName: string, containerName: string, logs: string, lineCount: number): string;
|
|
44
|
+
/**
|
|
45
|
+
* 生成执行结果 HTML
|
|
46
|
+
*/
|
|
47
|
+
export declare function generateExecHtml(nodeName: string, containerName: string, command: string, output: string, exitCode: number): string;
|
|
40
48
|
export {};
|
package/lib/utils/render.js
CHANGED
|
@@ -6,6 +6,8 @@ exports.generateResultHtml = generateResultHtml;
|
|
|
6
6
|
exports.generateInspectHtml = generateInspectHtml;
|
|
7
7
|
exports.generateNodesHtml = generateNodesHtml;
|
|
8
8
|
exports.generateNodeDetailHtml = generateNodeDetailHtml;
|
|
9
|
+
exports.generateLogsHtml = generateLogsHtml;
|
|
10
|
+
exports.generateExecHtml = generateExecHtml;
|
|
9
11
|
const koishi_1 = require("koishi");
|
|
10
12
|
// 基础样式
|
|
11
13
|
const STYLE = `
|
|
@@ -121,6 +123,14 @@ const STYLE = `
|
|
|
121
123
|
.detail-value.highlight {
|
|
122
124
|
color: #60a5fa;
|
|
123
125
|
}
|
|
126
|
+
.detail-span {
|
|
127
|
+
grid-column: 1 / -1;
|
|
128
|
+
}
|
|
129
|
+
.detail-span .detail-value {
|
|
130
|
+
white-space: pre-wrap;
|
|
131
|
+
font-size: 13px;
|
|
132
|
+
line-height: 1.6;
|
|
133
|
+
}
|
|
124
134
|
|
|
125
135
|
/* 操作结果样式 */
|
|
126
136
|
.result-card {
|
|
@@ -167,26 +177,30 @@ async function renderToImage(ctx, html, options = {}) {
|
|
|
167
177
|
throw new Error('未安装 koishi-plugin-puppeteer 插件');
|
|
168
178
|
}
|
|
169
179
|
return ctx.puppeteer.render(html, async (page, next) => {
|
|
170
|
-
//
|
|
180
|
+
// 1. 设置初始视口
|
|
171
181
|
await page.setViewport({
|
|
172
|
-
width: options.width ||
|
|
173
|
-
height: options.height ||
|
|
174
|
-
deviceScaleFactor: 2
|
|
182
|
+
width: options.width || 800,
|
|
183
|
+
height: options.height || 100,
|
|
184
|
+
deviceScaleFactor: 2
|
|
175
185
|
});
|
|
176
|
-
// 等待内容渲染
|
|
186
|
+
// 2. 等待内容渲染
|
|
177
187
|
const body = await page.$('body');
|
|
178
188
|
const wrapper = await page.$('.wrapper');
|
|
179
|
-
//
|
|
180
|
-
const
|
|
181
|
-
if (
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
// 3. 获取实际内容的高度
|
|
190
|
+
const boundingBox = await wrapper?.boundingBox() || await body?.boundingBox();
|
|
191
|
+
if (boundingBox) {
|
|
192
|
+
// 调整视口高度以匹配内容
|
|
193
|
+
await page.setViewport({
|
|
194
|
+
width: options.width || 800,
|
|
195
|
+
height: Math.ceil(boundingBox.height) + 100,
|
|
196
|
+
deviceScaleFactor: 2
|
|
197
|
+
});
|
|
198
|
+
// 重新获取 clip (因为视口变化可能导致重绘)
|
|
199
|
+
const finalClip = await wrapper?.boundingBox() || await body?.boundingBox();
|
|
200
|
+
if (finalClip) {
|
|
201
|
+
const buffer = await page.screenshot({ clip: finalClip });
|
|
202
|
+
return koishi_1.h.image(buffer, 'image/png').toString();
|
|
203
|
+
}
|
|
190
204
|
}
|
|
191
205
|
// Fallback
|
|
192
206
|
const buffer = await page.screenshot({ fullPage: true });
|
|
@@ -289,25 +303,57 @@ function generateInspectHtml(nodeName, info) {
|
|
|
289
303
|
const name = info.Name.replace('/', '');
|
|
290
304
|
const shortId = info.Id.slice(0, 12);
|
|
291
305
|
const isRunning = info.State.Running;
|
|
306
|
+
// 网络信息
|
|
307
|
+
const networks = info.NetworkSettings?.Networks;
|
|
308
|
+
const networkInfo = networks && Object.keys(networks).length > 0
|
|
309
|
+
? Object.entries(networks).map(([name, net]) => {
|
|
310
|
+
const n = net;
|
|
311
|
+
const ip = n.IPAddress || '-';
|
|
312
|
+
const gateway = n.Gateway || '-';
|
|
313
|
+
return ` ${name}: ${ip} (GW: ${gateway})`;
|
|
314
|
+
}).join('\n')
|
|
315
|
+
: '-';
|
|
316
|
+
// 环境变量
|
|
317
|
+
const envVars = info.Config?.Env || [];
|
|
318
|
+
const envDisplay = envVars.length > 0
|
|
319
|
+
? envVars.slice(0, 10).map(e => {
|
|
320
|
+
const [key, ...val] = e.split('=');
|
|
321
|
+
return ` ${key}=${val.join('=').slice(0, 50)}${val.join('=').length > 50 ? '...' : ''}`;
|
|
322
|
+
}).join('\n') + (envVars.length > 10 ? `\n ... (共 ${envVars.length} 个)` : '')
|
|
323
|
+
: '-';
|
|
324
|
+
// 重启策略
|
|
325
|
+
const restartPolicy = info.HostConfig?.RestartPolicy;
|
|
326
|
+
const restartDisplay = restartPolicy
|
|
327
|
+
? `${restartPolicy.Name}${restartPolicy.Name !== 'no' ? ` (最大 ${restartPolicy.MaximumRetryCount} 次重试)` : ''}`
|
|
328
|
+
: 'no';
|
|
329
|
+
// 挂载目录
|
|
330
|
+
const mounts = info.Mounts || [];
|
|
331
|
+
const mountsDisplay = mounts.length > 0
|
|
332
|
+
? mounts.map((m) => {
|
|
333
|
+
const mount = m;
|
|
334
|
+
return ` ${mount.Source} → ${mount.Destination} (${mount.Type})`;
|
|
335
|
+
}).join('\n')
|
|
336
|
+
: '-';
|
|
292
337
|
const items = [
|
|
293
|
-
{ label: '容器名称', value: name },
|
|
294
|
-
{ label: '容器 ID', value: info.Id },
|
|
295
|
-
{ label: '镜像', value: info.Config.Image },
|
|
296
|
-
{ label: '状态', value: info.State.Status, highlight: true },
|
|
297
|
-
{ label: '创建时间', value: new Date(info.Created).toLocaleString() },
|
|
298
|
-
{ label: '启动时间', value: new Date(info.State.StartedAt).toLocaleString() },
|
|
299
|
-
{ label: '
|
|
300
|
-
{ label: '
|
|
301
|
-
{ label: '
|
|
302
|
-
{ label: '
|
|
338
|
+
{ label: '容器名称', value: name, span: false },
|
|
339
|
+
{ label: '容器 ID', value: info.Id, span: false },
|
|
340
|
+
{ label: '镜像', value: info.Config.Image, span: false },
|
|
341
|
+
{ label: '状态', value: info.State.Status, highlight: true, span: false },
|
|
342
|
+
{ label: '创建时间', value: new Date(info.Created).toLocaleString(), span: false },
|
|
343
|
+
{ label: '启动时间', value: new Date(info.State.StartedAt).toLocaleString(), span: false },
|
|
344
|
+
{ label: '重启策略', value: restartDisplay, span: false },
|
|
345
|
+
{ label: '重启次数', value: String(info.RestartCount), span: false },
|
|
346
|
+
{ label: '网络', value: networkInfo, span: true },
|
|
347
|
+
{ label: '环境变量', value: envDisplay, span: true },
|
|
348
|
+
{ label: '挂载目录', value: mountsDisplay, span: true },
|
|
303
349
|
];
|
|
304
350
|
if (info.State.Health) {
|
|
305
|
-
items.push({ label: '健康状态', value: info.State.Health.Status, highlight: true });
|
|
351
|
+
items.push({ label: '健康状态', value: info.State.Health.Status, highlight: true, span: false });
|
|
306
352
|
}
|
|
307
353
|
const gridItems = items.map(item => `
|
|
308
|
-
<div class="detail-item">
|
|
354
|
+
<div class="detail-item ${item.span ? 'detail-span' : ''}">
|
|
309
355
|
<div class="detail-label">${item.label}</div>
|
|
310
|
-
<div class="detail-value ${item.highlight ? 'highlight' : ''}">${item.value}</div>
|
|
356
|
+
<div class="detail-value ${item.highlight ? 'highlight' : ''}">${item.value.replace(/\n/g, '<br>')}</div>
|
|
311
357
|
</div>
|
|
312
358
|
`).join('');
|
|
313
359
|
const header = `
|
|
@@ -330,7 +376,6 @@ function generateInspectHtml(nodeName, info) {
|
|
|
330
376
|
${gridItems}
|
|
331
377
|
</div>
|
|
332
378
|
</div>
|
|
333
|
-
<!--Mounts/Ports could be added here-->
|
|
334
379
|
</div>
|
|
335
380
|
`;
|
|
336
381
|
return wrapHtml(header + body);
|
|
@@ -339,22 +384,27 @@ function generateInspectHtml(nodeName, info) {
|
|
|
339
384
|
* 生成节点列表 HTML
|
|
340
385
|
*/
|
|
341
386
|
function generateNodesHtml(nodes) {
|
|
342
|
-
|
|
387
|
+
// 兼容字段名称
|
|
388
|
+
const getStatus = (n) => n.status || n.Status || 'unknown';
|
|
389
|
+
const getName = (n) => n.name || n.Name || 'Unknown';
|
|
390
|
+
const getId = (n) => n.id || n.ID || n.Id || '-';
|
|
391
|
+
const onlineCount = nodes.filter(n => getStatus(n) === 'connected').length;
|
|
343
392
|
const totalCount = nodes.length;
|
|
344
393
|
const listItems = nodes.map(n => {
|
|
345
|
-
const
|
|
346
|
-
const
|
|
394
|
+
const status = getStatus(n);
|
|
395
|
+
const isOnline = status === 'connected' || status === 'running';
|
|
396
|
+
const isConnecting = status === 'connecting';
|
|
347
397
|
const icon = isOnline ? '🟢' : (isConnecting ? '🟡' : '🔴');
|
|
348
|
-
const tags = n.tags.map((t) => `<span class="tag">@${t}</span>`).join(' ');
|
|
398
|
+
const tags = (n.tags || []).map((t) => `<span class="tag">@${t}</span>`).join(' ');
|
|
349
399
|
return `
|
|
350
400
|
<div class="list-item">
|
|
351
401
|
<div class="status-icon">${icon}</div>
|
|
352
402
|
<div class="name-col">
|
|
353
|
-
<div>${n
|
|
354
|
-
<div style="font-size:12px; opacity:0.6; margin-top:2px;">${n
|
|
403
|
+
<div>${getName(n)}</div>
|
|
404
|
+
<div style="font-size:12px; opacity:0.6; margin-top:2px;">${getId(n)}</div>
|
|
355
405
|
</div>
|
|
356
406
|
<div class="meta-col">
|
|
357
|
-
<div style="color: ${isOnline ? '#4ade80' : (isConnecting ? '#facc15' : '#f87171')}">${
|
|
407
|
+
<div style="color: ${isOnline ? '#4ade80' : (isConnecting ? '#facc15' : '#f87171')}">${status}</div>
|
|
358
408
|
</div>
|
|
359
409
|
<div>${tags}</div>
|
|
360
410
|
</div>
|
|
@@ -371,18 +421,37 @@ function generateNodesHtml(nodes) {
|
|
|
371
421
|
/**
|
|
372
422
|
* 生成节点详情 HTML
|
|
373
423
|
*/
|
|
374
|
-
function generateNodeDetailHtml(node, version) {
|
|
375
|
-
|
|
424
|
+
function generateNodeDetailHtml(node, version, systemInfo) {
|
|
425
|
+
// 兼容字段名称 (处理大小写不一致的问题)
|
|
426
|
+
// 优先从 config 获取名称,因为 node 对象可能是 DockerNode 实例
|
|
427
|
+
const nodeName = node.config?.name || node.name || node.Name || 'Unknown';
|
|
428
|
+
const nodeId = node.id || node.ID || node.Id || node.config?.id || '-';
|
|
429
|
+
const nodeStatus = node.status || node.Status || 'unknown';
|
|
430
|
+
const nodeTags = node.tags || node.config?.tags || [];
|
|
431
|
+
const isOnline = nodeStatus === 'connected' || nodeStatus === 'running';
|
|
432
|
+
// 解析系统信息 (兼容不同字段格式)
|
|
433
|
+
const cpuCores = systemInfo?.NCPU || systemInfo?.Ncpu || systemInfo?.ncpu || '-';
|
|
434
|
+
const memoryTotal = systemInfo?.MemTotal ? formatBytes(systemInfo.MemTotal) : '-';
|
|
435
|
+
// 如果没有 MemAvailable,则只显示总内存
|
|
436
|
+
const memoryDisplay = systemInfo?.MemAvailable !== undefined
|
|
437
|
+
? `${formatBytes(systemInfo.MemAvailable)} / ${memoryTotal}`
|
|
438
|
+
: memoryTotal !== '-' ? memoryTotal : '-';
|
|
376
439
|
// 基础信息
|
|
377
440
|
const items = [
|
|
378
|
-
{ label: '节点名称', value:
|
|
379
|
-
{ label: '节点 ID', value:
|
|
380
|
-
{ label: '状态', value:
|
|
381
|
-
{ label: '标签', value:
|
|
441
|
+
{ label: '节点名称', value: nodeName },
|
|
442
|
+
{ label: '节点 ID', value: nodeId },
|
|
443
|
+
{ label: '状态', value: nodeStatus, highlight: isOnline },
|
|
444
|
+
{ label: '标签', value: (nodeTags || []).join(', ') || '(无)' },
|
|
382
445
|
];
|
|
446
|
+
// 系统资源信息
|
|
447
|
+
items.push({ label: 'CPU', value: `${cpuCores} 核心` }, { label: '内存', value: memoryDisplay }, { label: '容器数量', value: String(node.containerCount ?? node.Containers ?? node.containers ?? '-') }, { label: '镜像数量', value: String(node.imageCount ?? node.Images ?? node.images ?? '-') });
|
|
448
|
+
// 集群信息
|
|
449
|
+
if (node.cluster || node.Swarm?.NodeID) {
|
|
450
|
+
items.push({ label: '集群', value: node.cluster || 'Swarm Mode' });
|
|
451
|
+
}
|
|
383
452
|
// 版本信息
|
|
384
453
|
if (version) {
|
|
385
|
-
items.push({ label: 'Docker 版本', value: version.Version }, { label: 'API 版本', value: version.ApiVersion }, { label: '操作系统', value: `${version.Os} (${version.Arch
|
|
454
|
+
items.push({ label: 'Docker 版本', value: version.Version || version.version || '-' }, { label: 'API 版本', value: version.ApiVersion || version.ApiVersion || '-' }, { label: '操作系统', value: `${version.Os || version.Os || 'unknown'} (${version.Arch || version.Arch || 'unknown'})` }, { label: '内核版本', value: version.KernelVersion || version.KernelVersion || '-' });
|
|
386
455
|
}
|
|
387
456
|
const gridItems = items.map(item => `
|
|
388
457
|
<div class="detail-item">
|
|
@@ -393,7 +462,7 @@ function generateNodeDetailHtml(node, version) {
|
|
|
393
462
|
const header = `
|
|
394
463
|
<div class="header">
|
|
395
464
|
<div class="header-title">节点详情</div>
|
|
396
|
-
<div class="header-badge">${
|
|
465
|
+
<div class="header-badge">${nodeName}</div>
|
|
397
466
|
</div>
|
|
398
467
|
`;
|
|
399
468
|
const body = `
|
|
@@ -402,8 +471,8 @@ function generateNodeDetailHtml(node, version) {
|
|
|
402
471
|
<div style="display: flex; align-items: center; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
|
403
472
|
<div style="font-size: 32px; margin-right: 16px;">${isOnline ? '🟢' : '🔴'}</div>
|
|
404
473
|
<div>
|
|
405
|
-
<div style="font-size: 20px; font-weight: 600;">${
|
|
406
|
-
<div style="font-size: 13px; color: #94a3b8; font-family: monospace;">${
|
|
474
|
+
<div style="font-size: 20px; font-weight: 600;">${nodeName}</div>
|
|
475
|
+
<div style="font-size: 13px; color: #94a3b8; font-family: monospace;">${nodeId}</div>
|
|
407
476
|
</div>
|
|
408
477
|
</div>
|
|
409
478
|
<div class="detail-grid">
|
|
@@ -414,3 +483,200 @@ function generateNodeDetailHtml(node, version) {
|
|
|
414
483
|
`;
|
|
415
484
|
return wrapHtml(header + body);
|
|
416
485
|
}
|
|
486
|
+
/**
|
|
487
|
+
* 生成日志 HTML
|
|
488
|
+
*/
|
|
489
|
+
function generateLogsHtml(nodeName, containerName, logs, lineCount) {
|
|
490
|
+
// 限制日志行数,避免过长
|
|
491
|
+
const maxLines = 150;
|
|
492
|
+
const allLines = logs.split('\n');
|
|
493
|
+
const totalLines = allLines.length;
|
|
494
|
+
const displayLines = allLines.slice(-maxLines);
|
|
495
|
+
const displayLogs = displayLines.join('\n');
|
|
496
|
+
const displayLineCount = displayLines.length;
|
|
497
|
+
// 逐行渲染,带行号和高亮
|
|
498
|
+
const logLines = displayLines.map((line, idx) => {
|
|
499
|
+
const lineNum = totalLines - displayLineCount + idx + 1;
|
|
500
|
+
return `<span class="line-num">${lineNum.toString().padStart(5, ' ')}</span><span class="line-content">${highlightLogContent(line)}</span>`;
|
|
501
|
+
}).join('\n');
|
|
502
|
+
const header = `
|
|
503
|
+
<div class="header">
|
|
504
|
+
<div class="header-title">📋 容器日志</div>
|
|
505
|
+
<div class="header-badge">${nodeName}/${containerName}</div>
|
|
506
|
+
</div>
|
|
507
|
+
`;
|
|
508
|
+
const body = `
|
|
509
|
+
<div class="content">
|
|
510
|
+
<div style="margin-bottom: 12px; font-size: 13px; color: #94a3b8; display: flex; justify-content: space-between;">
|
|
511
|
+
<span>显示第 ${totalLines - displayLineCount + 1} - ${totalLines} 行</span>
|
|
512
|
+
<span>共 ${totalLines} 行</span>
|
|
513
|
+
</div>
|
|
514
|
+
<div class="log-container">
|
|
515
|
+
<div class="log-lines">${logLines}</div>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
`;
|
|
519
|
+
// 添加日志专用样式
|
|
520
|
+
const logStyle = `
|
|
521
|
+
.log-container {
|
|
522
|
+
background: rgba(0, 0, 0, 0.3);
|
|
523
|
+
border-radius: 8px;
|
|
524
|
+
padding: 16px;
|
|
525
|
+
overflow: visible;
|
|
526
|
+
}
|
|
527
|
+
.log-lines {
|
|
528
|
+
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
|
529
|
+
font-size: 12px;
|
|
530
|
+
line-height: 1.6;
|
|
531
|
+
white-space: pre-wrap;
|
|
532
|
+
word-break: break-all;
|
|
533
|
+
color: #e2e8f0;
|
|
534
|
+
}
|
|
535
|
+
.line-num {
|
|
536
|
+
color: #475569;
|
|
537
|
+
margin-right: 12px;
|
|
538
|
+
user-select: none;
|
|
539
|
+
display: inline-block;
|
|
540
|
+
min-width: 35px;
|
|
541
|
+
text-align: right;
|
|
542
|
+
border-right: 1px solid #334155;
|
|
543
|
+
padding-right: 8px;
|
|
544
|
+
}
|
|
545
|
+
.line-content {
|
|
546
|
+
color: #e2e8f0;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* 高亮样式 */
|
|
550
|
+
.hl-date { color: #64748b; }
|
|
551
|
+
.hl-ip { color: #22d3ee; }
|
|
552
|
+
.hl-string { color: #a5f3fc; opacity: 0.9; }
|
|
553
|
+
.hl-error { color: #ef4444; font-weight: bold; background: rgba(239, 68, 68, 0.1); padding: 0 4px; border-radius: 2px; }
|
|
554
|
+
.hl-warn { color: #f59e0b; font-weight: bold; }
|
|
555
|
+
.hl-info { color: #3b82f6; font-weight: bold; }
|
|
556
|
+
.hl-debug { color: #94a3b8; }
|
|
557
|
+
`;
|
|
558
|
+
return wrapHtml(header + body, STYLE + logStyle);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* 格式化字节为可读格式
|
|
562
|
+
*/
|
|
563
|
+
function formatBytes(bytes) {
|
|
564
|
+
if (!bytes || bytes < 0)
|
|
565
|
+
return '-';
|
|
566
|
+
const k = 1024;
|
|
567
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
568
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
569
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* HTML 转义
|
|
573
|
+
*/
|
|
574
|
+
function escapeHtml(text) {
|
|
575
|
+
return text
|
|
576
|
+
.replace(/&/g, '&')
|
|
577
|
+
.replace(/</g, '<')
|
|
578
|
+
.replace(/>/g, '>')
|
|
579
|
+
.replace(/"/g, '"')
|
|
580
|
+
.replace(/'/g, ''');
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* 处理日志高亮
|
|
584
|
+
*/
|
|
585
|
+
function highlightLogContent(text) {
|
|
586
|
+
// 1. 先进行基础的 HTML 转义
|
|
587
|
+
let html = escapeHtml(text);
|
|
588
|
+
// 2. 定义高亮规则 (注意顺序:先匹配复杂的,再匹配简单的)
|
|
589
|
+
// [时间戳] YYYY-MM-DD HH:mm:ss 或 ISO8601
|
|
590
|
+
html = html.replace(/(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/g, '\x1f$1\x1f');
|
|
591
|
+
// [IP地址] 简单的 IPv4 匹配
|
|
592
|
+
html = html.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '\x1f$&\x1f');
|
|
593
|
+
// [日志等级 - Error/Fail] 红色
|
|
594
|
+
html = html.replace(/(\b(ERROR|ERR|FATAL|CRITICAL|FAIL|FAILED|EXCEPTION)\b|\[(ERROR|ERR)\])/gi, '\x1f$1\x1f');
|
|
595
|
+
// [日志等级 - Warn] 黄色
|
|
596
|
+
html = html.replace(/(\b(WARN|WARNING)\b|\[(WARN|WARNING)\])/gi, '\x1f$1\x1f');
|
|
597
|
+
// [日志等级 - Info] 蓝色
|
|
598
|
+
html = html.replace(/(\b(INFO|INFORMATION)\b|\[(INFO)\])/gi, '\x1f$1\x1f');
|
|
599
|
+
// [日志等级 - Debug/Trace] 灰色
|
|
600
|
+
html = html.replace(/(\b(DEBUG|TRACE)\b|\[(DEBUG|TRACE)\])/gi, '\x1f$1\x1f');
|
|
601
|
+
// [引用/字符串] "xxx" 或 'xxx'
|
|
602
|
+
html = html.replace(/(".*?"|'.*?')/g, '\x1f$1\x1f');
|
|
603
|
+
// 3. 将占位符替换回 HTML 标签
|
|
604
|
+
html = html
|
|
605
|
+
.replace(/\x1f(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\x1f/g, '<span class="hl-date">$1</span>')
|
|
606
|
+
.replace(/\x1f((?:\d{1,3}\.){3}\d{1,3})\x1f/g, '<span class="hl-ip">$1</span>')
|
|
607
|
+
.replace(/\x1f((?:\[[^\]]*\]|\w+))\x1f/g, (match, p1) => {
|
|
608
|
+
const lower = p1.toLowerCase();
|
|
609
|
+
if (lower.includes('error') || lower.includes('fatal') || lower.includes('fail') || lower.includes('exception')) {
|
|
610
|
+
return `<span class="hl-error">${p1}</span>`;
|
|
611
|
+
}
|
|
612
|
+
if (lower.includes('warn')) {
|
|
613
|
+
return `<span class="hl-warn">${p1}</span>`;
|
|
614
|
+
}
|
|
615
|
+
if (lower.includes('info')) {
|
|
616
|
+
return `<span class="hl-info">${p1}</span>`;
|
|
617
|
+
}
|
|
618
|
+
if (lower.includes('debug') || lower.includes('trace')) {
|
|
619
|
+
return `<span class="hl-debug">${p1}</span>`;
|
|
620
|
+
}
|
|
621
|
+
if (p1.startsWith('"') || p1.startsWith("'")) {
|
|
622
|
+
return `<span class="hl-string">${p1}</span>`;
|
|
623
|
+
}
|
|
624
|
+
return p1;
|
|
625
|
+
});
|
|
626
|
+
return html;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* 生成执行结果 HTML
|
|
630
|
+
*/
|
|
631
|
+
function generateExecHtml(nodeName, containerName, command, output, exitCode) {
|
|
632
|
+
const isSuccess = exitCode === 0;
|
|
633
|
+
const statusIcon = isSuccess ? '✅' : '❌';
|
|
634
|
+
const header = `
|
|
635
|
+
<div class="header">
|
|
636
|
+
<div class="header-title">🔧 命令执行</div>
|
|
637
|
+
<div class="header-badge">${nodeName}/${containerName}</div>
|
|
638
|
+
</div>
|
|
639
|
+
`;
|
|
640
|
+
const body = `
|
|
641
|
+
<div class="content">
|
|
642
|
+
<div style="
|
|
643
|
+
background: rgba(0, 0, 0, 0.2);
|
|
644
|
+
border-radius: 8px;
|
|
645
|
+
padding: 16px;
|
|
646
|
+
margin-bottom: 16px;
|
|
647
|
+
">
|
|
648
|
+
<div style="font-size: 13px; color: #94a3b8; margin-bottom: 8px;">执行命令</div>
|
|
649
|
+
<div style="
|
|
650
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
651
|
+
font-size: 13px;
|
|
652
|
+
color: #60a5fa;
|
|
653
|
+
background: rgba(96, 165, 250, 0.1);
|
|
654
|
+
padding: 8px 12px;
|
|
655
|
+
border-radius: 4px;
|
|
656
|
+
">${command}</div>
|
|
657
|
+
</div>
|
|
658
|
+
|
|
659
|
+
<div style="
|
|
660
|
+
background: rgba(0, 0, 0, 0.3);
|
|
661
|
+
border-radius: 8px;
|
|
662
|
+
padding: 16px;
|
|
663
|
+
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
|
664
|
+
font-size: 12px;
|
|
665
|
+
line-height: 1.6;
|
|
666
|
+
max-height: 300px;
|
|
667
|
+
overflow-y: auto;
|
|
668
|
+
white-space: pre-wrap;
|
|
669
|
+
word-break: break-all;
|
|
670
|
+
color: #e2e8f0;
|
|
671
|
+
">${output || '(无输出)'}</div>
|
|
672
|
+
|
|
673
|
+
<div style="margin-top: 16px; display: flex; align-items: center; gap: 8px;">
|
|
674
|
+
<span style="font-size: 20px;">${statusIcon}</span>
|
|
675
|
+
<span style="color: ${isSuccess ? '#4ade80' : '#f87171'}">
|
|
676
|
+
退出码: ${exitCode}
|
|
677
|
+
</span>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
`;
|
|
681
|
+
return wrapHtml(header + body);
|
|
682
|
+
}
|