@jacksontian/kite-server 0.1.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 +105 -0
- package/bin/server.js +191 -0
- package/config.example.yaml +31 -5
- package/deploy/kiteserver.service +24 -0
- 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 +25 -5
- package/web/index.html +305 -187
- package/web/pc.html +1104 -0
- package/web/vendor/addon-fit.min.js +8 -0
- package/web/vendor/xterm.min.css +8 -0
- package/web/vendor/xterm.min.js +8 -0
- package/config.js +0 -205
- package/server.js +0 -921
package/web/index.html
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
6
|
<meta name="theme-color" content="#111827">
|
|
7
7
|
<title>Kite 控制台</title>
|
|
8
|
-
|
|
8
|
+
<!-- xterm 随包自托管(vendor/ 为锁版本的官方产物):不依赖 CDN,
|
|
9
|
+
杜绝 CDN 投毒/HTTP 下被篡改注入窃取密钥的供应链风险 -->
|
|
10
|
+
<link rel="stylesheet" href="vendor/xterm.min.css">
|
|
9
11
|
<style>
|
|
10
12
|
:root {
|
|
11
13
|
--bg: #111827; --panel: #1f2937; --line: #374151;
|
|
@@ -29,6 +31,12 @@
|
|
|
29
31
|
#worker-status.online { color: var(--ok); }
|
|
30
32
|
#worker-status.offline { color: var(--err); }
|
|
31
33
|
|
|
34
|
+
/* 明文 HTTP 部署的风险提示(仅非 localhost 的 http: 环境显示) */
|
|
35
|
+
#http-warn {
|
|
36
|
+
background: rgba(239,68,68,.12); border: 1px solid var(--err); color: var(--err);
|
|
37
|
+
border-radius: 10px; padding: 8px 12px; font-size: 12px; line-height: 1.6; margin-bottom: 14px;
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-bottom: 14px; }
|
|
33
41
|
label { display: block; font-size: 12px; color: var(--dim); margin: 10px 0 4px; }
|
|
34
42
|
input, textarea, select {
|
|
@@ -39,13 +47,6 @@
|
|
|
39
47
|
textarea { min-height: 72px; resize: vertical; }
|
|
40
48
|
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
|
|
41
49
|
|
|
42
|
-
.types { display: flex; gap: 8px; margin-top: 4px; }
|
|
43
|
-
.types button {
|
|
44
|
-
flex: 1; padding: 10px 0; border-radius: 8px; border: 1px solid var(--line);
|
|
45
|
-
background: var(--bg); color: var(--dim); font-size: 13px;
|
|
46
|
-
}
|
|
47
|
-
.types button.active { border-color: var(--accent); color: var(--accent); background: rgba(59,130,246,.12); }
|
|
48
|
-
|
|
49
50
|
.submit {
|
|
50
51
|
width: 100%; margin-top: 14px; padding: 12px;
|
|
51
52
|
background: var(--accent); color: #fff; border: none; border-radius: 10px;
|
|
@@ -71,15 +72,6 @@
|
|
|
71
72
|
color: #a5f3fc; display: none;
|
|
72
73
|
}
|
|
73
74
|
.task.open .logs { display: block; }
|
|
74
|
-
/* shell 视图:默认只露出命令回显 + 末尾 5 行 stdout,接近终端体验;
|
|
75
|
-
元信息/结果/更早输出要点「展开」才可见 */
|
|
76
|
-
.term {
|
|
77
|
-
margin-top: 8px; background: #0b1220; border-radius: 8px; padding: 8px;
|
|
78
|
-
font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.6;
|
|
79
|
-
white-space: pre-wrap; word-break: break-all; color: #a5f3fc; cursor: pointer;
|
|
80
|
-
}
|
|
81
|
-
.term .prompt { color: var(--ok); }
|
|
82
|
-
.term .dimline { color: var(--dim); }
|
|
83
75
|
.task .actions { margin-top: 8px; display: flex; gap: 8px; }
|
|
84
76
|
.task .actions button {
|
|
85
77
|
font-size: 12px; padding: 4px 12px; border-radius: 6px;
|
|
@@ -98,7 +90,11 @@
|
|
|
98
90
|
#term-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; }
|
|
99
91
|
#term-title { font-size: 13px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
100
92
|
#term-close { font-size: 13px; padding: 4px 12px; border-radius: 6px; background: var(--panel); color: var(--text); border: 1px solid var(--line); }
|
|
101
|
-
|
|
93
|
+
/* position:relative + 绝对定位的 .term-pane:多个 pty 终端各占一层,
|
|
94
|
+
同时只显示 active 的一个,避免多实例挂在同一容器里叠出来串台 */
|
|
95
|
+
#term-body { flex: 1; margin: 0 8px; min-height: 0; position: relative; }
|
|
96
|
+
.term-pane { position: absolute; inset: 0; display: none; }
|
|
97
|
+
.term-pane.active { display: block; }
|
|
102
98
|
#term-bar { display: flex; align-items: center; gap: 6px; padding: 8px; }
|
|
103
99
|
#term-bar button {
|
|
104
100
|
min-width: 44px; padding: 9px 0; font-size: 13px; border-radius: 8px;
|
|
@@ -106,7 +102,6 @@
|
|
|
106
102
|
}
|
|
107
103
|
#term-status { flex: 1; font-size: 11px; color: var(--dim); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
108
104
|
#term-input { position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; opacity: .01; border: none; }
|
|
109
|
-
#type-bar button { font-size: 12px; }
|
|
110
105
|
#auth-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
|
|
111
106
|
#auth-bar .title { font-size: 13px; color: var(--ok); }
|
|
112
107
|
#auth-bar .edit { font-size: 12px; color: var(--dim); }
|
|
@@ -118,6 +113,24 @@
|
|
|
118
113
|
}
|
|
119
114
|
#btn-eye.on { color: var(--accent); border-color: var(--accent); }
|
|
120
115
|
#btn-done { color: var(--ok); }
|
|
116
|
+
/* 两级视图:一级 Worker 列表 / 二级所选 Worker 详情 */
|
|
117
|
+
#back-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
|
118
|
+
#btn-back {
|
|
119
|
+
flex: none; padding: 8px 14px; border: 1px solid var(--line); border-radius: 8px;
|
|
120
|
+
background: var(--panel); color: var(--text); font-size: 14px;
|
|
121
|
+
}
|
|
122
|
+
#worker-title { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
123
|
+
.worker-card {
|
|
124
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
|
125
|
+
padding: 12px; margin-bottom: 10px;
|
|
126
|
+
}
|
|
127
|
+
.worker-card .wc-row1 { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
|
128
|
+
.worker-card .wc-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
129
|
+
.worker-card .wc-meta { font-size: 11px; color: var(--dim); margin-top: 4px; line-height: 1.5; word-break: break-all; }
|
|
130
|
+
.w-online { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,.15); color: var(--ok); white-space: nowrap; }
|
|
131
|
+
.w-offline { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(239,68,68,.15); color: var(--err); white-space: nowrap; }
|
|
132
|
+
.empty-tip { color: var(--dim); font-size: 13px; text-align: center; padding: 28px 0; }
|
|
133
|
+
|
|
121
134
|
@keyframes task-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
122
135
|
.task.new { animation: task-in .25s ease; }
|
|
123
136
|
#toast {
|
|
@@ -137,6 +150,12 @@
|
|
|
137
150
|
</div>
|
|
138
151
|
</header>
|
|
139
152
|
|
|
153
|
+
<!-- 明文 HTTP 部署警告:任务内容与签名流量可被窃听,建议尽快上 HTTPS -->
|
|
154
|
+
<div id="http-warn" class="hidden">
|
|
155
|
+
⚠️ 当前为明文 HTTP 访问:任务、日志与签名在网络链路上明文可见,且本机无法使用安全加密 API。
|
|
156
|
+
请勿在不信任的网络(如公共 WiFi)使用,并尽快为服务端配置 HTTPS(如 Caddy 自动证书)。
|
|
157
|
+
</div>
|
|
158
|
+
|
|
140
159
|
<!-- 鉴权设置:密钥已存 localStorage 时折叠成一行,点「修改」才展开 -->
|
|
141
160
|
<div class="panel" id="auth-panel">
|
|
142
161
|
<div id="auth-bar" class="hidden">
|
|
@@ -153,52 +172,34 @@
|
|
|
153
172
|
</div>
|
|
154
173
|
</div>
|
|
155
174
|
|
|
156
|
-
<!--
|
|
157
|
-
<div
|
|
158
|
-
<div
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
175
|
+
<!-- 一级视图:Worker 列表,选中某个 Worker 进入二级详情 -->
|
|
176
|
+
<div id="view-workers">
|
|
177
|
+
<div id="worker-cards"></div>
|
|
178
|
+
<div id="workers-empty" class="empty-tip hidden">暂无已注册 Worker</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<!-- 二级视图:所选 Worker 的新建任务与任务列表 -->
|
|
182
|
+
<div id="view-worker" class="hidden">
|
|
183
|
+
<div id="back-row">
|
|
184
|
+
<button id="btn-back" type="button">‹ Worker 列表</button>
|
|
185
|
+
<span id="worker-title"></span>
|
|
164
186
|
</div>
|
|
165
187
|
|
|
188
|
+
<!-- 新建任务(仅 pty 交互终端) -->
|
|
189
|
+
<div class="panel">
|
|
166
190
|
<label>标题</label>
|
|
167
191
|
<input id="f-title" placeholder="一句话描述这个任务">
|
|
168
192
|
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<label>Prompt(交给编码 Agent 的指令)</label>
|
|
173
|
-
<textarea id="f-prompt" placeholder="例:把 README 里的安装步骤补全,然后跑一遍测试"></textarea>
|
|
174
|
-
<label>项目目录名(相对 workspace)</label>
|
|
175
|
-
<input id="f-project" value="default">
|
|
176
|
-
</div>
|
|
177
|
-
<div id="fields-shell" class="hidden">
|
|
178
|
-
<label>命令(在 workspace 目录执行)</label>
|
|
179
|
-
<textarea id="f-command" placeholder="例:git -C myrepo pull && npm test"></textarea>
|
|
180
|
-
</div>
|
|
181
|
-
<div id="fields-pty" class="hidden">
|
|
182
|
-
<label>项目目录名(相对 workspace,终端的起始目录)</label>
|
|
183
|
-
<input id="f-pty-project" value="default">
|
|
184
|
-
<label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
|
|
185
|
-
</div>
|
|
186
|
-
<div id="fields-script" class="hidden">
|
|
187
|
-
<label>脚本名(worker/scripts 目录下的白名单脚本)</label>
|
|
188
|
-
<input id="f-script" placeholder="例:backup.sh">
|
|
189
|
-
<label>参数(空格分隔)</label>
|
|
190
|
-
<input id="f-args" placeholder="例:--full /tmp/out">
|
|
191
|
-
</div>
|
|
192
|
-
<div id="fields-chat" class="hidden">
|
|
193
|
-
<label>留言内容</label>
|
|
194
|
-
<textarea id="f-message" placeholder="记一条备忘,或触发 CHAT_HOOK"></textarea>
|
|
195
|
-
</div>
|
|
193
|
+
<label>项目目录(终端的起始目录,Worker workspace 下已有目录)</label>
|
|
194
|
+
<select id="f-pty-project"><option value="default">default</option></select>
|
|
195
|
+
<label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
|
|
196
196
|
|
|
197
197
|
<button class="submit" id="btn-submit">提交任务</button>
|
|
198
|
-
</div>
|
|
198
|
+
</div>
|
|
199
199
|
|
|
200
|
-
<!--
|
|
201
|
-
<div id="task-list"></div>
|
|
200
|
+
<!-- 任务列表(仅所选 Worker) -->
|
|
201
|
+
<div id="task-list"></div>
|
|
202
|
+
</div><!-- /view-worker -->
|
|
202
203
|
|
|
203
204
|
<!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY -->
|
|
204
205
|
<div id="term-overlay" class="hidden">
|
|
@@ -223,11 +224,15 @@
|
|
|
223
224
|
|
|
224
225
|
<div id="toast"></div>
|
|
225
226
|
|
|
226
|
-
<script src="
|
|
227
|
-
<script src="
|
|
227
|
+
<script src="vendor/xterm.min.js"></script>
|
|
228
|
+
<script src="vendor/addon-fit.min.js"></script>
|
|
228
229
|
<script>
|
|
229
230
|
'use strict';
|
|
230
231
|
const $ = (id) => document.getElementById(id);
|
|
232
|
+
// 非 localhost 的明文 HTTP 环境:常驻风险提示(localhost 本地联调不打扰)
|
|
233
|
+
if (location.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(location.hostname)) {
|
|
234
|
+
$('http-warn').classList.remove('hidden');
|
|
235
|
+
}
|
|
231
236
|
const secretInput = $('secret');
|
|
232
237
|
secretInput.value = localStorage.getItem('rw_secret') || '';
|
|
233
238
|
|
|
@@ -323,7 +328,17 @@ function signHeaders(method, path, bodyStr) {
|
|
|
323
328
|
return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
|
|
324
329
|
}
|
|
325
330
|
|
|
331
|
+
// 未配置密钥时禁止发起任何需要签名的请求:空密钥签名必 401,
|
|
332
|
+
// 还会白刷服务端验签失败限流计数,统一在本地拦截
|
|
333
|
+
function hasSecret() {
|
|
334
|
+
return secretInput.value.trim() !== '';
|
|
335
|
+
}
|
|
336
|
+
function requireSecret() {
|
|
337
|
+
if (!hasSecret()) throw new Error('请先配置 API Secret');
|
|
338
|
+
}
|
|
339
|
+
|
|
326
340
|
async function api(path, body) {
|
|
341
|
+
requireSecret();
|
|
327
342
|
const method = body === undefined ? 'GET' : 'POST';
|
|
328
343
|
const bodyStr = body === undefined ? '' : JSON.stringify(body);
|
|
329
344
|
const res = await fetch(path, {
|
|
@@ -350,7 +365,10 @@ let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变
|
|
|
350
365
|
secretInput.addEventListener('change', () => {
|
|
351
366
|
localStorage.setItem('rw_secret', secretInput.value.trim());
|
|
352
367
|
wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
|
|
353
|
-
if (
|
|
368
|
+
if (hasSecret()) {
|
|
369
|
+
if (!wsReady) connectWS();
|
|
370
|
+
refresh(); // 之前无密钥没拉过数据,配好后补一次
|
|
371
|
+
}
|
|
354
372
|
});
|
|
355
373
|
|
|
356
374
|
// 密钥已配置时收起输入区,只留一行状态条;点状态条可展开修改
|
|
@@ -393,7 +411,7 @@ function setConnMode(realtime) {
|
|
|
393
411
|
}
|
|
394
412
|
|
|
395
413
|
function connectWS() {
|
|
396
|
-
if (wsAuthRejected) return;
|
|
414
|
+
if (wsAuthRejected || !hasSecret()) return; // 无密钥时握手必被 4003 拒绝,不发起
|
|
397
415
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
398
416
|
const sock = new WebSocket(`${proto}://${location.host}/ws`);
|
|
399
417
|
curWs = sock;
|
|
@@ -430,7 +448,6 @@ function connectWS() {
|
|
|
430
448
|
renderTasks();
|
|
431
449
|
break;
|
|
432
450
|
case 'task.logs':
|
|
433
|
-
mergePreview(msg.id, msg.lines); // 未展开时也要刷新 shell 视图
|
|
434
451
|
appendLogs(msg.id, msg.lines);
|
|
435
452
|
break;
|
|
436
453
|
case 'workers':
|
|
@@ -466,7 +483,8 @@ function connectWS() {
|
|
|
466
483
|
ent.pendingAttach = false;
|
|
467
484
|
if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
|
|
468
485
|
ent.live = msg.live;
|
|
469
|
-
|
|
486
|
+
ent.liveHint = msg.live ? '' : (ATTACH_HINTS[msg.reason] || ATTACH_HINTS.unknown);
|
|
487
|
+
if (!msg.live) ent.xt.write(`\r\n\x1b[33m[会话未就绪:${ent.liveHint}]\x1b[0m\r\n`);
|
|
470
488
|
termStatus(ent);
|
|
471
489
|
}
|
|
472
490
|
break;
|
|
@@ -499,32 +517,197 @@ function fmtTime(ts) {
|
|
|
499
517
|
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
500
518
|
}
|
|
501
519
|
|
|
520
|
+
let workerList = []; // 最近一次 Worker 快照:渲染目标下拉与项目目录联动
|
|
521
|
+
|
|
522
|
+
/** 相对时间:离线 Worker 的最近心跳展示成「X 分钟前」,超过一天回退具体时间 */
|
|
523
|
+
function relTime(ts) {
|
|
524
|
+
if (!ts) return '从未';
|
|
525
|
+
const diff = Date.now() - ts;
|
|
526
|
+
if (diff < 60_000) return '1 分钟内';
|
|
527
|
+
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
|
|
528
|
+
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`;
|
|
529
|
+
return fmtTime(ts);
|
|
530
|
+
}
|
|
531
|
+
|
|
502
532
|
function renderWorkers(workers) {
|
|
533
|
+
workerList = workers;
|
|
503
534
|
const ws = $('worker-status');
|
|
504
|
-
const online = workers.
|
|
505
|
-
|
|
535
|
+
const online = workers.filter((w) => w.online).length;
|
|
536
|
+
let text;
|
|
537
|
+
if (!workers.length) {
|
|
538
|
+
text = '无 Worker';
|
|
539
|
+
} else if (online) {
|
|
540
|
+
text = `${online}/${workers.length} Worker 在线`;
|
|
541
|
+
} else {
|
|
542
|
+
// 全离线时给出最近一次心跳时间,便于判断是「刚掉线」还是「长期没上线」
|
|
543
|
+
const lastSeen = Math.max(...workers.map((w) => w.lastSeenAt || 0));
|
|
544
|
+
text = `Worker 全部离线(最近心跳 ${relTime(lastSeen)})`;
|
|
545
|
+
}
|
|
506
546
|
if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
|
|
507
547
|
ws.className = online ? 'online' : 'offline';
|
|
508
|
-
|
|
548
|
+
// 当前所在二级视图的 Worker 已注销(如配置移除):退回一级列表
|
|
549
|
+
if (currentWorkerId && !workers.some((w) => w.workerId === currentWorkerId)) navBack();
|
|
550
|
+
renderWorkerCards(workers);
|
|
551
|
+
// 首份快照到达后按地址栏 hash 恢复视图(刷新进入二级页/展开的长任务)
|
|
552
|
+
if (!routeInited) { routeInited = true; applyRoute(); }
|
|
553
|
+
}
|
|
554
|
+
// 「X 分钟前」随时间变化:存在离线 Worker 时定期用最近快照重渲染,
|
|
555
|
+
// 保持离线时长读数新鲜(全在线时不动卡片列表,避免打扰交互)
|
|
556
|
+
setInterval(() => {
|
|
557
|
+
if (workerList.length && !workerList.some((w) => w.online)) renderWorkers(workerList);
|
|
558
|
+
}, 30_000);
|
|
559
|
+
|
|
560
|
+
// 一级视图 Worker 卡片:sig 去重,心跳快照没变就不重建(避免打断点击)
|
|
561
|
+
let cardsSig = '';
|
|
562
|
+
function renderWorkerCards(workers) {
|
|
563
|
+
$('workers-empty').classList.toggle('hidden', workers.length > 0);
|
|
564
|
+
const sig = JSON.stringify(workers.map((w) => [
|
|
565
|
+
w.workerId, w.hostname, w.online, w.lastSeenAt, w.projects || [],
|
|
566
|
+
]));
|
|
567
|
+
if (sig === cardsSig) return;
|
|
568
|
+
cardsSig = sig;
|
|
569
|
+
const box = $('worker-cards');
|
|
570
|
+
box.textContent = '';
|
|
571
|
+
for (const w of workers) {
|
|
572
|
+
const card = document.createElement('div');
|
|
573
|
+
card.className = 'worker-card';
|
|
574
|
+
const name = w.hostname && w.hostname !== w.workerId ? `${w.workerId}(${w.hostname})` : w.workerId;
|
|
575
|
+
const status = w.online ? '在线' : `离线(最近心跳 ${relTime(w.lastSeenAt)})`;
|
|
576
|
+
card.innerHTML = `
|
|
577
|
+
<div class="wc-row1">
|
|
578
|
+
<span class="wc-name"></span>
|
|
579
|
+
<span class="${w.online ? 'w-online' : 'w-offline'}"></span>
|
|
580
|
+
</div>
|
|
581
|
+
<div class="wc-meta"></div>`;
|
|
582
|
+
card.querySelector('.wc-name').textContent = name; // textContent 天然防注入
|
|
583
|
+
card.querySelector('.wc-row1 span:last-child').textContent = status;
|
|
584
|
+
const projects = [...new Set(w.projects || [])];
|
|
585
|
+
card.querySelector('.wc-meta').textContent = projects.length ? `项目目录:${projects.join(' / ')}` : '未上报项目目录';
|
|
586
|
+
card.addEventListener('click', () => navToWorker(w.workerId));
|
|
587
|
+
box.appendChild(card);
|
|
588
|
+
}
|
|
509
589
|
}
|
|
510
590
|
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
591
|
+
// ---------- 两级视图导航:一级 Worker 列表 / 二级所选 Worker 详情 ----------
|
|
592
|
+
let currentWorkerId = null;
|
|
593
|
+
let routeInited = false; // 首份 Worker 快照到达前 hash 恢复无法执行(卡片还没渲染)
|
|
594
|
+
// pushRoute=false 时只切视图不动地址栏(来自 hashchange 的同步,避免二次入栈)
|
|
595
|
+
function navToWorker(workerId, pushRoute = true) {
|
|
596
|
+
const w = workerList.find((x) => x.workerId === workerId);
|
|
597
|
+
if (!w) return;
|
|
598
|
+
currentWorkerId = workerId;
|
|
599
|
+
$('worker-title').textContent = w.hostname && w.hostname !== workerId ? `${workerId}(${w.hostname})` : workerId;
|
|
600
|
+
$('view-workers').classList.add('hidden');
|
|
601
|
+
$('view-worker').classList.remove('hidden');
|
|
602
|
+
if (pushRoute) pushRoute_();
|
|
603
|
+
renderProjectOptions();
|
|
604
|
+
renderTasks(); // 切换到该 Worker 的任务过滤
|
|
605
|
+
}
|
|
606
|
+
function navBack(pushRoute = true) {
|
|
607
|
+
currentWorkerId = null;
|
|
608
|
+
$('view-worker').classList.add('hidden');
|
|
609
|
+
$('view-workers').classList.remove('hidden');
|
|
610
|
+
if (pushRoute) pushRoute_();
|
|
611
|
+
renderTasks(); // 离开二级视图时清掉过滤后的任务节点
|
|
612
|
+
}
|
|
613
|
+
$('btn-back').addEventListener('click', () => navBack());
|
|
614
|
+
|
|
615
|
+
// ---------- hash 路由:#/w/<workerId>(附 /t/<展开的任务 id>)----------
|
|
616
|
+
// 进入二级视图同步地址栏:刷新留在当前页面;长任务的展开状态也随 URL 保留
|
|
617
|
+
function route() {
|
|
618
|
+
const parts = location.hash.slice(1).split('/').filter(Boolean);
|
|
619
|
+
return {
|
|
620
|
+
worker: parts[0] === 'w' ? decodeURIComponent(parts[1] || '') : null,
|
|
621
|
+
tasks: parts[2] === 't' ? (parts[3] || '').split('.').filter(Boolean) : [],
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function buildHash() {
|
|
625
|
+
if (!currentWorkerId) return '#/';
|
|
626
|
+
// 只把当前视图可见(属于该 Worker)的展开任务写进 URL
|
|
627
|
+
const open = [...openTasks].filter((id) => {
|
|
628
|
+
const t = taskCache.get(id);
|
|
629
|
+
return t && (t.workerId === currentWorkerId || t.targetWorker === currentWorkerId);
|
|
630
|
+
});
|
|
631
|
+
const base = `#/w/${encodeURIComponent(currentWorkerId)}`;
|
|
632
|
+
return open.length ? `${base}/t/${open.join('.')}` : base;
|
|
633
|
+
}
|
|
634
|
+
function pushRoute_() {
|
|
635
|
+
const h = buildHash();
|
|
636
|
+
if (location.hash === h) return;
|
|
637
|
+
history.pushState(null, '', h);
|
|
638
|
+
}
|
|
639
|
+
// 展开/收起只替换当前历史条目:不给返回键堆一堆中间状态
|
|
640
|
+
function syncTaskRoute() {
|
|
641
|
+
history.replaceState(null, '', buildHash());
|
|
642
|
+
}
|
|
643
|
+
function applyRoute() {
|
|
644
|
+
const r = route();
|
|
645
|
+
if (r.worker && r.worker !== currentWorkerId) navToWorker(r.worker, false);
|
|
646
|
+
else if (!r.worker && currentWorkerId) navBack(false);
|
|
647
|
+
for (const id of r.tasks) { // 刷新后恢复长任务的展开(日志区)
|
|
648
|
+
if (openTasks.has(id) || !taskEls.has(id)) continue;
|
|
649
|
+
openTasks.add(id);
|
|
650
|
+
const entry = taskEls.get(id);
|
|
651
|
+
entry.el.classList.add('open');
|
|
652
|
+
entry.toggle.textContent = '收起';
|
|
653
|
+
loadLogs(id);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
window.addEventListener('hashchange', applyRoute);
|
|
657
|
+
|
|
658
|
+
// 项目目录下拉:只列所选 Worker 上报的项目目录(显式指派,不做跨机并集);
|
|
659
|
+
// 末尾常驻「+ 新建目录…」,输入的新目录在任务执行时由 Worker 自动创建
|
|
660
|
+
// (pty 执行前会 mkdir)
|
|
661
|
+
const NEW_PROJECT = '__new__';
|
|
662
|
+
const projectSels = ['f-pty-project'];
|
|
663
|
+
|
|
664
|
+
function renderProjectOptions() {
|
|
665
|
+
const selected = workerList.find((w) => w.workerId === currentWorkerId);
|
|
666
|
+
const projects = [...new Set(selected ? (selected.projects || []) : [])].sort();
|
|
667
|
+
if (!projects.length) return; // Worker 尚未上报,保留默认 default 选项
|
|
668
|
+
for (const id of projectSels) {
|
|
669
|
+
const sel = $(id);
|
|
670
|
+
const prev = sel.value === NEW_PROJECT ? 'default' : sel.value;
|
|
671
|
+
sel.textContent = '';
|
|
672
|
+
for (const p of projects) {
|
|
673
|
+
const opt = document.createElement('option');
|
|
674
|
+
opt.value = p;
|
|
675
|
+
opt.textContent = p;
|
|
676
|
+
sel.appendChild(opt);
|
|
677
|
+
}
|
|
678
|
+
const newOpt = document.createElement('option');
|
|
679
|
+
newOpt.value = NEW_PROJECT;
|
|
680
|
+
newOpt.textContent = '+ 新建目录…';
|
|
681
|
+
sel.appendChild(newOpt);
|
|
682
|
+
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
|
|
524
683
|
}
|
|
525
|
-
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
|
|
526
684
|
}
|
|
527
685
|
|
|
686
|
+
// 选「+ 新建目录…」:弹输入框收目录名,合法则加入两个下拉并选中;
|
|
687
|
+
// 取消或非法则退回原选项,NEW_PROJECT 值不会进入提交 payload
|
|
688
|
+
function handleNewProject(ev) {
|
|
689
|
+
const sel = ev.target;
|
|
690
|
+
if (sel.value !== NEW_PROJECT) return;
|
|
691
|
+
const fallback = [...sel.options].find((o) => o.value !== NEW_PROJECT)?.value || '';
|
|
692
|
+
const name = (window.prompt('新项目目录名(任务执行时在 Worker workspace 下自动创建)') || '').trim();
|
|
693
|
+
if (!name || !/^[\w.-]+$/.test(name) || name.includes('..')) {
|
|
694
|
+
if (name) toast('目录名只能含字母/数字/._-,且不含 ..');
|
|
695
|
+
sel.value = fallback;
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
for (const id of projectSels) {
|
|
699
|
+
const s = $(id);
|
|
700
|
+
if (![...s.options].some((o) => o.value === name)) {
|
|
701
|
+
const opt = document.createElement('option');
|
|
702
|
+
opt.value = name;
|
|
703
|
+
opt.textContent = name;
|
|
704
|
+
s.insertBefore(opt, s.querySelector(`option[value="${NEW_PROJECT}"]`));
|
|
705
|
+
}
|
|
706
|
+
s.value = name;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
for (const id of projectSels) $(id).addEventListener('change', handleNewProject);
|
|
710
|
+
|
|
528
711
|
function createTaskEl(t) {
|
|
529
712
|
const div = document.createElement('div');
|
|
530
713
|
div.className = 'task new' + (openTasks.has(t.id) ? ' open' : '');
|
|
@@ -534,7 +717,6 @@ function createTaskEl(t) {
|
|
|
534
717
|
<span class="badge"></span>
|
|
535
718
|
</div>
|
|
536
719
|
<div class="meta"></div>
|
|
537
|
-
<div class="term hidden"></div>
|
|
538
720
|
<div class="logs"></div>
|
|
539
721
|
<div class="actions">
|
|
540
722
|
<button data-act="toggle">展开</button>
|
|
@@ -546,19 +728,13 @@ function createTaskEl(t) {
|
|
|
546
728
|
el: div,
|
|
547
729
|
badge: div.querySelector('.badge'),
|
|
548
730
|
meta: div.querySelector('.meta'),
|
|
549
|
-
termEl: div.querySelector('.term'),
|
|
550
731
|
logsEl: div.querySelector('.logs'),
|
|
551
732
|
toggle: div.querySelector('[data-act=toggle]'),
|
|
552
733
|
term: div.querySelector('[data-act=term]'),
|
|
553
734
|
cancel: div.querySelector('[data-act=cancel]'),
|
|
554
735
|
status: null,
|
|
555
736
|
metaText: null,
|
|
556
|
-
termSig: null,
|
|
557
737
|
};
|
|
558
|
-
// 点 shell 视图本身也展开(手机上比点小按钮顺手)
|
|
559
|
-
entry.termEl.addEventListener('click', () => {
|
|
560
|
-
if (!openTasks.has(t.id)) entry.toggle.click();
|
|
561
|
-
});
|
|
562
738
|
entry.term.addEventListener('click', () => openTerm(t));
|
|
563
739
|
entry.toggle.addEventListener('click', async () => {
|
|
564
740
|
if (openTasks.has(t.id)) {
|
|
@@ -570,7 +746,7 @@ function createTaskEl(t) {
|
|
|
570
746
|
await loadLogs(t.id);
|
|
571
747
|
}
|
|
572
748
|
entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
|
|
573
|
-
|
|
749
|
+
syncTaskRoute(); // 展开状态入 URL:长任务执行中刷新仍留在任务页面
|
|
574
750
|
});
|
|
575
751
|
entry.cancel.addEventListener('click', async () => {
|
|
576
752
|
try {
|
|
@@ -592,66 +768,20 @@ function patchTask(t, entry) {
|
|
|
592
768
|
entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
|
|
593
769
|
}
|
|
594
770
|
entry.term.classList.toggle('hidden', t.type !== 'pty'); // pty 任务入口
|
|
595
|
-
|
|
771
|
+
// Worker 标注:已领取显示执行者,pending 定向任务显示目标
|
|
772
|
+
const workerPart = t.workerId ? `@${t.workerId}` : (t.targetWorker ? `→ ${t.targetWorker}` : '');
|
|
773
|
+
const meta = `${t.type}${workerPart ? ' · ' + workerPart : ''} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
|
|
596
774
|
if (entry.metaText !== meta) { entry.metaText = meta; entry.meta.textContent = meta; }
|
|
597
775
|
entry.el.classList.toggle('open', openTasks.has(t.id));
|
|
598
|
-
patchTerm(t, entry);
|
|
599
776
|
// 展开且有缓存:内容没变就不动 DOM,保留滚动位置
|
|
600
777
|
if (openTasks.has(t.id) && logCache.has(t.id)) writeLogs(t.id, logCache.get(t.id));
|
|
601
778
|
}
|
|
602
779
|
|
|
603
|
-
// shell 视图:$ 命令回显 + 末尾最多 5 行 stdout;展开后让位给全量日志。
|
|
604
|
-
// 用 sig 跳过无变化的重绘,避免日志流式到达时整卡闪烁。
|
|
605
|
-
function patchTerm(t, entry) {
|
|
606
|
-
const open = openTasks.has(t.id);
|
|
607
|
-
const tail = Array.isArray(t.tail) ? t.tail : [];
|
|
608
|
-
const sig = `${open}|${t.cmd || ''}|${t.outLines || 0}|${tail.join('\n')}`;
|
|
609
|
-
if (entry.termSig === sig) return;
|
|
610
|
-
entry.termSig = sig;
|
|
611
|
-
entry.termEl.textContent = '';
|
|
612
|
-
if (open || (!t.cmd && !tail.length)) { entry.termEl.classList.add('hidden'); return; }
|
|
613
|
-
entry.termEl.classList.remove('hidden');
|
|
614
|
-
const omitted = (t.outLines || 0) - tail.length;
|
|
615
|
-
if (omitted > 0) {
|
|
616
|
-
const d = document.createElement('div');
|
|
617
|
-
d.className = 'dimline';
|
|
618
|
-
d.textContent = `… 更早 ${omitted} 行输出已折叠,点击展开`;
|
|
619
|
-
entry.termEl.appendChild(d);
|
|
620
|
-
}
|
|
621
|
-
if (t.cmd) {
|
|
622
|
-
const line = document.createElement('div');
|
|
623
|
-
const p = document.createElement('span');
|
|
624
|
-
p.className = 'prompt';
|
|
625
|
-
p.textContent = '$ ';
|
|
626
|
-
line.appendChild(p);
|
|
627
|
-
line.appendChild(document.createTextNode(t.cmd.startsWith('$ ') ? t.cmd.slice(2) : t.cmd));
|
|
628
|
-
entry.termEl.appendChild(line);
|
|
629
|
-
}
|
|
630
|
-
for (const l of tail) {
|
|
631
|
-
const line = document.createElement('div');
|
|
632
|
-
line.textContent = l;
|
|
633
|
-
entry.termEl.appendChild(line);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// 任务运行中:WS 日志流增量维护预览(规则与 server 端 shellPreview 一致)
|
|
638
|
-
function mergePreview(id, lines) {
|
|
639
|
-
const t = taskCache.get(id);
|
|
640
|
-
if (!t) return;
|
|
641
|
-
let changed = false;
|
|
642
|
-
for (const line of lines) {
|
|
643
|
-
if (line.startsWith('$ ')) { if (!t.cmd) { t.cmd = line; changed = true; } continue; }
|
|
644
|
-
if (line.startsWith('[worker]') || line.startsWith('[server]') || line === '') continue;
|
|
645
|
-
t.outLines = (t.outLines || 0) + 1;
|
|
646
|
-
t.tail = [...(t.tail || []), line].slice(-5);
|
|
647
|
-
changed = true;
|
|
648
|
-
}
|
|
649
|
-
if (changed) renderTasks();
|
|
650
|
-
}
|
|
651
|
-
|
|
652
780
|
function renderTasks() {
|
|
653
781
|
const list = $('task-list');
|
|
654
|
-
const tasks = [...taskCache.values()]
|
|
782
|
+
const tasks = [...taskCache.values()]
|
|
783
|
+
.filter((t) => !currentWorkerId || t.workerId === currentWorkerId || t.targetWorker === currentWorkerId)
|
|
784
|
+
.sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
|
|
655
785
|
// 清理已不存在的任务节点
|
|
656
786
|
const ids = new Set(tasks.map((t) => t.id));
|
|
657
787
|
for (const [id, entry] of taskEls) {
|
|
@@ -705,8 +835,16 @@ function appendLogs(id, lines) {
|
|
|
705
835
|
|
|
706
836
|
// ---------- 交互终端(pty 任务:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
|
|
707
837
|
// 手机端无物理键盘:隐藏 textarea 承接击键,工具条补 Tab/方向键/Ctrl 组合键
|
|
708
|
-
const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach }
|
|
838
|
+
const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach, liveHint }
|
|
709
839
|
let activeTermId = null;
|
|
840
|
+
// 会话未就绪时的原因文案(与服务端 pty.attach 应答的 reason 对应)
|
|
841
|
+
const ATTACH_HINTS = {
|
|
842
|
+
not_claimed: '任务尚未被领取,等待 Worker 上线领取…',
|
|
843
|
+
worker_offline: 'Worker 离线(通道断开),重连后可自动恢复…',
|
|
844
|
+
starting: '任务已被领取,会话启动中,请稍候…',
|
|
845
|
+
ended: '会话已结束或已被回收',
|
|
846
|
+
unknown: '任务尚未被领取或 Worker 离线,请稍候…',
|
|
847
|
+
};
|
|
710
848
|
const KEY_SEQ = { BS: '\x7f', Tab: '\t', Esc: '\x1b', Up: '\x1b[A', Down: '\x1b[B', CtrlC: '\x03', CtrlD: '\x04' };
|
|
711
849
|
|
|
712
850
|
function termStatus(ent) {
|
|
@@ -714,14 +852,14 @@ function termStatus(ent) {
|
|
|
714
852
|
if (!ent) { el.textContent = ''; return; }
|
|
715
853
|
if (!wsReady) el.textContent = '实时通道断开,重连中…';
|
|
716
854
|
else if (ent.live) el.textContent = '已连接';
|
|
717
|
-
else el.textContent =
|
|
855
|
+
else el.textContent = `会话未就绪(${ent.liveHint || ATTACH_HINTS.unknown})`;
|
|
718
856
|
}
|
|
719
857
|
|
|
720
858
|
function ensureTerm(t) {
|
|
721
859
|
let ent = termEntries.get(t.id);
|
|
722
860
|
if (ent) return ent;
|
|
723
861
|
if (!window.Terminal || !window.FitAddon) {
|
|
724
|
-
toast('xterm.js
|
|
862
|
+
toast('xterm.js 加载失败(vendor/ 资源缺失,请刷新重试)');
|
|
725
863
|
return null;
|
|
726
864
|
}
|
|
727
865
|
const xt = new Terminal({
|
|
@@ -730,13 +868,22 @@ function ensureTerm(t) {
|
|
|
730
868
|
});
|
|
731
869
|
const fit = new FitAddon.FitAddon();
|
|
732
870
|
xt.loadAddon(fit);
|
|
733
|
-
|
|
734
|
-
|
|
871
|
+
// 每个终端独占一个 pane 容器:直接挂在 #term-body 上会让多个实例叠在一起串台
|
|
872
|
+
const pane = document.createElement('div');
|
|
873
|
+
pane.className = 'term-pane';
|
|
874
|
+
$('term-body').appendChild(pane);
|
|
875
|
+
xt.open(pane);
|
|
876
|
+
ent = { xt, fit, pane, live: false, pendingAttach: false, liveHint: '' };
|
|
735
877
|
termEntries.set(t.id, ent);
|
|
736
878
|
xt.onData((data) => sendWs({ type: 'session.input', taskId: t.id, data }));
|
|
737
879
|
return ent;
|
|
738
880
|
}
|
|
739
881
|
|
|
882
|
+
// 只显示当前终端的 pane,其余隐藏(实例不销毁,后台继续收输出)
|
|
883
|
+
function showTermPane(id) {
|
|
884
|
+
for (const [tid, e] of termEntries) e.pane.classList.toggle('active', tid === id);
|
|
885
|
+
}
|
|
886
|
+
|
|
740
887
|
function fitActiveTerm() {
|
|
741
888
|
const ent = termEntries.get(activeTermId);
|
|
742
889
|
if (!ent) return;
|
|
@@ -750,6 +897,7 @@ function openTerm(t) {
|
|
|
750
897
|
activeTermId = t.id;
|
|
751
898
|
$('term-title').textContent = t.title || '交互终端';
|
|
752
899
|
$('term-overlay').classList.remove('hidden');
|
|
900
|
+
showTermPane(t.id); // 先亮出本终端的 pane 再 fit(隐藏状态下 fit 拿不到尺寸)
|
|
753
901
|
fitActiveTerm();
|
|
754
902
|
ent.pendingAttach = true;
|
|
755
903
|
sendWs({ type: 'pty.attach', taskId: t.id }); // 取回看缓冲 + 会话存活状态
|
|
@@ -771,6 +919,7 @@ function disposeTerm(id) {
|
|
|
771
919
|
termEntries.delete(id);
|
|
772
920
|
if (activeTermId === id) closeTermOverlay();
|
|
773
921
|
try { ent.xt.dispose(); } catch { /* 已释放 */ }
|
|
922
|
+
ent.pane.remove();
|
|
774
923
|
}
|
|
775
924
|
|
|
776
925
|
// 输入代理里常驻一个哨兵字符:手机键盘在空 textarea 上按退格不产生任何事件,
|
|
@@ -837,53 +986,20 @@ async function refresh() {
|
|
|
837
986
|
toast(err.message);
|
|
838
987
|
}
|
|
839
988
|
}
|
|
840
|
-
setInterval(() => { if (!wsReady && !wsAuthRejected) refresh(); }, 4000);
|
|
841
|
-
|
|
842
|
-
// ---------- 类型切换 ----------
|
|
843
|
-
let currentType = 'agent';
|
|
844
|
-
document.querySelectorAll('#type-bar button').forEach((btn) => {
|
|
845
|
-
btn.addEventListener('click', () => {
|
|
846
|
-
currentType = btn.dataset.type;
|
|
847
|
-
document.querySelectorAll('#type-bar button').forEach((b) => b.classList.toggle('active', b === btn));
|
|
848
|
-
for (const t of ['agent', 'shell', 'pty', 'script', 'chat']) {
|
|
849
|
-
$('fields-' + t).classList.toggle('hidden', t !== currentType);
|
|
850
|
-
}
|
|
851
|
-
});
|
|
852
|
-
});
|
|
989
|
+
setInterval(() => { if (!wsReady && !wsAuthRejected && hasSecret()) refresh(); }, 4000);
|
|
853
990
|
|
|
854
991
|
// ---------- 提交任务(REST,WS 负责推送变更)----------
|
|
855
992
|
$('btn-submit').addEventListener('click', async () => {
|
|
993
|
+
// 显式指派:二级视图下任务固定提交给当前 Worker
|
|
994
|
+
const targetWorker = currentWorkerId;
|
|
995
|
+
if (!targetWorker) return toast('请先在 Worker 列表中选择 Worker');
|
|
856
996
|
const title = $('f-title').value.trim();
|
|
857
|
-
|
|
858
|
-
if (
|
|
859
|
-
payload = {
|
|
860
|
-
prompt: $('f-prompt').value.trim(),
|
|
861
|
-
project: $('f-project').value.trim() || 'default',
|
|
862
|
-
agent: $('f-agent').value,
|
|
863
|
-
};
|
|
864
|
-
if (!payload.prompt) return toast('请填写 Prompt');
|
|
865
|
-
} else if (currentType === 'shell') {
|
|
866
|
-
payload = { command: $('f-command').value.trim() };
|
|
867
|
-
if (!payload.command) return toast('请填写命令');
|
|
868
|
-
} else if (currentType === 'pty') {
|
|
869
|
-
payload = { project: $('f-pty-project').value.trim() || 'default' };
|
|
870
|
-
if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
|
|
871
|
-
} else if (currentType === 'script') {
|
|
872
|
-
payload = {
|
|
873
|
-
script: $('f-script').value.trim(),
|
|
874
|
-
args: $('f-args').value.trim() ? $('f-args').value.trim().split(/\s+/) : [],
|
|
875
|
-
};
|
|
876
|
-
if (!payload.script) return toast('请填写脚本名');
|
|
877
|
-
} else {
|
|
878
|
-
payload = { message: $('f-message').value.trim() };
|
|
879
|
-
if (!payload.message) return toast('请填写留言');
|
|
880
|
-
}
|
|
997
|
+
const payload = { project: $('f-pty-project').value.trim() || 'default' };
|
|
998
|
+
if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
|
|
881
999
|
try {
|
|
882
1000
|
$('btn-submit').disabled = true;
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
await api('/api/tasks', { type: currentType, title: title || autoTitle, payload });
|
|
886
|
-
toast(currentType === 'pty' ? '已提交,任务运行后点「进入终端」' : '任务已提交');
|
|
1001
|
+
await api('/api/tasks', { type: 'pty', title: title || '交互终端', payload, targetWorker });
|
|
1002
|
+
toast('已提交,任务运行后点「进入终端」');
|
|
887
1003
|
$('f-title').value = '';
|
|
888
1004
|
if (!wsReady) await refresh();
|
|
889
1005
|
} catch (err) {
|
|
@@ -894,7 +1010,9 @@ $('btn-submit').addEventListener('click', async () => {
|
|
|
894
1010
|
});
|
|
895
1011
|
|
|
896
1012
|
connectWS();
|
|
897
|
-
|
|
1013
|
+
// 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据(无密钥时不发请求)
|
|
1014
|
+
if (hasSecret()) refresh();
|
|
1015
|
+
else setConnMode(false); // 无密钥不发起任何连接,状态条直接落到轮询态
|
|
898
1016
|
</script>
|
|
899
1017
|
</body>
|
|
900
1018
|
</html>
|