koishi-plugin-docker-control 0.0.3 → 0.0.5
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 +6 -5
- package/lib/commands/index.js +37 -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/index.d.ts +3 -0
- package/lib/index.js +1 -1
- package/lib/service/connector.d.ts +30 -1
- package/lib/service/connector.js +97 -3
- package/lib/service/index.d.ts +7 -0
- package/lib/service/index.js +17 -0
- package/lib/service/node.d.ts +39 -2
- package/lib/service/node.js +151 -11
- package/lib/types.d.ts +24 -0
- package/lib/utils/render.d.ts +13 -1
- package/lib/utils/render.js +391 -47
- package/package.json +1 -1
|
@@ -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
|
@@ -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
|
@@ -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
|
}
|
|
@@ -68,16 +70,36 @@ function registerHelperCommands(ctx, getService, config) {
|
|
|
68
70
|
}
|
|
69
71
|
const node = nodes[0];
|
|
70
72
|
try {
|
|
71
|
-
const version = await
|
|
73
|
+
const [version, systemInfo, containerCount, imageCount] = await Promise.all([
|
|
74
|
+
node.getVersion(),
|
|
75
|
+
node.getSystemInfo(),
|
|
76
|
+
node.getContainerCount(),
|
|
77
|
+
node.getImageCount(),
|
|
78
|
+
]);
|
|
79
|
+
// 将容器和镜像数量添加到节点对象
|
|
80
|
+
const nodeData = {
|
|
81
|
+
...node,
|
|
82
|
+
containerCount: containerCount.total,
|
|
83
|
+
imageCount: imageCount,
|
|
84
|
+
};
|
|
72
85
|
if (useImageOutput && ctx.puppeteer) {
|
|
73
|
-
const html = (0, render_1.generateNodeDetailHtml)(
|
|
86
|
+
const html = (0, render_1.generateNodeDetailHtml)(nodeData, version, systemInfo);
|
|
74
87
|
return await (0, render_1.renderToImage)(ctx, html);
|
|
75
88
|
}
|
|
89
|
+
const memoryUsed = systemInfo?.MemTotal && systemInfo?.MemAvailable !== undefined
|
|
90
|
+
? `${Math.round((1 - systemInfo.MemAvailable / systemInfo.MemTotal) * 100)}%`
|
|
91
|
+
: '-';
|
|
92
|
+
const nodeName = node.config?.name || node.name || node.Name || 'Unknown';
|
|
93
|
+
const nodeId = node.id || node.ID || node.Id || node.config?.id || '-';
|
|
76
94
|
const lines = [
|
|
77
|
-
`=== ${
|
|
78
|
-
`ID: ${
|
|
79
|
-
`状态: ${node.status}`,
|
|
80
|
-
`标签: ${node.tags.join(', ') || '无'}`,
|
|
95
|
+
`=== ${nodeName} ===`,
|
|
96
|
+
`ID: ${nodeId}`,
|
|
97
|
+
`状态: ${node.status || node.Status || 'unknown'}`,
|
|
98
|
+
`标签: ${node.tags?.join(', ') || node.config?.tags?.join(', ') || '无'}`,
|
|
99
|
+
`CPU: ${systemInfo?.NCPU || '-'} 核心`,
|
|
100
|
+
`内存: ${memoryUsed} (可用: ${systemInfo?.MemAvailable ? Math.round(systemInfo.MemAvailable / 1024 / 1024) + ' MB' : '-'})`,
|
|
101
|
+
`容器: ${containerCount.running}/${containerCount.total} 运行中`,
|
|
102
|
+
`镜像: ${imageCount} 个`,
|
|
81
103
|
`Docker 版本: ${version.Version}`,
|
|
82
104
|
`API 版本: ${version.ApiVersion}`,
|
|
83
105
|
`操作系统: ${version.Os} (${version.Arch})`,
|
|
@@ -146,7 +168,7 @@ function registerHelperCommands(ctx, getService, config) {
|
|
|
146
168
|
if (c.State !== 'running') {
|
|
147
169
|
return `容器 ${container} 未运行`;
|
|
148
170
|
}
|
|
149
|
-
const result = await node.execContainer(c.Id, cmd
|
|
171
|
+
const result = await node.execContainer(c.Id, cmd);
|
|
150
172
|
return [
|
|
151
173
|
`=== 执行结果 ===`,
|
|
152
174
|
`退出码: ${result.exitCode}`,
|
|
@@ -158,47 +180,6 @@ function registerHelperCommands(ctx, getService, config) {
|
|
|
158
180
|
return `执行失败: ${e.message}`;
|
|
159
181
|
}
|
|
160
182
|
});
|
|
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
183
|
/**
|
|
203
184
|
* 查看帮助
|
|
204
185
|
*/
|
|
@@ -210,15 +191,14 @@ function registerHelperCommands(ctx, getService, config) {
|
|
|
210
191
|
' docker.nodes - 查看节点列表',
|
|
211
192
|
' docker.node <节点> - 查看节点详情',
|
|
212
193
|
'',
|
|
213
|
-
'【容器操作】',
|
|
214
|
-
' docker.ls
|
|
215
|
-
' docker.start <容器>
|
|
216
|
-
' docker.stop <容器>
|
|
217
|
-
' docker.restart <容器>
|
|
218
|
-
' docker.logs <容器> [-
|
|
219
|
-
' docker.
|
|
220
|
-
' docker.exec <容器> <命令>
|
|
221
|
-
' docker.shell <容器> <命令> - 交互式执行',
|
|
194
|
+
'【容器操作】(参数顺序: 节点 容器)',
|
|
195
|
+
' docker.ls <节点> - 列出容器',
|
|
196
|
+
' docker.start <节点> <容器> - 启动容器',
|
|
197
|
+
' docker.stop <节点> <容器> - 停止容器',
|
|
198
|
+
' docker.restart <节点> <容器> - 重启容器',
|
|
199
|
+
' docker.logs <节点> <容器> [-n 行数] - 查看日志',
|
|
200
|
+
' docker.inspect <节点> <容器> - 查看容器详情',
|
|
201
|
+
' docker.exec <节点> <容器> <命令> - 在容器内执行命令',
|
|
222
202
|
'',
|
|
223
203
|
'【节点选择器】',
|
|
224
204
|
' 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
|
}
|
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({
|
|
@@ -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 每行事件数据的回调
|
|
@@ -64,4 +74,23 @@ export declare class DockerConnector {
|
|
|
64
74
|
* 获取凭证配置
|
|
65
75
|
*/
|
|
66
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>;
|
|
67
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());
|
|
@@ -77,6 +79,43 @@ class DockerConnector {
|
|
|
77
79
|
});
|
|
78
80
|
});
|
|
79
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* 执行命令并返回输出和退出码
|
|
84
|
+
*/
|
|
85
|
+
async execWithExitCode(command) {
|
|
86
|
+
const client = await this.getConnection();
|
|
87
|
+
logger_1.connectorLogger.debug(`[${this.config.name}] 执行命令: ${command}`);
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
client.exec(command, (err, stream) => {
|
|
90
|
+
if (err) {
|
|
91
|
+
logger_1.connectorLogger.debug(`[${this.config.name}] 命令执行错误: ${err.message}`);
|
|
92
|
+
reject(err);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let stdout = '';
|
|
96
|
+
let stderr = '';
|
|
97
|
+
let exitCode = null;
|
|
98
|
+
stream.on('close', (code, signal) => {
|
|
99
|
+
logger_1.connectorLogger.debug(`[${this.config.name}] 命令完成: code=${code}, signal=${signal}`);
|
|
100
|
+
exitCode = code ?? 0;
|
|
101
|
+
// 显式结束 stream 防止 channel 泄露
|
|
102
|
+
try {
|
|
103
|
+
stream.end();
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
// 可能已经关闭,忽略错误
|
|
107
|
+
}
|
|
108
|
+
resolve({ output: stdout.trim(), exitCode });
|
|
109
|
+
});
|
|
110
|
+
stream.on('data', (data) => {
|
|
111
|
+
stdout += data.toString();
|
|
112
|
+
});
|
|
113
|
+
stream.on('err', (data) => {
|
|
114
|
+
stderr += data.toString();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
80
119
|
/**
|
|
81
120
|
* 执行 docker ps 获取容器列表
|
|
82
121
|
*/
|
|
@@ -115,7 +154,7 @@ class DockerConnector {
|
|
|
115
154
|
async execContainer(containerId, cmd) {
|
|
116
155
|
// 使用 docker exec 需要处理引号
|
|
117
156
|
const escapedCmd = cmd.replace(/'/g, "'\\''");
|
|
118
|
-
return this.
|
|
157
|
+
return this.execWithExitCode(`docker exec ${containerId} sh -c '${escapedCmd}'`);
|
|
119
158
|
}
|
|
120
159
|
/**
|
|
121
160
|
* 监听 Docker 事件流
|
|
@@ -263,5 +302,60 @@ class DockerConnector {
|
|
|
263
302
|
getCredential() {
|
|
264
303
|
return (0, config_1.getCredentialById)(this.fullConfig, this.config.credentialId);
|
|
265
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
|
+
}
|
|
266
360
|
}
|
|
267
361
|
exports.DockerConnector = DockerConnector;
|