@jacksontian/kite-server 0.3.0 → 0.4.0
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/README.md +44 -8
- package/bin/server.js +191 -0
- package/config.example.yaml +13 -3
- package/deploy/kiteserver.service +2 -2
- package/lib/api.js +209 -0
- package/lib/auth.js +130 -0
- package/lib/broadcast.js +31 -0
- package/lib/cli/args.js +83 -0
- package/lib/cli/deploy.js +94 -0
- package/lib/cli/init.js +208 -0
- package/lib/cli/keygen.js +23 -0
- package/lib/cli/meta.js +56 -0
- package/lib/cli/util.js +44 -0
- package/lib/config.js +280 -0
- package/lib/im.js +70 -0
- package/lib/logger.js +129 -0
- package/lib/pty.js +55 -0
- package/lib/static.js +51 -0
- package/lib/store.js +115 -0
- package/lib/summary.js +29 -0
- package/lib/util.js +37 -0
- package/lib/websocket.js +261 -0
- package/package.json +22 -6
- package/web/index.html +253 -217
- package/web/pc.html +440 -214
- package/config.js +0 -540
- package/server.js +0 -1103
package/web/pc.html
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
body {
|
|
19
19
|
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
|
|
20
20
|
background: var(--bg); color: var(--text);
|
|
21
|
-
padding: 20px
|
|
22
|
-
max-width:
|
|
21
|
+
padding: 20px 32px 40px;
|
|
22
|
+
max-width: 1600px; margin: 0 auto;
|
|
23
23
|
}
|
|
24
24
|
header { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px 16px; gap: 8px; }
|
|
25
25
|
h1 { font-size: 20px; font-weight: 600; }
|
|
@@ -37,10 +37,14 @@
|
|
|
37
37
|
border-radius: 10px; padding: 10px 14px; font-size: 13px; line-height: 1.6; margin-bottom: 16px;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/*
|
|
41
|
-
|
|
40
|
+
/* 双栏桌面布局:左栏提交任务,右栏任务流;
|
|
41
|
+
一级视图(.one-col)退化为全宽单栏,Worker 卡片横向铺开,不挤在窄左栏 */
|
|
42
|
+
.layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 18px; align-items: start; }
|
|
43
|
+
.layout.one-col { grid-template-columns: minmax(0, 1fr); }
|
|
42
44
|
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
|
43
45
|
.col-left { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 16px; }
|
|
46
|
+
/* 全宽单栏时密钥面板拉通一行太长,限个舒适宽度 */
|
|
47
|
+
.layout.one-col #auth-panel { max-width: 640px; }
|
|
44
48
|
|
|
45
49
|
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }
|
|
46
50
|
label { display: block; font-size: 12px; color: var(--dim); margin: 12px 0 5px; }
|
|
@@ -52,13 +56,6 @@
|
|
|
52
56
|
textarea { min-height: 84px; resize: vertical; }
|
|
53
57
|
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
|
|
54
58
|
|
|
55
|
-
.types { display: flex; gap: 8px; margin-top: 4px; }
|
|
56
|
-
.types button {
|
|
57
|
-
flex: 1; padding: 8px 0; border-radius: 8px; border: 1px solid var(--line);
|
|
58
|
-
background: var(--bg); color: var(--dim); font-size: 13px; cursor: pointer;
|
|
59
|
-
}
|
|
60
|
-
.types button.active { border-color: var(--accent); color: var(--accent); background: rgba(59,130,246,.12); }
|
|
61
|
-
|
|
62
59
|
.submit {
|
|
63
60
|
width: 100%; margin-top: 16px; padding: 11px;
|
|
64
61
|
background: var(--accent); color: #fff; border: none; border-radius: 10px;
|
|
@@ -80,19 +77,10 @@
|
|
|
80
77
|
.logs {
|
|
81
78
|
margin-top: 10px; background: #0b1220; border-radius: 8px; padding: 10px;
|
|
82
79
|
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.6;
|
|
83
|
-
max-height:
|
|
80
|
+
max-height: min(60vh, 640px); overflow-y: auto; white-space: pre-wrap; word-break: break-all;
|
|
84
81
|
color: #a5f3fc; display: none;
|
|
85
82
|
}
|
|
86
83
|
.task.open .logs { display: block; }
|
|
87
|
-
/* shell 视图:默认只露出命令回显 + 末尾 5 行 stdout,接近终端体验;
|
|
88
|
-
元信息/结果/更早输出要点「展开」才可见 */
|
|
89
|
-
.term {
|
|
90
|
-
margin-top: 10px; background: #0b1220; border-radius: 8px; padding: 10px;
|
|
91
|
-
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.6;
|
|
92
|
-
white-space: pre-wrap; word-break: break-all; color: #a5f3fc; cursor: pointer;
|
|
93
|
-
}
|
|
94
|
-
.term .prompt { color: var(--ok); }
|
|
95
|
-
.term .dimline { color: var(--dim); }
|
|
96
84
|
.task .actions { margin-top: 10px; display: flex; gap: 8px; }
|
|
97
85
|
.task .actions button {
|
|
98
86
|
font-size: 13px; padding: 5px 16px; border-radius: 6px;
|
|
@@ -107,11 +95,46 @@
|
|
|
107
95
|
display: flex; flex-direction: column;
|
|
108
96
|
}
|
|
109
97
|
#term-overlay.hidden { display: none; }
|
|
110
|
-
#term-head { display: flex; align-items: center;
|
|
111
|
-
|
|
112
|
-
#term-
|
|
98
|
+
#term-head { display: flex; align-items: center; gap: 10px; padding: 8px 16px; }
|
|
99
|
+
/* tab 条:每个打开的终端一个 tab,非当前终端有新输出时亮活动圆点 */
|
|
100
|
+
#term-tabs { flex: 1; display: flex; gap: 6px; overflow-x: auto; min-width: 0; }
|
|
101
|
+
.term-tab {
|
|
102
|
+
display: flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 8px;
|
|
103
|
+
border: 1px solid var(--line); background: var(--panel); color: var(--dim);
|
|
104
|
+
font-size: 13px; cursor: pointer; white-space: nowrap; max-width: 220px;
|
|
105
|
+
}
|
|
106
|
+
.term-tab .tt-title { overflow: hidden; text-overflow: ellipsis; }
|
|
107
|
+
.term-tab.active { border-color: var(--accent); color: var(--text); background: rgba(59,130,246,.12); }
|
|
108
|
+
.term-tab .tt-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--warn); display: none; }
|
|
109
|
+
.term-tab.dirty .tt-dot { display: block; }
|
|
110
|
+
#term-layout-btns { display: flex; gap: 6px; }
|
|
111
|
+
#term-layout-btns button {
|
|
112
|
+
font-size: 12px; padding: 4px 12px; border-radius: 6px; cursor: pointer;
|
|
113
|
+
background: var(--panel); color: var(--dim); border: 1px solid var(--line);
|
|
114
|
+
}
|
|
115
|
+
#term-layout-btns button.active { border-color: var(--accent); color: var(--accent); background: rgba(59,130,246,.12); }
|
|
113
116
|
#term-close { font-size: 13px; padding: 5px 16px; border-radius: 6px; background: var(--panel); color: var(--text); border: 1px solid var(--line); cursor: pointer; }
|
|
114
|
-
|
|
117
|
+
/* 单屏模式:pane 绝对定位叠层,只显示 active 的一个;
|
|
118
|
+
并排(grid)模式:所有终端按 CSS grid 平铺,同时盯多个 pty */
|
|
119
|
+
#term-body { flex: 1; margin: 0 16px 16px; min-height: 0; position: relative; }
|
|
120
|
+
#term-body.grid {
|
|
121
|
+
display: grid; gap: 10px;
|
|
122
|
+
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
|
123
|
+
grid-auto-rows: minmax(0, 1fr);
|
|
124
|
+
}
|
|
125
|
+
.term-pane { position: absolute; inset: 0; display: none; flex-direction: column; }
|
|
126
|
+
.term-pane.active { display: flex; }
|
|
127
|
+
#term-body.grid .term-pane {
|
|
128
|
+
position: relative; inset: auto; display: flex; min-height: 0;
|
|
129
|
+
border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
|
|
130
|
+
}
|
|
131
|
+
#term-body.grid .term-pane.active { border-color: var(--accent); }
|
|
132
|
+
.tp-head { display: flex; align-items: center; gap: 10px; padding: 6px 10px; background: var(--panel); flex: none; }
|
|
133
|
+
.tp-title { font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 30%; }
|
|
134
|
+
.tp-status { flex: 1; font-size: 11px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
135
|
+
.tp-close { flex: none; border: none; background: none; color: var(--dim); font-size: 14px; cursor: pointer; padding: 0 4px; }
|
|
136
|
+
.tp-close:hover { color: var(--err); }
|
|
137
|
+
.tp-term { flex: 1; min-height: 0; }
|
|
115
138
|
#auth-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
|
|
116
139
|
#auth-bar .title { font-size: 13px; color: var(--ok); }
|
|
117
140
|
#auth-bar .edit { font-size: 12px; color: var(--dim); }
|
|
@@ -130,6 +153,27 @@
|
|
|
130
153
|
background: #000c; color: #fff; padding: 9px 18px; border-radius: 8px;
|
|
131
154
|
font-size: 13px; opacity: 0; transition: opacity .2s; pointer-events: none;
|
|
132
155
|
}
|
|
156
|
+
/* 两级视图:一级 Worker 列表 / 二级所选 Worker 详情 */
|
|
157
|
+
#back-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
|
158
|
+
#btn-back {
|
|
159
|
+
padding: 6px 14px; border: 1px solid var(--line); border-radius: 8px;
|
|
160
|
+
background: var(--panel); color: var(--text); font-size: 13px; cursor: pointer;
|
|
161
|
+
}
|
|
162
|
+
#btn-back:hover { border-color: var(--accent); color: var(--accent); }
|
|
163
|
+
#worker-title { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
164
|
+
#worker-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
|
165
|
+
.worker-card {
|
|
166
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
|
167
|
+
padding: 14px; cursor: pointer; transition: border-color .15s;
|
|
168
|
+
}
|
|
169
|
+
.worker-card:hover { border-color: var(--accent); }
|
|
170
|
+
.worker-card .wc-row1 { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
|
171
|
+
.worker-card .wc-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
172
|
+
.worker-card .wc-meta { font-size: 12px; color: var(--dim); margin-top: 6px; line-height: 1.5; word-break: break-all; }
|
|
173
|
+
.w-online { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,.15); color: var(--ok); white-space: nowrap; }
|
|
174
|
+
.w-offline { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(239,68,68,.15); color: var(--err); white-space: nowrap; }
|
|
175
|
+
.empty-tip { color: var(--dim); font-size: 13px; text-align: center; padding: 40px 0; }
|
|
176
|
+
|
|
133
177
|
.hidden { display: none !important; }
|
|
134
178
|
</style>
|
|
135
179
|
</head>
|
|
@@ -148,7 +192,7 @@
|
|
|
148
192
|
请勿在不信任的网络(如公共 WiFi)使用,并尽快为服务端配置 HTTPS(如 Caddy 自动证书)。
|
|
149
193
|
</div>
|
|
150
194
|
|
|
151
|
-
<div class="layout">
|
|
195
|
+
<div class="layout one-col" id="layout">
|
|
152
196
|
<div class="col-left">
|
|
153
197
|
|
|
154
198
|
<!-- 鉴权设置:密钥已存 localStorage 时折叠成一行,点「修改」才展开 -->
|
|
@@ -167,62 +211,48 @@
|
|
|
167
211
|
</div>
|
|
168
212
|
</div>
|
|
169
213
|
|
|
170
|
-
<!--
|
|
171
|
-
<div
|
|
172
|
-
<div
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
214
|
+
<!-- 一级视图:Worker 列表,选中某个 Worker 进入二级详情 -->
|
|
215
|
+
<div id="view-workers">
|
|
216
|
+
<div id="worker-cards"></div>
|
|
217
|
+
<div id="workers-empty" class="empty-tip hidden">暂无已注册 Worker</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<!-- 二级视图:所选 Worker 的新建任务 -->
|
|
221
|
+
<div id="view-worker" class="hidden">
|
|
222
|
+
<div id="back-row">
|
|
223
|
+
<button id="btn-back" type="button">‹ Worker 列表</button>
|
|
224
|
+
<span id="worker-title"></span>
|
|
178
225
|
</div>
|
|
179
226
|
|
|
227
|
+
<!-- 新建任务(仅 pty 交互终端) -->
|
|
228
|
+
<div class="panel">
|
|
180
229
|
<label>标题</label>
|
|
181
230
|
<input id="f-title" placeholder="一句话描述这个任务">
|
|
182
231
|
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<label>Prompt(交给编码 Agent 的指令)</label>
|
|
187
|
-
<textarea id="f-prompt" placeholder="例:把 README 里的安装步骤补全,然后跑一遍测试"></textarea>
|
|
188
|
-
<label>项目目录名(相对 workspace)</label>
|
|
189
|
-
<input id="f-project" value="default">
|
|
190
|
-
</div>
|
|
191
|
-
<div id="fields-shell" class="hidden">
|
|
192
|
-
<label>命令(在 workspace 目录执行)</label>
|
|
193
|
-
<textarea id="f-command" placeholder="例:git -C myrepo pull && npm test"></textarea>
|
|
194
|
-
</div>
|
|
195
|
-
<div id="fields-pty" class="hidden">
|
|
196
|
-
<label>项目目录名(相对 workspace,终端的起始目录)</label>
|
|
197
|
-
<input id="f-pty-project" value="default">
|
|
198
|
-
<label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
|
|
199
|
-
</div>
|
|
200
|
-
<div id="fields-script" class="hidden">
|
|
201
|
-
<label>脚本名(worker/scripts 目录下的白名单脚本)</label>
|
|
202
|
-
<input id="f-script" placeholder="例:backup.sh">
|
|
203
|
-
<label>参数(空格分隔)</label>
|
|
204
|
-
<input id="f-args" placeholder="例:--full /tmp/out">
|
|
205
|
-
</div>
|
|
206
|
-
<div id="fields-chat" class="hidden">
|
|
207
|
-
<label>留言内容</label>
|
|
208
|
-
<textarea id="f-message" placeholder="记一条备忘,或触发 CHAT_HOOK"></textarea>
|
|
209
|
-
</div>
|
|
232
|
+
<label>项目目录(终端的起始目录,Worker workspace 下已有目录)</label>
|
|
233
|
+
<select id="f-pty-project"><option value="default">default</option></select>
|
|
234
|
+
<label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
|
|
210
235
|
|
|
211
236
|
<button class="submit" id="btn-submit">提交任务</button>
|
|
212
|
-
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div><!-- /view-worker -->
|
|
213
239
|
|
|
214
240
|
</div><!-- /.col-left -->
|
|
215
241
|
|
|
216
|
-
<!--
|
|
217
|
-
<div id="task-list"></div>
|
|
242
|
+
<!-- 任务列表(仅所选 Worker,进入二级视图后显示在右侧) -->
|
|
243
|
+
<div id="task-list" class="hidden"></div>
|
|
218
244
|
|
|
219
245
|
</div><!-- /.layout -->
|
|
220
246
|
|
|
221
|
-
<!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY
|
|
247
|
+
<!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY。
|
|
248
|
+
tab 条 + 单屏/并排布局切换(并排时多个 pty 同屏平铺),活动圆点标记非当前终端的新输出 -->
|
|
222
249
|
<div id="term-overlay" class="hidden">
|
|
223
250
|
<div id="term-head">
|
|
224
|
-
<
|
|
225
|
-
<
|
|
251
|
+
<div id="term-tabs"></div>
|
|
252
|
+
<div id="term-layout-btns">
|
|
253
|
+
<button type="button" data-layout="single">单屏</button>
|
|
254
|
+
<button type="button" data-layout="grid">并排</button>
|
|
255
|
+
</div>
|
|
226
256
|
<button id="term-close" type="button">收起</button>
|
|
227
257
|
</div>
|
|
228
258
|
<div id="term-body"></div>
|
|
@@ -334,7 +364,17 @@ function signHeaders(method, path, bodyStr) {
|
|
|
334
364
|
return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
|
|
335
365
|
}
|
|
336
366
|
|
|
367
|
+
// 未配置密钥时禁止发起任何需要签名的请求:空密钥签名必 401,
|
|
368
|
+
// 还会白刷服务端验签失败限流计数,统一在本地拦截
|
|
369
|
+
function hasSecret() {
|
|
370
|
+
return secretInput.value.trim() !== '';
|
|
371
|
+
}
|
|
372
|
+
function requireSecret() {
|
|
373
|
+
if (!hasSecret()) throw new Error('请先配置 API Secret');
|
|
374
|
+
}
|
|
375
|
+
|
|
337
376
|
async function api(path, body) {
|
|
377
|
+
requireSecret();
|
|
338
378
|
const method = body === undefined ? 'GET' : 'POST';
|
|
339
379
|
const bodyStr = body === undefined ? '' : JSON.stringify(body);
|
|
340
380
|
const res = await fetch(path, {
|
|
@@ -361,7 +401,10 @@ let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变
|
|
|
361
401
|
secretInput.addEventListener('change', () => {
|
|
362
402
|
localStorage.setItem('rw_secret', secretInput.value.trim());
|
|
363
403
|
wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
|
|
364
|
-
if (
|
|
404
|
+
if (hasSecret()) {
|
|
405
|
+
if (!wsReady) connectWS();
|
|
406
|
+
refresh(); // 之前无密钥没拉过数据,配好后补一次
|
|
407
|
+
}
|
|
365
408
|
});
|
|
366
409
|
|
|
367
410
|
// 密钥已配置时收起输入区,只留一行状态条;点状态条可展开修改
|
|
@@ -404,7 +447,7 @@ function setConnMode(realtime) {
|
|
|
404
447
|
}
|
|
405
448
|
|
|
406
449
|
function connectWS() {
|
|
407
|
-
if (wsAuthRejected) return;
|
|
450
|
+
if (wsAuthRejected || !hasSecret()) return; // 无密钥时握手必被 4003 拒绝,不发起
|
|
408
451
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
409
452
|
const sock = new WebSocket(`${proto}://${location.host}/ws`);
|
|
410
453
|
curWs = sock;
|
|
@@ -441,7 +484,6 @@ function connectWS() {
|
|
|
441
484
|
renderTasks();
|
|
442
485
|
break;
|
|
443
486
|
case 'task.logs':
|
|
444
|
-
mergePreview(msg.id, msg.lines); // 未展开时也要刷新 shell 视图
|
|
445
487
|
appendLogs(msg.id, msg.lines);
|
|
446
488
|
break;
|
|
447
489
|
case 'workers':
|
|
@@ -450,7 +492,13 @@ function connectWS() {
|
|
|
450
492
|
// --- pty 交互终端流 ---
|
|
451
493
|
case 'pty.data': {
|
|
452
494
|
const ent = termEntries.get(msg.id);
|
|
453
|
-
if (ent) {
|
|
495
|
+
if (ent) {
|
|
496
|
+
ent.xt.write(msg.data);
|
|
497
|
+
ent.live = true;
|
|
498
|
+
// 非当前终端有新输出:tab 亮活动圆点,切过去后熄灭
|
|
499
|
+
if (msg.id !== activeTermId) { ent.dirty = true; ent.tab.classList.add('dirty'); }
|
|
500
|
+
termStatus(ent);
|
|
501
|
+
}
|
|
454
502
|
break;
|
|
455
503
|
}
|
|
456
504
|
case 'pty.started': {
|
|
@@ -477,7 +525,8 @@ function connectWS() {
|
|
|
477
525
|
ent.pendingAttach = false;
|
|
478
526
|
if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
|
|
479
527
|
ent.live = msg.live;
|
|
480
|
-
|
|
528
|
+
ent.liveHint = msg.live ? '' : (ATTACH_HINTS[msg.reason] || ATTACH_HINTS.unknown);
|
|
529
|
+
if (!msg.live) ent.xt.write(`\r\n\x1b[33m[会话未就绪:${ent.liveHint}]\x1b[0m\r\n`);
|
|
481
530
|
termStatus(ent);
|
|
482
531
|
}
|
|
483
532
|
break;
|
|
@@ -487,7 +536,7 @@ function connectWS() {
|
|
|
487
536
|
sock.addEventListener('close', (ev) => {
|
|
488
537
|
wsReady = false;
|
|
489
538
|
setConnMode(false);
|
|
490
|
-
|
|
539
|
+
for (const ent of termEntries.values()) termStatus(ent); // 各打开终端的状态条都显示重连中
|
|
491
540
|
if (ev.code === 4003) {
|
|
492
541
|
// 验签被拒:无意义重试只会刷服务端日志,等用户改密钥
|
|
493
542
|
wsAuthRejected = true;
|
|
@@ -510,31 +559,194 @@ function fmtTime(ts) {
|
|
|
510
559
|
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
511
560
|
}
|
|
512
561
|
|
|
562
|
+
let workerList = []; // 心跳下发的 Worker 快照;目标 Worker 与项目目录下拉都基于它
|
|
563
|
+
|
|
564
|
+
function relTime(ts) {
|
|
565
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
566
|
+
if (s < 60) return `${s} 秒前`;
|
|
567
|
+
if (s < 3600) return `${Math.round(s / 60)} 分钟前`;
|
|
568
|
+
if (s < 86400) return `${Math.round(s / 3600)} 小时前`;
|
|
569
|
+
return `${Math.round(s / 86400)} 天前`;
|
|
570
|
+
}
|
|
571
|
+
|
|
513
572
|
function renderWorkers(workers) {
|
|
573
|
+
workerList = workers;
|
|
514
574
|
const ws = $('worker-status');
|
|
515
|
-
const
|
|
516
|
-
const text =
|
|
575
|
+
const onlineCount = workers.filter((w) => w.online).length;
|
|
576
|
+
const text = workers.length ? `${onlineCount}/${workers.length} 台 Worker 在线` : '无已注册 Worker';
|
|
517
577
|
if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
|
|
518
|
-
ws.className =
|
|
519
|
-
|
|
578
|
+
ws.className = onlineCount ? 'online' : 'offline';
|
|
579
|
+
// 当前所在二级视图的 Worker 已注销(如配置移除):退回一级列表
|
|
580
|
+
if (currentWorkerId && !workers.some((w) => w.workerId === currentWorkerId)) navBack();
|
|
581
|
+
renderWorkerCards();
|
|
582
|
+
// 首份快照到达后按地址栏 hash 恢复视图(刷新进入二级页/展开的长任务)
|
|
583
|
+
if (!routeInited) { routeInited = true; applyRoute(); }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// 一级视图 Worker 卡片:sig 去重,心跳快照没变就不重建(避免打断点击)
|
|
587
|
+
let cardsSig = '';
|
|
588
|
+
function renderWorkerCards() {
|
|
589
|
+
$('workers-empty').classList.toggle('hidden', workerList.length > 0);
|
|
590
|
+
const sig = JSON.stringify(workerList.map((w) => [
|
|
591
|
+
w.workerId, w.hostname, w.online, w.lastSeenAt, w.projects || [],
|
|
592
|
+
]));
|
|
593
|
+
if (sig === cardsSig) return;
|
|
594
|
+
cardsSig = sig;
|
|
595
|
+
const box = $('worker-cards');
|
|
596
|
+
box.textContent = '';
|
|
597
|
+
for (const w of workerList) {
|
|
598
|
+
const card = document.createElement('div');
|
|
599
|
+
card.className = 'worker-card';
|
|
600
|
+
const name = w.hostname && w.hostname !== w.workerId ? `${w.workerId}(${w.hostname})` : w.workerId;
|
|
601
|
+
const status = w.online ? '在线' : `离线(最近心跳 ${relTime(w.lastSeenAt)})`;
|
|
602
|
+
card.innerHTML = `
|
|
603
|
+
<div class="wc-row1">
|
|
604
|
+
<span class="wc-name"></span>
|
|
605
|
+
<span class="${w.online ? 'w-online' : 'w-offline'}"></span>
|
|
606
|
+
</div>
|
|
607
|
+
<div class="wc-meta"></div>`;
|
|
608
|
+
card.querySelector('.wc-name').textContent = name; // textContent 天然防注入
|
|
609
|
+
card.querySelector('.wc-row1 span:last-child').textContent = status;
|
|
610
|
+
const projects = [...new Set(w.projects || [])];
|
|
611
|
+
card.querySelector('.wc-meta').textContent = projects.length ? `项目目录:${projects.join(' / ')}` : '未上报项目目录';
|
|
612
|
+
card.addEventListener('click', () => navToWorker(w.workerId));
|
|
613
|
+
box.appendChild(card);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------- 两级视图导航:一级 Worker 列表 / 二级所选 Worker 详情 ----------
|
|
618
|
+
let currentWorkerId = null;
|
|
619
|
+
let routeInited = false; // 首份 Worker 快照到达前 hash 恢复无法执行(卡片还没渲染)
|
|
620
|
+
// pushRoute=false 时只切视图不动地址栏(来自 hashchange 的同步,避免二次入栈)
|
|
621
|
+
function navToWorker(workerId, pushRoute = true) {
|
|
622
|
+
const w = workerList.find((x) => x.workerId === workerId);
|
|
623
|
+
if (!w) return;
|
|
624
|
+
currentWorkerId = workerId;
|
|
625
|
+
$('worker-title').textContent = w.hostname && w.hostname !== workerId ? `${workerId}(${w.hostname})` : workerId;
|
|
626
|
+
$('view-workers').classList.add('hidden');
|
|
627
|
+
$('view-worker').classList.remove('hidden');
|
|
628
|
+
$('task-list').classList.remove('hidden'); // 二级视图才展示右侧任务列表
|
|
629
|
+
$('layout').classList.remove('one-col'); // 进入二级视图恢复双栏
|
|
630
|
+
if (pushRoute) pushRoute_();
|
|
631
|
+
updateSubmitState();
|
|
632
|
+
renderProjectOptions();
|
|
633
|
+
renderTasks(); // 切换到该 Worker 的任务过滤
|
|
634
|
+
}
|
|
635
|
+
function navBack(pushRoute = true) {
|
|
636
|
+
currentWorkerId = null;
|
|
637
|
+
$('view-worker').classList.add('hidden');
|
|
638
|
+
$('view-workers').classList.remove('hidden');
|
|
639
|
+
$('task-list').classList.add('hidden');
|
|
640
|
+
$('layout').classList.add('one-col'); // 回到一级视图:全宽铺开 Worker 卡片
|
|
641
|
+
if (pushRoute) pushRoute_();
|
|
642
|
+
renderTasks(); // 离开二级视图时清掉过滤后的任务节点
|
|
643
|
+
}
|
|
644
|
+
$('btn-back').addEventListener('click', () => navBack());
|
|
645
|
+
|
|
646
|
+
// ---------- hash 路由:#/w/<workerId>(附 /t/<展开的任务 id>)----------
|
|
647
|
+
// 进入二级视图同步地址栏:刷新留在当前页面;长任务的展开状态也随 URL 保留
|
|
648
|
+
function route() {
|
|
649
|
+
const parts = location.hash.slice(1).split('/').filter(Boolean);
|
|
650
|
+
return {
|
|
651
|
+
worker: parts[0] === 'w' ? decodeURIComponent(parts[1] || '') : null,
|
|
652
|
+
tasks: parts[2] === 't' ? (parts[3] || '').split('.').filter(Boolean) : [],
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function buildHash() {
|
|
656
|
+
if (!currentWorkerId) return '#/';
|
|
657
|
+
// 只把当前视图可见(属于该 Worker)的展开任务写进 URL
|
|
658
|
+
const open = [...openTasks].filter((id) => {
|
|
659
|
+
const t = taskCache.get(id);
|
|
660
|
+
return t && (t.workerId === currentWorkerId || t.targetWorker === currentWorkerId);
|
|
661
|
+
});
|
|
662
|
+
const base = `#/w/${encodeURIComponent(currentWorkerId)}`;
|
|
663
|
+
return open.length ? `${base}/t/${open.join('.')}` : base;
|
|
664
|
+
}
|
|
665
|
+
function pushRoute_() {
|
|
666
|
+
const h = buildHash();
|
|
667
|
+
if (location.hash === h) return;
|
|
668
|
+
history.pushState(null, '', h);
|
|
669
|
+
}
|
|
670
|
+
// 展开/收起只替换当前历史条目:不给返回键堆一堆中间状态
|
|
671
|
+
function syncTaskRoute() {
|
|
672
|
+
history.replaceState(null, '', buildHash());
|
|
673
|
+
}
|
|
674
|
+
function applyRoute() {
|
|
675
|
+
const r = route();
|
|
676
|
+
if (r.worker && r.worker !== currentWorkerId) navToWorker(r.worker, false);
|
|
677
|
+
else if (!r.worker && currentWorkerId) navBack(false);
|
|
678
|
+
for (const id of r.tasks) { // 刷新后恢复长任务的展开(日志区)
|
|
679
|
+
if (openTasks.has(id) || !taskEls.has(id)) continue;
|
|
680
|
+
openTasks.add(id);
|
|
681
|
+
const entry = taskEls.get(id);
|
|
682
|
+
entry.el.classList.add('open');
|
|
683
|
+
entry.toggle.textContent = '收起';
|
|
684
|
+
loadLogs(id);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
window.addEventListener('hashchange', applyRoute);
|
|
688
|
+
|
|
689
|
+
function updateSubmitState() {
|
|
690
|
+
$('btn-submit').disabled = !currentWorkerId;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// 项目目录下拉数据源:只有所选 Worker 的上报(显式指派,不做跨机并集)
|
|
694
|
+
function selectedWorker() {
|
|
695
|
+
return workerList.find((w) => w.workerId === currentWorkerId) || null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// 项目目录下拉:列 Worker 心跳上报的 workspace 已有目录;末尾常驻
|
|
699
|
+
// 「+ 新建目录…」,输入的新目录在任务执行时由 Worker 自动创建
|
|
700
|
+
// (pty 执行前会 mkdir)
|
|
701
|
+
const NEW_PROJECT = '__new__';
|
|
702
|
+
const projectSels = ['f-pty-project'];
|
|
703
|
+
|
|
704
|
+
function renderProjectOptions() {
|
|
705
|
+
const w = selectedWorker();
|
|
706
|
+
const projects = [...new Set(w ? (w.projects || []) : [])].sort();
|
|
707
|
+
if (!projects.length) return; // Worker 尚未上报,保留默认 default 选项
|
|
708
|
+
for (const id of projectSels) {
|
|
709
|
+
const sel = $(id);
|
|
710
|
+
const prev = sel.value === NEW_PROJECT ? 'default' : sel.value;
|
|
711
|
+
sel.textContent = '';
|
|
712
|
+
for (const p of projects) {
|
|
713
|
+
const opt = document.createElement('option');
|
|
714
|
+
opt.value = p;
|
|
715
|
+
opt.textContent = p;
|
|
716
|
+
sel.appendChild(opt);
|
|
717
|
+
}
|
|
718
|
+
const newOpt = document.createElement('option');
|
|
719
|
+
newOpt.value = NEW_PROJECT;
|
|
720
|
+
newOpt.textContent = '+ 新建目录…';
|
|
721
|
+
sel.appendChild(newOpt);
|
|
722
|
+
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
|
|
723
|
+
}
|
|
520
724
|
}
|
|
521
725
|
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
if (
|
|
527
|
-
const
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
726
|
+
// 选「+ 新建目录…」:弹输入框收目录名,合法则加入两个下拉并选中;
|
|
727
|
+
// 取消或非法则退回原选项,NEW_PROJECT 值不会进入提交 payload
|
|
728
|
+
function handleNewProject(ev) {
|
|
729
|
+
const sel = ev.target;
|
|
730
|
+
if (sel.value !== NEW_PROJECT) return;
|
|
731
|
+
const fallback = [...sel.options].find((o) => o.value !== NEW_PROJECT)?.value || '';
|
|
732
|
+
const name = (window.prompt('新项目目录名(任务执行时在 Worker workspace 下自动创建)') || '').trim();
|
|
733
|
+
if (!name || !/^[\w.-]+$/.test(name) || name.includes('..')) {
|
|
734
|
+
if (name) toast('目录名只能含字母/数字/._-,且不含 ..');
|
|
735
|
+
sel.value = fallback;
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
for (const id of projectSels) {
|
|
739
|
+
const s = $(id);
|
|
740
|
+
if (![...s.options].some((o) => o.value === name)) {
|
|
741
|
+
const opt = document.createElement('option');
|
|
742
|
+
opt.value = name;
|
|
743
|
+
opt.textContent = name;
|
|
744
|
+
s.insertBefore(opt, s.querySelector(`option[value="${NEW_PROJECT}"]`));
|
|
745
|
+
}
|
|
746
|
+
s.value = name;
|
|
535
747
|
}
|
|
536
|
-
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
|
|
537
748
|
}
|
|
749
|
+
for (const id of projectSels) $(id).addEventListener('change', handleNewProject);
|
|
538
750
|
|
|
539
751
|
function createTaskEl(t) {
|
|
540
752
|
const div = document.createElement('div');
|
|
@@ -545,7 +757,6 @@ function createTaskEl(t) {
|
|
|
545
757
|
<span class="badge"></span>
|
|
546
758
|
</div>
|
|
547
759
|
<div class="meta"></div>
|
|
548
|
-
<div class="term hidden"></div>
|
|
549
760
|
<div class="logs"></div>
|
|
550
761
|
<div class="actions">
|
|
551
762
|
<button data-act="toggle">展开</button>
|
|
@@ -557,18 +768,13 @@ function createTaskEl(t) {
|
|
|
557
768
|
el: div,
|
|
558
769
|
badge: div.querySelector('.badge'),
|
|
559
770
|
meta: div.querySelector('.meta'),
|
|
560
|
-
termEl: div.querySelector('.term'),
|
|
561
771
|
logsEl: div.querySelector('.logs'),
|
|
562
772
|
toggle: div.querySelector('[data-act=toggle]'),
|
|
563
773
|
term: div.querySelector('[data-act=term]'),
|
|
564
774
|
cancel: div.querySelector('[data-act=cancel]'),
|
|
565
775
|
status: null,
|
|
566
776
|
metaText: null,
|
|
567
|
-
termSig: null,
|
|
568
777
|
};
|
|
569
|
-
entry.termEl.addEventListener('click', () => {
|
|
570
|
-
if (!openTasks.has(t.id)) entry.toggle.click();
|
|
571
|
-
});
|
|
572
778
|
entry.term.addEventListener('click', () => openTerm(t));
|
|
573
779
|
entry.toggle.addEventListener('click', async () => {
|
|
574
780
|
if (openTasks.has(t.id)) {
|
|
@@ -580,7 +786,7 @@ function createTaskEl(t) {
|
|
|
580
786
|
await loadLogs(t.id);
|
|
581
787
|
}
|
|
582
788
|
entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
|
|
583
|
-
|
|
789
|
+
syncTaskRoute(); // 展开状态入 URL:长任务执行中刷新仍留在任务页面
|
|
584
790
|
});
|
|
585
791
|
entry.cancel.addEventListener('click', async () => {
|
|
586
792
|
try {
|
|
@@ -602,66 +808,20 @@ function patchTask(t, entry) {
|
|
|
602
808
|
entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
|
|
603
809
|
}
|
|
604
810
|
entry.term.classList.toggle('hidden', t.type !== 'pty'); // pty 任务入口
|
|
605
|
-
|
|
811
|
+
// Worker 标注:已领取显示执行者,pending 显示指派目标
|
|
812
|
+
const workerPart = t.workerId ? `@${t.workerId}` : (t.targetWorker ? `→ ${t.targetWorker}` : '');
|
|
813
|
+
const meta = `${t.type}${workerPart ? ' · ' + workerPart : ''} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
|
|
606
814
|
if (entry.metaText !== meta) { entry.metaText = meta; entry.meta.textContent = meta; }
|
|
607
815
|
entry.el.classList.toggle('open', openTasks.has(t.id));
|
|
608
|
-
patchTerm(t, entry);
|
|
609
816
|
// 展开且有缓存:内容没变就不动 DOM,保留滚动位置
|
|
610
817
|
if (openTasks.has(t.id) && logCache.has(t.id)) writeLogs(t.id, logCache.get(t.id));
|
|
611
818
|
}
|
|
612
819
|
|
|
613
|
-
// shell 视图:$ 命令回显 + 末尾最多 5 行 stdout;展开后让位给全量日志。
|
|
614
|
-
// 用 sig 跳过无变化的重绘,避免日志流式到达时整卡闪烁。
|
|
615
|
-
function patchTerm(t, entry) {
|
|
616
|
-
const open = openTasks.has(t.id);
|
|
617
|
-
const tail = Array.isArray(t.tail) ? t.tail : [];
|
|
618
|
-
const sig = `${open}|${t.cmd || ''}|${t.outLines || 0}|${tail.join('\n')}`;
|
|
619
|
-
if (entry.termSig === sig) return;
|
|
620
|
-
entry.termSig = sig;
|
|
621
|
-
entry.termEl.textContent = '';
|
|
622
|
-
if (open || (!t.cmd && !tail.length)) { entry.termEl.classList.add('hidden'); return; }
|
|
623
|
-
entry.termEl.classList.remove('hidden');
|
|
624
|
-
const omitted = (t.outLines || 0) - tail.length;
|
|
625
|
-
if (omitted > 0) {
|
|
626
|
-
const d = document.createElement('div');
|
|
627
|
-
d.className = 'dimline';
|
|
628
|
-
d.textContent = `… 更早 ${omitted} 行输出已折叠,点击展开`;
|
|
629
|
-
entry.termEl.appendChild(d);
|
|
630
|
-
}
|
|
631
|
-
if (t.cmd) {
|
|
632
|
-
const line = document.createElement('div');
|
|
633
|
-
const p = document.createElement('span');
|
|
634
|
-
p.className = 'prompt';
|
|
635
|
-
p.textContent = '$ ';
|
|
636
|
-
line.appendChild(p);
|
|
637
|
-
line.appendChild(document.createTextNode(t.cmd.startsWith('$ ') ? t.cmd.slice(2) : t.cmd));
|
|
638
|
-
entry.termEl.appendChild(line);
|
|
639
|
-
}
|
|
640
|
-
for (const l of tail) {
|
|
641
|
-
const line = document.createElement('div');
|
|
642
|
-
line.textContent = l;
|
|
643
|
-
entry.termEl.appendChild(line);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// 任务运行中:WS 日志流增量维护预览(规则与 server 端 shellPreview 一致)
|
|
648
|
-
function mergePreview(id, lines) {
|
|
649
|
-
const t = taskCache.get(id);
|
|
650
|
-
if (!t) return;
|
|
651
|
-
let changed = false;
|
|
652
|
-
for (const line of lines) {
|
|
653
|
-
if (line.startsWith('$ ')) { if (!t.cmd) { t.cmd = line; changed = true; } continue; }
|
|
654
|
-
if (line.startsWith('[worker]') || line.startsWith('[server]') || line === '') continue;
|
|
655
|
-
t.outLines = (t.outLines || 0) + 1;
|
|
656
|
-
t.tail = [...(t.tail || []), line].slice(-5);
|
|
657
|
-
changed = true;
|
|
658
|
-
}
|
|
659
|
-
if (changed) renderTasks();
|
|
660
|
-
}
|
|
661
|
-
|
|
662
820
|
function renderTasks() {
|
|
663
821
|
const list = $('task-list');
|
|
664
|
-
const tasks = [...taskCache.values()]
|
|
822
|
+
const tasks = [...taskCache.values()]
|
|
823
|
+
.filter((t) => !currentWorkerId || t.workerId === currentWorkerId || t.targetWorker === currentWorkerId)
|
|
824
|
+
.sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
|
|
665
825
|
// 清理已不存在的任务节点
|
|
666
826
|
const ids = new Set(tasks.map((t) => t.id));
|
|
667
827
|
for (const [id, entry] of taskEls) {
|
|
@@ -715,15 +875,26 @@ function appendLogs(id, lines) {
|
|
|
715
875
|
|
|
716
876
|
// ---------- 交互终端(pty 任务:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
|
|
717
877
|
// PC 有物理键盘:xterm 聚焦后直接承接按键,无需手机端的隐藏输入代理与按键工具条
|
|
718
|
-
|
|
878
|
+
// 布局借鉴 mwt LayoutManager:单屏(叠层只显示一个)/ 并排(grid 平铺同盯多个 pty),
|
|
879
|
+
// tab 活动圆点标记非当前终端的新输出;偏好存 localStorage
|
|
880
|
+
const termEntries = new Map(); // taskId -> { xt, fit, pane, tab, statusEl, live, pendingAttach, liveHint, dirty, attached }
|
|
719
881
|
let activeTermId = null;
|
|
882
|
+
let termLayout = localStorage.getItem('rw_termLayout') === 'grid' ? 'grid' : 'single';
|
|
883
|
+
// 会话未就绪时的原因文案(与服务端 pty.attach 应答的 reason 对应)
|
|
884
|
+
const ATTACH_HINTS = {
|
|
885
|
+
not_claimed: '任务尚未被领取,等待 Worker 上线领取…',
|
|
886
|
+
worker_offline: 'Worker 离线(通道断开),重连后可自动恢复…',
|
|
887
|
+
starting: '任务已被领取,会话启动中,请稍候…',
|
|
888
|
+
ended: '会话已结束或已被回收',
|
|
889
|
+
unknown: '任务尚未被领取或 Worker 离线,请稍候…',
|
|
890
|
+
};
|
|
720
891
|
|
|
892
|
+
// 状态条挂在各终端自己的 pane 头部(并排时各有各的状态)
|
|
721
893
|
function termStatus(ent) {
|
|
722
|
-
|
|
723
|
-
if (!
|
|
724
|
-
if (
|
|
725
|
-
else
|
|
726
|
-
else el.textContent = '会话未就绪(任务未领取或 Worker 离线)';
|
|
894
|
+
if (!ent || !ent.statusEl) return;
|
|
895
|
+
if (!wsReady) ent.statusEl.textContent = '实时通道断开,重连中…';
|
|
896
|
+
else if (ent.live) ent.statusEl.textContent = '已连接';
|
|
897
|
+
else ent.statusEl.textContent = `会话未就绪(${ent.liveHint || ATTACH_HINTS.unknown})`;
|
|
727
898
|
}
|
|
728
899
|
|
|
729
900
|
function ensureTerm(t) {
|
|
@@ -739,37 +910,108 @@ function ensureTerm(t) {
|
|
|
739
910
|
});
|
|
740
911
|
const fit = new FitAddon.FitAddon();
|
|
741
912
|
xt.loadAddon(fit);
|
|
742
|
-
|
|
743
|
-
|
|
913
|
+
// 每个终端独占一个 pane(标题栏 + 渲染区):挂在同一容器里会让多实例叠在一起串台
|
|
914
|
+
const pane = document.createElement('div');
|
|
915
|
+
pane.className = 'term-pane';
|
|
916
|
+
pane.innerHTML = `
|
|
917
|
+
<div class="tp-head">
|
|
918
|
+
<span class="tp-title"></span>
|
|
919
|
+
<span class="tp-status"></span>
|
|
920
|
+
<button class="tp-close" type="button" title="关闭该终端">✕</button>
|
|
921
|
+
</div>
|
|
922
|
+
<div class="tp-term"></div>`;
|
|
923
|
+
pane.querySelector('.tp-title').textContent = t.title || '交互终端'; // textContent 天然防注入
|
|
924
|
+
pane.querySelector('.tp-close').addEventListener('click', (ev) => {
|
|
925
|
+
ev.stopPropagation();
|
|
926
|
+
disposeTerm(t.id); // 只释放本地实例,任务卡上「进入终端」可随时重进(pty.attach 回补缓冲)
|
|
927
|
+
});
|
|
928
|
+
pane.addEventListener('mousedown', () => { // 并排时点非当前 pane 即切焦点过去
|
|
929
|
+
if (activeTermId !== t.id) activateTerm(t.id);
|
|
930
|
+
});
|
|
931
|
+
$('term-body').appendChild(pane);
|
|
932
|
+
xt.open(pane.querySelector('.tp-term'));
|
|
933
|
+
// 顶部同步一个 tab:点击切换,活动圆点标记后台新输出
|
|
934
|
+
const tab = document.createElement('div');
|
|
935
|
+
tab.className = 'term-tab';
|
|
936
|
+
tab.innerHTML = '<span class="tt-dot"></span><span class="tt-title"></span>';
|
|
937
|
+
tab.querySelector('.tt-title').textContent = t.title || '交互终端';
|
|
938
|
+
tab.addEventListener('click', () => activateTerm(t.id));
|
|
939
|
+
$('term-tabs').appendChild(tab);
|
|
940
|
+
ent = {
|
|
941
|
+
xt, fit, pane, tab, statusEl: pane.querySelector('.tp-status'),
|
|
942
|
+
live: false, pendingAttach: false, liveHint: '', dirty: false, attached: false,
|
|
943
|
+
};
|
|
744
944
|
termEntries.set(t.id, ent);
|
|
745
945
|
xt.onData((data) => sendWs({ type: 'session.input', taskId: t.id, data }));
|
|
746
946
|
return ent;
|
|
747
947
|
}
|
|
748
948
|
|
|
749
|
-
|
|
750
|
-
|
|
949
|
+
// 切换当前终端:点亮对应 tab 与 pane,熄灭活动圆点,聚焦承接输入
|
|
950
|
+
function activateTerm(id) {
|
|
951
|
+
const ent = termEntries.get(id);
|
|
751
952
|
if (!ent) return;
|
|
953
|
+
activeTermId = id;
|
|
954
|
+
ent.dirty = false;
|
|
955
|
+
for (const [tid, e] of termEntries) {
|
|
956
|
+
const on = tid === id;
|
|
957
|
+
e.tab.classList.toggle('active', on);
|
|
958
|
+
e.tab.classList.toggle('dirty', e.dirty); // active 的 dirty 已清,顺带同步
|
|
959
|
+
// 单屏模式 pane 叠层只显示当前;并排模式 CSS 让全部可见,active 只高亮边框
|
|
960
|
+
e.pane.classList.toggle('active', on);
|
|
961
|
+
}
|
|
962
|
+
fitTerm(id, ent); // pane 可见后再 fit(隐藏状态下拿不到尺寸)
|
|
963
|
+
termStatus(ent);
|
|
964
|
+
ent.xt.focus();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// 按容器尺寸重算 cols/rows,并通知 Worker 端 PTY 同步窗口大小
|
|
968
|
+
function fitTerm(id, ent) {
|
|
752
969
|
try { ent.fit.fit(); } catch { return; }
|
|
753
|
-
sendWs({ type: 'session.resize', taskId:
|
|
970
|
+
if (wsReady) sendWs({ type: 'session.resize', taskId: id, cols: ent.xt.cols, rows: ent.xt.rows });
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// fit 所有当前可见终端(并排:全部;单屏:仅 active)
|
|
974
|
+
function fitVisibleTerms() {
|
|
975
|
+
if ($('term-overlay').classList.contains('hidden')) return;
|
|
976
|
+
for (const [id, ent] of termEntries) {
|
|
977
|
+
if (termLayout === 'grid' || id === activeTermId) fitTerm(id, ent);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// 应用布局模式:并排按终端数重算 grid 列数(至多 3 列),并重新 fit 全部可见终端
|
|
982
|
+
function applyTermLayout() {
|
|
983
|
+
const body = $('term-body');
|
|
984
|
+
body.classList.toggle('grid', termLayout === 'grid');
|
|
985
|
+
document.querySelectorAll('#term-layout-btns button').forEach((b) => {
|
|
986
|
+
b.classList.toggle('active', b.dataset.layout === termLayout);
|
|
987
|
+
});
|
|
988
|
+
if (termLayout === 'grid') {
|
|
989
|
+
const n = termEntries.size;
|
|
990
|
+
body.style.setProperty('--cols', String(n <= 1 ? 1 : (n <= 4 ? 2 : 3)));
|
|
991
|
+
} else {
|
|
992
|
+
body.style.removeProperty('--cols');
|
|
993
|
+
}
|
|
994
|
+
setTimeout(fitVisibleTerms, 60); // 等布局切换稳定再重 fit
|
|
754
995
|
}
|
|
755
996
|
|
|
756
997
|
function openTerm(t) {
|
|
757
998
|
const ent = ensureTerm(t);
|
|
758
999
|
if (!ent) return;
|
|
759
|
-
activeTermId = t.id;
|
|
760
|
-
$('term-title').textContent = t.title || '交互终端';
|
|
761
1000
|
$('term-overlay').classList.remove('hidden');
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
ent.
|
|
1001
|
+
applyTermLayout(); // 距上次收起终端数可能有变,确保布局类与列数正确
|
|
1002
|
+
activateTerm(t.id);
|
|
1003
|
+
// 重复打开不重复 attach:回看缓冲只回补一次,避免终端内容翻倍
|
|
1004
|
+
if (!ent.attached) {
|
|
1005
|
+
ent.attached = true;
|
|
1006
|
+
ent.pendingAttach = true;
|
|
1007
|
+
sendWs({ type: 'pty.attach', taskId: t.id }); // 取回看缓冲 + 会话存活状态
|
|
1008
|
+
}
|
|
766
1009
|
termStatus(ent);
|
|
767
1010
|
}
|
|
768
1011
|
|
|
769
1012
|
function closeTermOverlay() {
|
|
770
1013
|
$('term-overlay').classList.add('hidden');
|
|
771
1014
|
activeTermId = null;
|
|
772
|
-
termStatus(null);
|
|
773
1015
|
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
|
774
1016
|
}
|
|
775
1017
|
|
|
@@ -778,8 +1020,15 @@ function disposeTerm(id) {
|
|
|
778
1020
|
const ent = termEntries.get(id);
|
|
779
1021
|
if (!ent) return;
|
|
780
1022
|
termEntries.delete(id);
|
|
781
|
-
|
|
1023
|
+
ent.tab.remove();
|
|
782
1024
|
try { ent.xt.dispose(); } catch { /* 已释放 */ }
|
|
1025
|
+
ent.pane.remove();
|
|
1026
|
+
if (activeTermId === id) {
|
|
1027
|
+
const next = termEntries.keys().next().value;
|
|
1028
|
+
if (next && !$('term-overlay').classList.contains('hidden')) activateTerm(next);
|
|
1029
|
+
else closeTermOverlay();
|
|
1030
|
+
}
|
|
1031
|
+
applyTermLayout(); // 终端数有变:重算并排列数(覆盖层收起时 fit 有兜底不执行)
|
|
783
1032
|
}
|
|
784
1033
|
|
|
785
1034
|
$('term-close').addEventListener('click', closeTermOverlay);
|
|
@@ -787,6 +1036,14 @@ $('term-body').addEventListener('click', () => {
|
|
|
787
1036
|
const ent = termEntries.get(activeTermId);
|
|
788
1037
|
if (ent) ent.xt.focus();
|
|
789
1038
|
});
|
|
1039
|
+
// 布局切换:单屏 / 并排,偏好持久化
|
|
1040
|
+
document.querySelectorAll('#term-layout-btns button').forEach((btn) => {
|
|
1041
|
+
btn.addEventListener('click', () => {
|
|
1042
|
+
termLayout = btn.dataset.layout;
|
|
1043
|
+
localStorage.setItem('rw_termLayout', termLayout);
|
|
1044
|
+
applyTermLayout();
|
|
1045
|
+
});
|
|
1046
|
+
});
|
|
790
1047
|
// Esc 收起终端覆盖层(终端聚焦时 Esc 会先被 xterm 吃掉,此处兜底非聚焦场景)
|
|
791
1048
|
document.addEventListener('keydown', (ev) => {
|
|
792
1049
|
if (ev.key === 'Escape' && activeTermId && !$('term-overlay').classList.contains('hidden')) {
|
|
@@ -797,8 +1054,8 @@ document.addEventListener('keydown', (ev) => {
|
|
|
797
1054
|
closeTermOverlay();
|
|
798
1055
|
}
|
|
799
1056
|
});
|
|
800
|
-
// 窗口尺寸变化后重新 fit
|
|
801
|
-
window.addEventListener('resize', () => { if (
|
|
1057
|
+
// 窗口尺寸变化后重新 fit 所有可见终端
|
|
1058
|
+
window.addEventListener('resize', () => { if (termEntries.size) setTimeout(fitVisibleTerms, 120); });
|
|
802
1059
|
|
|
803
1060
|
// ---------- 轮询回退(仅 WS 不可用时生效)----------
|
|
804
1061
|
async function refresh() {
|
|
@@ -815,64 +1072,33 @@ async function refresh() {
|
|
|
815
1072
|
toast(err.message);
|
|
816
1073
|
}
|
|
817
1074
|
}
|
|
818
|
-
setInterval(() => { if (!wsReady && !wsAuthRejected) refresh(); }, 4000);
|
|
819
|
-
|
|
820
|
-
// ---------- 类型切换 ----------
|
|
821
|
-
let currentType = 'agent';
|
|
822
|
-
document.querySelectorAll('#type-bar button').forEach((btn) => {
|
|
823
|
-
btn.addEventListener('click', () => {
|
|
824
|
-
currentType = btn.dataset.type;
|
|
825
|
-
document.querySelectorAll('#type-bar button').forEach((b) => b.classList.toggle('active', b === btn));
|
|
826
|
-
for (const t of ['agent', 'shell', 'pty', 'script', 'chat']) {
|
|
827
|
-
$('fields-' + t).classList.toggle('hidden', t !== currentType);
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
});
|
|
1075
|
+
setInterval(() => { if (!wsReady && !wsAuthRejected && hasSecret()) refresh(); }, 4000);
|
|
831
1076
|
|
|
832
1077
|
// ---------- 提交任务(REST,WS 负责推送变更)----------
|
|
833
1078
|
$('btn-submit').addEventListener('click', async () => {
|
|
1079
|
+
// 显式指派:二级视图下任务固定提交给当前 Worker
|
|
1080
|
+
const targetWorker = currentWorkerId;
|
|
1081
|
+
if (!targetWorker) return toast('请先在 Worker 列表中选择 Worker');
|
|
834
1082
|
const title = $('f-title').value.trim();
|
|
835
|
-
|
|
836
|
-
if (
|
|
837
|
-
payload = {
|
|
838
|
-
prompt: $('f-prompt').value.trim(),
|
|
839
|
-
project: $('f-project').value.trim() || 'default',
|
|
840
|
-
agent: $('f-agent').value,
|
|
841
|
-
};
|
|
842
|
-
if (!payload.prompt) return toast('请填写 Prompt');
|
|
843
|
-
} else if (currentType === 'shell') {
|
|
844
|
-
payload = { command: $('f-command').value.trim() };
|
|
845
|
-
if (!payload.command) return toast('请填写命令');
|
|
846
|
-
} else if (currentType === 'pty') {
|
|
847
|
-
payload = { project: $('f-pty-project').value.trim() || 'default' };
|
|
848
|
-
if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
|
|
849
|
-
} else if (currentType === 'script') {
|
|
850
|
-
payload = {
|
|
851
|
-
script: $('f-script').value.trim(),
|
|
852
|
-
args: $('f-args').value.trim() ? $('f-args').value.trim().split(/\s+/) : [],
|
|
853
|
-
};
|
|
854
|
-
if (!payload.script) return toast('请填写脚本名');
|
|
855
|
-
} else {
|
|
856
|
-
payload = { message: $('f-message').value.trim() };
|
|
857
|
-
if (!payload.message) return toast('请填写留言');
|
|
858
|
-
}
|
|
1083
|
+
const payload = { project: $('f-pty-project').value.trim() || 'default' };
|
|
1084
|
+
if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
|
|
859
1085
|
try {
|
|
860
1086
|
$('btn-submit').disabled = true;
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
await api('/api/tasks', { type: currentType, title: title || autoTitle, payload });
|
|
864
|
-
toast(currentType === 'pty' ? '已提交,任务运行后点「进入终端」' : '任务已提交');
|
|
1087
|
+
await api('/api/tasks', { type: 'pty', title: title || '交互终端', payload, targetWorker });
|
|
1088
|
+
toast('已提交,任务运行后点「进入终端」');
|
|
865
1089
|
$('f-title').value = '';
|
|
866
1090
|
if (!wsReady) await refresh();
|
|
867
1091
|
} catch (err) {
|
|
868
1092
|
toast(err.message);
|
|
869
1093
|
} finally {
|
|
870
|
-
|
|
1094
|
+
updateSubmitState();
|
|
871
1095
|
}
|
|
872
1096
|
});
|
|
873
1097
|
|
|
874
1098
|
connectWS();
|
|
875
|
-
|
|
1099
|
+
// 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据(无密钥时不发请求)
|
|
1100
|
+
if (hasSecret()) refresh();
|
|
1101
|
+
else setConnMode(false); // 无密钥不发起任何连接,状态条直接落到轮询态
|
|
876
1102
|
</script>
|
|
877
1103
|
</body>
|
|
878
1104
|
</html>
|