koishi-plugin-docker-control 0.0.2 → 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.d.ts +1 -1
- package/lib/commands/control.js +45 -6
- package/lib/commands/index.js +48 -60
- package/lib/commands/list.js +13 -155
- package/lib/commands/logs.d.ts +1 -1
- package/lib/commands/logs.js +29 -39
- package/lib/index.js +1 -1
- 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 +38 -1
- package/lib/service/node.js +97 -11
- package/lib/utils/render.d.ts +48 -0
- package/lib/utils/render.js +682 -0
- package/package.json +2 -2
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderToImage = renderToImage;
|
|
4
|
+
exports.generateListHtml = generateListHtml;
|
|
5
|
+
exports.generateResultHtml = generateResultHtml;
|
|
6
|
+
exports.generateInspectHtml = generateInspectHtml;
|
|
7
|
+
exports.generateNodesHtml = generateNodesHtml;
|
|
8
|
+
exports.generateNodeDetailHtml = generateNodeDetailHtml;
|
|
9
|
+
exports.generateLogsHtml = generateLogsHtml;
|
|
10
|
+
exports.generateExecHtml = generateExecHtml;
|
|
11
|
+
const koishi_1 = require("koishi");
|
|
12
|
+
// 基础样式
|
|
13
|
+
const STYLE = `
|
|
14
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
15
|
+
body {
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
17
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
padding: 24px;
|
|
20
|
+
color: #e2e8f0;
|
|
21
|
+
line-height: 1.5;
|
|
22
|
+
}
|
|
23
|
+
.wrapper {
|
|
24
|
+
max-width: 800px;
|
|
25
|
+
margin: 0 auto;
|
|
26
|
+
background: rgba(30, 41, 59, 0.7);
|
|
27
|
+
backdrop-filter: blur(12px);
|
|
28
|
+
border-radius: 16px;
|
|
29
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
32
|
+
}
|
|
33
|
+
.header {
|
|
34
|
+
background: rgba(51, 65, 85, 0.5);
|
|
35
|
+
padding: 16px 24px;
|
|
36
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: space-between;
|
|
40
|
+
}
|
|
41
|
+
.header-title {
|
|
42
|
+
font-size: 18px;
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
color: #f8fafc;
|
|
45
|
+
}
|
|
46
|
+
.header-badge {
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
padding: 4px 12px;
|
|
49
|
+
border-radius: 9999px;
|
|
50
|
+
background: rgba(255, 255, 255, 0.1);
|
|
51
|
+
color: #cbd5e1;
|
|
52
|
+
}
|
|
53
|
+
.content {
|
|
54
|
+
padding: 24px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* 表格/列表样式 */
|
|
58
|
+
.list-item {
|
|
59
|
+
display: grid;
|
|
60
|
+
grid-template-columns: 48px 2fr 1.5fr 1fr;
|
|
61
|
+
gap: 16px;
|
|
62
|
+
padding: 16px;
|
|
63
|
+
border-radius: 8px;
|
|
64
|
+
align-items: center;
|
|
65
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
66
|
+
transition: background 0.2s;
|
|
67
|
+
}
|
|
68
|
+
.list-item:last-child {
|
|
69
|
+
border-bottom: none;
|
|
70
|
+
}
|
|
71
|
+
.list-item:hover {
|
|
72
|
+
background: rgba(255, 255, 255, 0.05);
|
|
73
|
+
}
|
|
74
|
+
.list-header {
|
|
75
|
+
font-size: 13px;
|
|
76
|
+
color: #94a3b8;
|
|
77
|
+
text-transform: uppercase;
|
|
78
|
+
letter-spacing: 0.05em;
|
|
79
|
+
padding: 0 16px 12px;
|
|
80
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
81
|
+
margin-bottom: 8px;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* 状态样式 */
|
|
85
|
+
.status-icon { font-size: 20px; }
|
|
86
|
+
.name-col { font-weight: 500; color: #fff; }
|
|
87
|
+
.meta-col { font-size: 13px; color: #94a3b8; font-family: 'SF Mono', Monaco, monospace; }
|
|
88
|
+
.tag {
|
|
89
|
+
display: inline-block;
|
|
90
|
+
padding: 2px 8px;
|
|
91
|
+
border-radius: 4px;
|
|
92
|
+
font-size: 12px;
|
|
93
|
+
background: rgba(255, 255, 255, 0.1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Inspect 详情样式 */
|
|
97
|
+
.detail-grid {
|
|
98
|
+
display: grid;
|
|
99
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
100
|
+
gap: 20px;
|
|
101
|
+
}
|
|
102
|
+
.detail-card {
|
|
103
|
+
background: rgba(0, 0, 0, 0.2);
|
|
104
|
+
border-radius: 12px;
|
|
105
|
+
padding: 20px;
|
|
106
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
107
|
+
}
|
|
108
|
+
.detail-item {
|
|
109
|
+
margin-bottom: 12px;
|
|
110
|
+
}
|
|
111
|
+
.detail-item:last-child { margin-bottom: 0; }
|
|
112
|
+
.detail-label {
|
|
113
|
+
font-size: 13px;
|
|
114
|
+
color: #94a3b8;
|
|
115
|
+
margin-bottom: 4px;
|
|
116
|
+
}
|
|
117
|
+
.detail-value {
|
|
118
|
+
font-size: 15px;
|
|
119
|
+
color: #e2e8f0;
|
|
120
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
121
|
+
word-break: break-all;
|
|
122
|
+
}
|
|
123
|
+
.detail-value.highlight {
|
|
124
|
+
color: #60a5fa;
|
|
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
|
+
}
|
|
134
|
+
|
|
135
|
+
/* 操作结果样式 */
|
|
136
|
+
.result-card {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
padding: 16px;
|
|
140
|
+
background: rgba(255, 255, 255, 0.03);
|
|
141
|
+
border-radius: 8px;
|
|
142
|
+
margin-bottom: 12px;
|
|
143
|
+
border-left: 4px solid #64748b;
|
|
144
|
+
}
|
|
145
|
+
.result-card.success { border-left-color: #4ade80; background: rgba(74, 222, 128, 0.1); }
|
|
146
|
+
.result-card.error { border-left-color: #f87171; background: rgba(248, 113, 113, 0.1); }
|
|
147
|
+
.result-icon {
|
|
148
|
+
font-size: 24px;
|
|
149
|
+
margin-right: 16px;
|
|
150
|
+
}
|
|
151
|
+
.result-info { flex: 1; }
|
|
152
|
+
.result-title { font-weight: 600; margin-bottom: 4px; }
|
|
153
|
+
.result-msg { font-size: 13px; color: #cbd5e1; }
|
|
154
|
+
`;
|
|
155
|
+
/**
|
|
156
|
+
* 包装 HTML
|
|
157
|
+
*/
|
|
158
|
+
function wrapHtml(content, style = STYLE) {
|
|
159
|
+
return `<!DOCTYPE html>
|
|
160
|
+
<html>
|
|
161
|
+
<head>
|
|
162
|
+
<meta charset="UTF-8">
|
|
163
|
+
<style>${style}</style>
|
|
164
|
+
</head>
|
|
165
|
+
<body>
|
|
166
|
+
<div class="wrapper">
|
|
167
|
+
${content}
|
|
168
|
+
</div>
|
|
169
|
+
</body>
|
|
170
|
+
</html>`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 通用渲染函数:将 HTML 转换为图片
|
|
174
|
+
*/
|
|
175
|
+
async function renderToImage(ctx, html, options = {}) {
|
|
176
|
+
if (!ctx.puppeteer) {
|
|
177
|
+
throw new Error('未安装 koishi-plugin-puppeteer 插件');
|
|
178
|
+
}
|
|
179
|
+
return ctx.puppeteer.render(html, async (page, next) => {
|
|
180
|
+
// 1. 设置初始视口
|
|
181
|
+
await page.setViewport({
|
|
182
|
+
width: options.width || 800,
|
|
183
|
+
height: options.height || 100,
|
|
184
|
+
deviceScaleFactor: 2
|
|
185
|
+
});
|
|
186
|
+
// 2. 等待内容渲染
|
|
187
|
+
const body = await page.$('body');
|
|
188
|
+
const wrapper = await page.$('.wrapper');
|
|
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
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Fallback
|
|
206
|
+
const buffer = await page.screenshot({ fullPage: true });
|
|
207
|
+
return koishi_1.h.image(buffer, 'image/png').toString();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 生成容器列表 HTML
|
|
212
|
+
*/
|
|
213
|
+
function generateListHtml(data, title = '容器列表') {
|
|
214
|
+
let stats = { running: 0, stopped: 0, total: 0 };
|
|
215
|
+
const content = data.map(({ node, containers }) => {
|
|
216
|
+
const nodeStats = {
|
|
217
|
+
running: containers.filter(c => c.State === 'running').length,
|
|
218
|
+
total: containers.length
|
|
219
|
+
};
|
|
220
|
+
stats.running += nodeStats.running;
|
|
221
|
+
stats.total += nodeStats.total;
|
|
222
|
+
stats.stopped += (nodeStats.total - nodeStats.running);
|
|
223
|
+
const listItems = containers.length === 0
|
|
224
|
+
? `<div style="padding: 20px; text-align: center; color: #64748b;">(暂无容器)</div>`
|
|
225
|
+
: containers.map(c => {
|
|
226
|
+
const isRunning = c.State === 'running';
|
|
227
|
+
const icon = isRunning ? '🟢' : (c.State === 'stopped' ? '🔴' : '⚪');
|
|
228
|
+
const name = c.Names[0]?.replace('/', '') || 'Unknown';
|
|
229
|
+
const shortId = c.Id.slice(0, 12);
|
|
230
|
+
const image = c.Image.split('/').pop() || c.Image;
|
|
231
|
+
return `
|
|
232
|
+
<div class="list-item">
|
|
233
|
+
<div class="status-icon">${icon}</div>
|
|
234
|
+
<div class="name-col">
|
|
235
|
+
<div>${name}</div>
|
|
236
|
+
<div style="font-size:12px; opacity:0.6; margin-top:2px;">${c.Status}</div>
|
|
237
|
+
</div>
|
|
238
|
+
<div class="meta-col">
|
|
239
|
+
<div>ID: ${shortId}</div>
|
|
240
|
+
<div style="color: #64748b; margin-top:2px;">${image}</div>
|
|
241
|
+
</div>
|
|
242
|
+
<div style="text-align: right;">
|
|
243
|
+
<span class="tag" style="background: ${isRunning ? 'rgba(74, 222, 128, 0.1); color: #4ade80' : 'rgba(248, 113, 113, 0.1); color: #f87171'}">${c.State}</span>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
`;
|
|
247
|
+
}).join('');
|
|
248
|
+
return `
|
|
249
|
+
<div style="margin-bottom: 24px;">
|
|
250
|
+
<div style="padding: 12px 16px; background: rgba(0,0,0,0.2); border-radius: 8px 8px 0 0; font-weight: 500; border-bottom: 1px solid rgba(255,255,255,0.05); display: flex; justify-content: space-between;">
|
|
251
|
+
<span>📦 ${node.name}</span>
|
|
252
|
+
<span style="font-size: 13px; opacity: 0.7;">${nodeStats.running} / ${nodeStats.total} 运行中</span>
|
|
253
|
+
</div>
|
|
254
|
+
<div style="background: rgba(0,0,0,0.1); border-radius: 0 0 8px 8px;">
|
|
255
|
+
${listItems}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
`;
|
|
259
|
+
}).join('');
|
|
260
|
+
const header = `
|
|
261
|
+
<div class="header">
|
|
262
|
+
<div class="header-title">${title}</div>
|
|
263
|
+
<div class="header-badge">Total: ${stats.running} running / ${stats.total} total</div>
|
|
264
|
+
</div>
|
|
265
|
+
`;
|
|
266
|
+
return wrapHtml(header + '<div class="content">' + content + '</div>');
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* 生成操作结果 HTML (启动/停止/重启)
|
|
270
|
+
*/
|
|
271
|
+
function generateResultHtml(results, title) {
|
|
272
|
+
const successCount = results.filter(r => r.success).length;
|
|
273
|
+
const failCount = results.length - successCount;
|
|
274
|
+
const items = results.map(r => {
|
|
275
|
+
const isSuccess = r.success;
|
|
276
|
+
const icon = isSuccess ? '✅' : '❌';
|
|
277
|
+
const name = r.container?.Names?.[0]?.replace('/', '') || r.container?.Id?.slice(0, 8) || 'Unknown';
|
|
278
|
+
const message = r.error || (isSuccess ? '操作成功' : '操作失败');
|
|
279
|
+
return `
|
|
280
|
+
<div class="result-card ${isSuccess ? 'success' : 'error'}">
|
|
281
|
+
<div class="result-icon">${icon}</div>
|
|
282
|
+
<div class="result-info">
|
|
283
|
+
<div class="result-title">${r.node.name}: ${name}</div>
|
|
284
|
+
<div class="result-msg">${message}</div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
`;
|
|
288
|
+
}).join('');
|
|
289
|
+
const header = `
|
|
290
|
+
<div class="header">
|
|
291
|
+
<div class="header-title">${title}</div>
|
|
292
|
+
<div class="header-badge" style="background: ${failCount > 0 ? 'rgba(248, 113, 113, 0.2); color: #fca5a5' : 'rgba(74, 222, 128, 0.2); color: #86efac'}">
|
|
293
|
+
成功: ${successCount} | 失败: ${failCount}
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
`;
|
|
297
|
+
return wrapHtml(header + '<div class="content">' + items + '</div>');
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* 生成详情 HTML
|
|
301
|
+
*/
|
|
302
|
+
function generateInspectHtml(nodeName, info) {
|
|
303
|
+
const name = info.Name.replace('/', '');
|
|
304
|
+
const shortId = info.Id.slice(0, 12);
|
|
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
|
+
: '-';
|
|
337
|
+
const items = [
|
|
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 },
|
|
349
|
+
];
|
|
350
|
+
if (info.State.Health) {
|
|
351
|
+
items.push({ label: '健康状态', value: info.State.Health.Status, highlight: true, span: false });
|
|
352
|
+
}
|
|
353
|
+
const gridItems = items.map(item => `
|
|
354
|
+
<div class="detail-item ${item.span ? 'detail-span' : ''}">
|
|
355
|
+
<div class="detail-label">${item.label}</div>
|
|
356
|
+
<div class="detail-value ${item.highlight ? 'highlight' : ''}">${item.value.replace(/\n/g, '<br>')}</div>
|
|
357
|
+
</div>
|
|
358
|
+
`).join('');
|
|
359
|
+
const header = `
|
|
360
|
+
<div class="header">
|
|
361
|
+
<div class="header-title">容器详情</div>
|
|
362
|
+
<div class="header-badge">${nodeName}</div>
|
|
363
|
+
</div>
|
|
364
|
+
`;
|
|
365
|
+
const body = `
|
|
366
|
+
<div class="content">
|
|
367
|
+
<div class="detail-card">
|
|
368
|
+
<div style="display: flex; align-items: center; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
|
369
|
+
<div style="font-size: 32px; margin-right: 16px;">${isRunning ? '🟢' : '🔴'}</div>
|
|
370
|
+
<div>
|
|
371
|
+
<div style="font-size: 20px; font-weight: 600;">${name}</div>
|
|
372
|
+
<div style="font-size: 13px; color: #94a3b8; font-family: monospace;">${shortId}</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
<div class="detail-grid">
|
|
376
|
+
${gridItems}
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
`;
|
|
381
|
+
return wrapHtml(header + body);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* 生成节点列表 HTML
|
|
385
|
+
*/
|
|
386
|
+
function generateNodesHtml(nodes) {
|
|
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;
|
|
392
|
+
const totalCount = nodes.length;
|
|
393
|
+
const listItems = nodes.map(n => {
|
|
394
|
+
const status = getStatus(n);
|
|
395
|
+
const isOnline = status === 'connected' || status === 'running';
|
|
396
|
+
const isConnecting = status === 'connecting';
|
|
397
|
+
const icon = isOnline ? '🟢' : (isConnecting ? '🟡' : '🔴');
|
|
398
|
+
const tags = (n.tags || []).map((t) => `<span class="tag">@${t}</span>`).join(' ');
|
|
399
|
+
return `
|
|
400
|
+
<div class="list-item">
|
|
401
|
+
<div class="status-icon">${icon}</div>
|
|
402
|
+
<div class="name-col">
|
|
403
|
+
<div>${getName(n)}</div>
|
|
404
|
+
<div style="font-size:12px; opacity:0.6; margin-top:2px;">${getId(n)}</div>
|
|
405
|
+
</div>
|
|
406
|
+
<div class="meta-col">
|
|
407
|
+
<div style="color: ${isOnline ? '#4ade80' : (isConnecting ? '#facc15' : '#f87171')}">${status}</div>
|
|
408
|
+
</div>
|
|
409
|
+
<div>${tags}</div>
|
|
410
|
+
</div>
|
|
411
|
+
`;
|
|
412
|
+
}).join('');
|
|
413
|
+
const header = `
|
|
414
|
+
<div class="header">
|
|
415
|
+
<div class="header-title">节点列表</div>
|
|
416
|
+
<div class="header-badge" style="background: rgba(74, 222, 128, 0.1); color: #4ade80">在线: ${onlineCount} / ${totalCount}</div>
|
|
417
|
+
</div>
|
|
418
|
+
`;
|
|
419
|
+
return wrapHtml(header + '<div class="content"><div style="background: rgba(0,0,0,0.2); border-radius: 8px;">' + listItems + '</div></div>');
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* 生成节点详情 HTML
|
|
423
|
+
*/
|
|
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 : '-';
|
|
439
|
+
// 基础信息
|
|
440
|
+
const items = [
|
|
441
|
+
{ label: '节点名称', value: nodeName },
|
|
442
|
+
{ label: '节点 ID', value: nodeId },
|
|
443
|
+
{ label: '状态', value: nodeStatus, highlight: isOnline },
|
|
444
|
+
{ label: '标签', value: (nodeTags || []).join(', ') || '(无)' },
|
|
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
|
+
}
|
|
452
|
+
// 版本信息
|
|
453
|
+
if (version) {
|
|
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 || '-' });
|
|
455
|
+
}
|
|
456
|
+
const gridItems = items.map(item => `
|
|
457
|
+
<div class="detail-item">
|
|
458
|
+
<div class="detail-label">${item.label}</div>
|
|
459
|
+
<div class="detail-value ${item.highlight ? 'highlight' : ''}">${item.value}</div>
|
|
460
|
+
</div>
|
|
461
|
+
`).join('');
|
|
462
|
+
const header = `
|
|
463
|
+
<div class="header">
|
|
464
|
+
<div class="header-title">节点详情</div>
|
|
465
|
+
<div class="header-badge">${nodeName}</div>
|
|
466
|
+
</div>
|
|
467
|
+
`;
|
|
468
|
+
const body = `
|
|
469
|
+
<div class="content">
|
|
470
|
+
<div class="detail-card">
|
|
471
|
+
<div style="display: flex; align-items: center; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
|
472
|
+
<div style="font-size: 32px; margin-right: 16px;">${isOnline ? '🟢' : '🔴'}</div>
|
|
473
|
+
<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>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
<div class="detail-grid">
|
|
479
|
+
${gridItems}
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
`;
|
|
484
|
+
return wrapHtml(header + body);
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-docker-control",
|
|
3
3
|
"description": "Koishi 插件 - 通过 SSH 控制 Docker 容器",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.4",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -33,4 +33,4 @@
|
|
|
33
33
|
"@types/ssh2": "^1.15.5",
|
|
34
34
|
"typescript": "^5.9.3"
|
|
35
35
|
}
|
|
36
|
-
}
|
|
36
|
+
}
|