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.
@@ -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
  }
@@ -68,16 +68,36 @@ function registerHelperCommands(ctx, getService, config) {
68
68
  }
69
69
  const node = nodes[0];
70
70
  try {
71
- const version = await node.getVersion();
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)(node, version);
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
- `=== ${node.name} ===`,
78
- `ID: ${node.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.split(' '));
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 <容器> [-t 行数] - 查看日志',
219
- ' docker.find <容器> - 搜索容器',
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 - 所有节点',
@@ -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, selector ? `容器列表 (${selector})` : '容器列表');
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 selector ? '所有指定节点均未连接' : '未发现任何容器';
62
+ return '所有指定节点均未连接';
55
63
  }
56
64
  const lines = [];
57
65
  for (const { node, containers } of results) {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 日志指令
3
- * docker.logs <container> [node]
3
+ * docker.logs <node> <container>
4
4
  */
5
5
  import { Context } from 'koishi';
6
6
  import type { DockerControlConfig } from '../types';
@@ -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> [node]', '查看容器日志')
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
- .action(async ({ options }, container, node) => {
21
- logger_1.commandLogger.debug(`docker.logs 被调用: container=${container}, node=${node}, lines=${options.lines}, timestamp=${options.timestamp}`);
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 '请指定容器名或ID\n用法示例:\n docker.logs my-app\n docker.logs my-app node-1 -n 50';
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
- logger_1.commandLogger.debug(`日志参数: tail=${tail}, showTimestamp=${showTimestamp}`);
37
+ const showAll = options.all || false;
38
+ logger_1.commandLogger.debug(`日志参数: tail=${tail}, showTimestamp=${showTimestamp}, showAll=${showAll}`);
35
39
  try {
36
- let targetNode = null;
37
- let containerInfo = null;
38
- // 查找容器逻辑
39
- if (node) {
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
- return `=== ${targetNode.name}: ${getContainerName(containerInfo)} (最后 ${displayLines.length} 行) ===\n${cleanLogs}`;
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 `❌ 获取日志失败: ${e.message}`;
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<string>;
42
+ execContainer(containerId: string, cmd: string): Promise<{
43
+ output: string;
44
+ exitCode: number;
45
+ }>;
36
46
  /**
37
47
  * 监听 Docker 事件流
38
48
  * @param callback 每行事件数据的回调
@@ -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.exec(`docker exec ${containerId} sh -c '${escapedCmd}'`);
155
+ return this.execWithExitCode(`docker exec ${containerId} sh -c '${escapedCmd}'`);
119
156
  }
120
157
  /**
121
158
  * 监听 Docker 事件流
@@ -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
  }
@@ -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;
@@ -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<string>;
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
  */
@@ -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
- let lastError = null;
50
- while (attempt < constants_1.MAX_RETRY_COUNT) {
50
+ const MAX_INITIAL_ATTEMPTS = 3; // 前 3 次快速重试
51
+ const LONG_RETRY_INTERVAL = 60000; // 1 分钟
52
+ while (true) {
51
53
  attempt++;
52
- logger_1.nodeLogger.info(`[${this.name}] 连接尝试 ${attempt}/${constants_1.MAX_RETRY_COUNT}...`);
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
- if (attempt < constants_1.MAX_RETRY_COUNT) {
84
- await new Promise(resolve => setTimeout(resolve, constants_1.RETRY_INTERVAL));
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
  */
@@ -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 {};
@@ -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
- // 设置适当的视口,高度设大一点以便 content 自适应,然后截图 clip
180
+ // 1. 设置初始视口
171
181
  await page.setViewport({
172
- width: options.width || 700,
173
- height: options.height || 1000,
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
- // 获取 wrapper 的实际大小
180
- const clip = await wrapper?.boundingBox() || await body?.boundingBox();
181
- if (clip) {
182
- // 增加一点 padding 截图
183
- // clip.x -= 10
184
- // clip.y -= 10
185
- // clip.width += 20
186
- // clip.height += 20
187
- // 直接截取 content
188
- const buffer = await page.screenshot({ clip });
189
- return koishi_1.h.image(buffer, 'image/png').toString();
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: '重启次数', value: info.RestartCount },
300
- { label: 'IP 地址', value: info.NetworkSettings?.IPAddress || '-' },
301
- { label: '平台', value: info.Platform || 'linux' },
302
- { label: '驱动', value: info.Driver },
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
- const onlineCount = nodes.filter(n => n.status === 'connected').length;
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 isOnline = n.status === 'connected';
346
- const isConnecting = n.status === 'connecting';
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.name}</div>
354
- <div style="font-size:12px; opacity:0.6; margin-top:2px;">${n.id}</div>
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')}">${n.status}</div>
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
- const isOnline = node.status === 'connected';
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: node.name },
379
- { label: '节点 ID', value: node.id },
380
- { label: '状态', value: node.status, highlight: isOnline },
381
- { label: '标签', value: node.tags.join(', ') || '(无)' },
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})` }, { label: '内核版本', value: version.KernelVersion }, { label: 'Go 版本', value: version.GoVersion }, { label: 'Git Commit', value: version.GitCommit }, { label: '构建时间', value: version.BuildTime });
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">${node.name}</div>
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;">${node.name}</div>
406
- <div style="font-size: 13px; color: #94a3b8; font-family: monospace;">${node.id}</div>
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, '&amp;')
577
+ .replace(/</g, '&lt;')
578
+ .replace(/>/g, '&gt;')
579
+ .replace(/"/g, '&quot;')
580
+ .replace(/'/g, '&#039;');
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
+ }
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.3",
4
+ "version": "0.0.4",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [