@jacksontian/kite-server 0.4.1 → 0.5.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/web/pc.html DELETED
@@ -1,1192 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <meta name="theme-color" content="#111827">
7
- <script>
8
- // 主题初始化(先于样式解析,避免先闪一下另一套配色):本地偏好 > 系统外观
9
- document.documentElement.dataset.theme = localStorage.getItem('rw_theme')
10
- || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
11
- </script>
12
- <title>Kite 控制台</title>
13
- <!-- xterm 随包自托管(vendor/ 为锁版本的官方产物):不依赖 CDN,
14
- 杜绝 CDN 投毒/HTTP 下被篡改注入窃取密钥的供应链风险 -->
15
- <link rel="stylesheet" href="vendor/xterm.min.css">
16
- <style>
17
- :root {
18
- --bg: #111827; --panel: #1f2937; --line: #374151;
19
- --text: #e5e7eb; --dim: #9ca3af; --accent: #3b82f6;
20
- --ok: #22c55e; --err: #ef4444; --warn: #f59e0b;
21
- --term-bg: #0b1220; --log-text: #a5f3fc;
22
- }
23
- /* light 主题:xterm 是 canvas 绘制读不到 CSS 变量,终端色由 JS 同步切 */
24
- :root[data-theme='light'] {
25
- --bg: #f4f6f9; --panel: #fff; --line: #d9dee7;
26
- --text: #1f2937; --dim: #64748b; --accent: #2563eb;
27
- --ok: #16a34a; --err: #dc2626; --warn: #d97706;
28
- --term-bg: #fff; --log-text: #0e7490;
29
- }
30
- * { box-sizing: border-box; margin: 0; padding: 0; }
31
- body {
32
- font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
33
- background: var(--bg); color: var(--text);
34
- padding: 20px 32px 40px;
35
- max-width: 1600px; margin: 0 auto;
36
- }
37
- header { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px 16px; gap: 8px; }
38
- h1 { font-size: 20px; font-weight: 600; }
39
- .header-right { display: flex; align-items: center; gap: 8px; }
40
- #conn-mode { font-size: 12px; padding: 3px 10px; border-radius: 999px; background: var(--panel); color: var(--dim); white-space: nowrap; }
41
- #conn-mode.realtime { color: var(--ok); }
42
- #conn-mode.polling { color: var(--warn); }
43
- #worker-status { font-size: 13px; padding: 4px 12px; border-radius: 999px; background: var(--panel); color: var(--dim); white-space: nowrap; }
44
- #worker-status.online { color: var(--ok); }
45
- #worker-status.offline { color: var(--err); }
46
- #btn-fullscreen, #btn-theme {
47
- font-size: 13px; padding: 4px 12px; border-radius: 8px; cursor: pointer;
48
- background: var(--panel); color: var(--dim); border: 1px solid var(--line); white-space: nowrap;
49
- }
50
- #btn-fullscreen:hover, #btn-theme:hover { border-color: var(--accent); color: var(--accent); }
51
-
52
- /* 明文 HTTP 部署的风险提示(仅非 localhost 的 http: 环境显示) */
53
- #http-warn {
54
- background: rgba(239,68,68,.12); border: 1px solid var(--err); color: var(--err);
55
- border-radius: 10px; padding: 10px 14px; font-size: 13px; line-height: 1.6; margin-bottom: 16px;
56
- }
57
-
58
- /* 双栏桌面布局:左栏提交任务,右栏任务流;
59
- 一级视图(.one-col)退化为全宽单栏,Worker 卡片横向铺开,不挤在窄左栏 */
60
- .layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 18px; align-items: start; }
61
- .layout.one-col { grid-template-columns: minmax(0, 1fr); }
62
- @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
63
- .col-left { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 16px; }
64
- /* 全宽单栏时密钥面板拉通一行太长,限个舒适宽度 */
65
- .layout.one-col #auth-panel { max-width: 640px; }
66
-
67
- .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }
68
- label { display: block; font-size: 12px; color: var(--dim); margin: 12px 0 5px; }
69
- input, textarea, select {
70
- width: 100%; background: var(--bg); color: var(--text);
71
- border: 1px solid var(--line); border-radius: 8px;
72
- padding: 9px 11px; font-size: 14px; font-family: inherit;
73
- }
74
- textarea { min-height: 84px; resize: vertical; }
75
- input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
76
-
77
- .submit {
78
- width: 100%; margin-top: 16px; padding: 11px;
79
- background: var(--accent); color: #fff; border: none; border-radius: 10px;
80
- font-size: 15px; font-weight: 600; cursor: pointer;
81
- }
82
- .submit:disabled { opacity: .5; }
83
-
84
- .task { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin-bottom: 10px; background: var(--panel); }
85
- .task .row1 { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
86
- .task .title { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
87
- .task .meta { font-size: 12px; color: var(--dim); margin-top: 5px; }
88
- .badge { font-size: 12px; padding: 2px 10px; border-radius: 999px; white-space: nowrap; }
89
- .b-pending { background: rgba(245,158,11,.15); color: var(--warn); }
90
- .b-running { background: rgba(59,130,246,.15); color: var(--accent); }
91
- .b-done { background: rgba(34,197,94,.15); color: var(--ok); }
92
- .b-failed { background: rgba(239,68,68,.15); color: var(--err); }
93
- .b-canceled { background: rgba(156,163,175,.15); color: var(--dim); }
94
-
95
- .logs {
96
- margin-top: 10px; background: var(--term-bg); border-radius: 8px; padding: 10px;
97
- font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.6;
98
- max-height: min(60vh, 640px); overflow-y: auto; white-space: pre-wrap; word-break: break-all;
99
- color: var(--log-text); display: none;
100
- }
101
- .task.open .logs { display: block; }
102
- .task .actions { margin-top: 10px; display: flex; gap: 8px; }
103
- .task .actions button {
104
- font-size: 13px; padding: 5px 16px; border-radius: 6px;
105
- background: var(--bg); color: var(--dim); border: 1px solid var(--line); cursor: pointer;
106
- }
107
- .task .actions button:disabled { opacity: .5; }
108
- .task .actions button.term-btn { color: var(--accent); border-color: var(--accent); }
109
-
110
- /* 交互终端全屏覆盖层(pty 任务);PC 有物理键盘,输入直接由 xterm 承接 */
111
- #term-overlay {
112
- position: fixed; inset: 0; z-index: 50; background: var(--term-bg);
113
- display: flex; flex-direction: column;
114
- }
115
- #term-overlay.hidden { display: none; }
116
- #term-head { display: flex; align-items: center; gap: 10px; padding: 8px 16px; }
117
- /* tab 条:每个打开的终端一个 tab,非当前终端有新输出时亮活动圆点 */
118
- #term-tabs { flex: 1; display: flex; gap: 6px; overflow-x: auto; min-width: 0; }
119
- .term-tab {
120
- display: flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 8px;
121
- border: 1px solid var(--line); background: var(--panel); color: var(--dim);
122
- font-size: 13px; cursor: pointer; white-space: nowrap; max-width: 220px;
123
- }
124
- .term-tab .tt-title { overflow: hidden; text-overflow: ellipsis; }
125
- .term-tab.active { border-color: var(--accent); color: var(--text); background: rgba(59,130,246,.12); }
126
- .term-tab .tt-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--warn); display: none; }
127
- .term-tab.dirty .tt-dot { display: block; }
128
- #term-layout-btns { display: flex; gap: 6px; }
129
- #term-layout-btns button {
130
- font-size: 12px; padding: 4px 12px; border-radius: 6px; cursor: pointer;
131
- background: var(--panel); color: var(--dim); border: 1px solid var(--line);
132
- }
133
- #term-layout-btns button.active { border-color: var(--accent); color: var(--accent); background: rgba(59,130,246,.12); }
134
- #term-fullscreen, #term-close { font-size: 13px; padding: 5px 16px; border-radius: 6px; background: var(--panel); color: var(--text); border: 1px solid var(--line); cursor: pointer; white-space: nowrap; }
135
- #term-fullscreen:hover { border-color: var(--accent); color: var(--accent); }
136
- /* 单屏模式:pane 绝对定位叠层,只显示 active 的一个;
137
- 并排(grid)模式:所有终端按 CSS grid 平铺,同时盯多个 pty */
138
- #term-body { flex: 1; margin: 0 16px 16px; min-height: 0; position: relative; }
139
- #term-body.grid {
140
- display: grid; gap: 10px;
141
- grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
142
- grid-auto-rows: minmax(0, 1fr);
143
- }
144
- .term-pane { position: absolute; inset: 0; display: none; flex-direction: column; }
145
- .term-pane.active { display: flex; }
146
- #term-body.grid .term-pane {
147
- position: relative; inset: auto; display: flex; min-height: 0;
148
- border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
149
- }
150
- #term-body.grid .term-pane.active { border-color: var(--accent); }
151
- .tp-head { display: flex; align-items: center; gap: 10px; padding: 6px 10px; background: var(--panel); flex: none; }
152
- .tp-title { font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 30%; }
153
- .tp-status { flex: 1; font-size: 11px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
154
- .tp-close { flex: none; border: none; background: none; color: var(--dim); font-size: 14px; cursor: pointer; padding: 0 4px; }
155
- .tp-close:hover { color: var(--err); }
156
- .tp-term { flex: 1; min-height: 0; }
157
- #auth-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
158
- #auth-bar .title { font-size: 13px; color: var(--ok); }
159
- #auth-bar .edit { font-size: 12px; color: var(--dim); }
160
- .secret-row { display: flex; gap: 8px; }
161
- .secret-row input { flex: 1; }
162
- #btn-eye, #btn-done {
163
- flex: none; padding: 0 14px; border: 1px solid var(--line); border-radius: 8px;
164
- background: var(--bg); color: var(--dim); font-size: 13px; cursor: pointer;
165
- }
166
- #btn-eye.on { color: var(--accent); border-color: var(--accent); }
167
- #btn-done { color: var(--ok); }
168
- @keyframes task-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
169
- .task.new { animation: task-in .25s ease; }
170
- #toast {
171
- position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%);
172
- background: #000c; color: #fff; padding: 9px 18px; border-radius: 8px;
173
- font-size: 13px; opacity: 0; transition: opacity .2s; pointer-events: none;
174
- }
175
- /* 两级视图:一级 Worker 列表 / 二级所选 Worker 详情 */
176
- #back-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
177
- #btn-back {
178
- padding: 6px 14px; border: 1px solid var(--line); border-radius: 8px;
179
- background: var(--panel); color: var(--text); font-size: 13px; cursor: pointer;
180
- }
181
- #btn-back:hover { border-color: var(--accent); color: var(--accent); }
182
- #worker-title { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
183
- #worker-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
184
- .worker-card {
185
- background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
186
- padding: 14px; cursor: pointer; transition: border-color .15s;
187
- }
188
- .worker-card:hover { border-color: var(--accent); }
189
- .worker-card .wc-row1 { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
190
- .worker-card .wc-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
191
- .worker-card .wc-meta { font-size: 12px; color: var(--dim); margin-top: 6px; line-height: 1.5; word-break: break-all; }
192
- .w-online { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,.15); color: var(--ok); white-space: nowrap; }
193
- .w-offline { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(239,68,68,.15); color: var(--err); white-space: nowrap; }
194
- .empty-tip { color: var(--dim); font-size: 13px; text-align: center; padding: 40px 0; }
195
-
196
- .hidden { display: none !important; }
197
- </style>
198
- </head>
199
- <body>
200
- <header>
201
- <h1>Kite</h1>
202
- <div class="header-right">
203
- <span id="conn-mode">连接中</span>
204
- <span id="worker-status">检测中…</span>
205
- <button id="btn-theme" type="button" title="切换 浅色/深色 主题">浅色</button>
206
- <button id="btn-fullscreen" type="button">全屏</button>
207
- </div>
208
- </header>
209
-
210
- <!-- 明文 HTTP 部署警告:任务内容与签名流量可被窃听,建议尽快上 HTTPS -->
211
- <div id="http-warn" class="hidden">
212
- ⚠️ 当前为明文 HTTP 访问:任务、日志与签名在网络链路上明文可见,且本机无法使用安全加密 API。
213
- 请勿在不信任的网络(如公共 WiFi)使用,并尽快为服务端配置 HTTPS(如 Caddy 自动证书)。
214
- </div>
215
-
216
- <div class="layout one-col" id="layout">
217
- <div class="col-left">
218
-
219
- <!-- 鉴权设置:密钥已存 localStorage 时折叠成一行,点「修改」才展开 -->
220
- <div class="panel" id="auth-panel">
221
- <div id="auth-bar" class="hidden">
222
- <span class="title">✓ 密钥已配置(存本机浏览器,不上网传输)</span>
223
- <span class="edit">修改</span>
224
- </div>
225
- <div id="auth-form">
226
- <label>API Secret(签名密钥:只存本机浏览器,永不在网络上传输)</label>
227
- <div class="secret-row">
228
- <input id="secret" type="password" placeholder="粘贴 API_SECRET" autocomplete="off">
229
- <button id="btn-eye" type="button">显示</button>
230
- <button id="btn-done" type="button">完成</button>
231
- </div>
232
- </div>
233
- </div>
234
-
235
- <!-- 一级视图:Worker 列表,选中某个 Worker 进入二级详情 -->
236
- <div id="view-workers">
237
- <div id="worker-cards"></div>
238
- <div id="workers-empty" class="empty-tip hidden">暂无已注册 Worker</div>
239
- </div>
240
-
241
- <!-- 二级视图:所选 Worker 的新建任务 -->
242
- <div id="view-worker" class="hidden">
243
- <div id="back-row">
244
- <button id="btn-back" type="button">‹ Worker 列表</button>
245
- <span id="worker-title"></span>
246
- </div>
247
-
248
- <!-- 新建任务(仅 pty 交互终端) -->
249
- <div class="panel">
250
- <label>标题</label>
251
- <input id="f-title" placeholder="一句话描述这个任务">
252
-
253
- <label>项目目录(终端的起始目录,Worker workspace 下已有目录)</label>
254
- <select id="f-pty-project"><option value="default">default</option></select>
255
- <label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
256
-
257
- <button class="submit" id="btn-submit">提交任务</button>
258
- </div>
259
- </div><!-- /view-worker -->
260
-
261
- </div><!-- /.col-left -->
262
-
263
- <!-- 任务列表(仅所选 Worker,进入二级视图后显示在右侧) -->
264
- <div id="task-list" class="hidden"></div>
265
-
266
- </div><!-- /.layout -->
267
-
268
- <!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY。
269
- tab 条 + 单屏/并排布局切换(并排时多个 pty 同屏平铺),活动圆点标记非当前终端的新输出 -->
270
- <div id="term-overlay" class="hidden">
271
- <div id="term-head">
272
- <div id="term-tabs"></div>
273
- <div id="term-layout-btns">
274
- <button type="button" data-layout="single">单屏</button>
275
- <button type="button" data-layout="grid">并排</button>
276
- </div>
277
- <button id="term-fullscreen" type="button">全屏</button>
278
- <button id="term-close" type="button">收起</button>
279
- </div>
280
- <div id="term-body"></div>
281
- </div>
282
-
283
- <div id="toast"></div>
284
-
285
- <script src="vendor/xterm.min.js"></script>
286
- <script src="vendor/addon-fit.min.js"></script>
287
- <script>
288
- 'use strict';
289
- const $ = (id) => document.getElementById(id);
290
- // 非 localhost 的明文 HTTP 环境:常驻风险提示(localhost 本地联调不打扰)
291
- if (location.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(location.hostname)) {
292
- $('http-warn').classList.remove('hidden');
293
- }
294
- const secretInput = $('secret');
295
- secretInput.value = localStorage.getItem('rw_secret') || '';
296
-
297
- function toast(msg) {
298
- const el = $('toast');
299
- el.textContent = msg; el.style.opacity = '1';
300
- setTimeout(() => { el.style.opacity = '0'; }, 2200);
301
- }
302
-
303
- // ---------- 主题:light/dark 切换,偏好存 localStorage,首访跟随系统外观 ----------
304
- // xterm 是 canvas 绘制读不到 CSS 变量,终端配色在这里维护并同步给各实例;
305
- // 浅色下重排 16 色调色板(默认 ANSI 的黄/亮黄在白底上不可见)
306
- const TERM_THEME = {
307
- dark: { background: '#0b1220', foreground: '#e5e7eb' },
308
- light: {
309
- background: '#ffffff', foreground: '#111827', cursor: '#111827',
310
- black: '#111827', red: '#dc2626', green: '#16a34a', yellow: '#ca8a04',
311
- blue: '#2563eb', magenta: '#9333ea', cyan: '#0891b2', white: '#f3f4f6',
312
- brightBlack: '#6b7280', brightRed: '#ef4444', brightGreen: '#22c55e',
313
- brightYellow: '#eab308', brightBlue: '#3b82f6', brightMagenta: '#a855f7',
314
- brightCyan: '#22d3ee', brightWhite: '#ffffff',
315
- },
316
- };
317
- function currentTheme() {
318
- return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
319
- }
320
- function applyTheme(theme) {
321
- document.documentElement.dataset.theme = theme;
322
- localStorage.setItem('rw_theme', theme);
323
- $('btn-theme').textContent = theme === 'light' ? '深色' : '浅色';
324
- document.querySelector('meta[name="theme-color"]').content = theme === 'light' ? '#f4f6f9' : '#111827';
325
- for (const ent of termEntries.values()) ent.xt.options.theme = { ...TERM_THEME[theme] };
326
- }
327
- $('btn-theme').addEventListener('click', () => applyTheme(currentTheme() === 'light' ? 'dark' : 'light'));
328
-
329
- // ---------- 纯 JS SHA-256 / HMAC-SHA256 ----------
330
- // 非 HTTPS 环境(http://VPS-IP)下浏览器的 crypto.subtle 不可用,故内联实现。
331
- // 密钥只参与本地 HMAC 运算,从不随请求发送。
332
- const SHA256_K = new Uint32Array([
333
- 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
334
- 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
335
- 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
336
- 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
337
- 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
338
- 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
339
- 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
340
- 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
341
- ]);
342
- const rotr = (x, n) => (x >>> n) | (x << (32 - n));
343
-
344
- function sha256Bytes(data) {
345
- const H = new Uint32Array([
346
- 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
347
- 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
348
- ]);
349
- const len = data.length;
350
- const paddedLen = (((len + 8) >> 6) + 1) << 6;
351
- const padded = new Uint8Array(paddedLen);
352
- padded.set(data);
353
- padded[len] = 0x80;
354
- const dv = new DataView(padded.buffer);
355
- dv.setUint32(paddedLen - 8, Math.floor(len / 0x20000000)); // 比特长度高 32 位
356
- dv.setUint32(paddedLen - 4, (len << 3) >>> 0); // 比特长度低 32 位
357
- const w = new Uint32Array(64);
358
- for (let off = 0; off < paddedLen; off += 64) {
359
- for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
360
- for (let i = 16; i < 64; i++) {
361
- const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
362
- const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
363
- w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
364
- }
365
- let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
366
- for (let i = 0; i < 64; i++) {
367
- const t1 = (h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + SHA256_K[i] + w[i]) >>> 0;
368
- const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
369
- h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
370
- }
371
- H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
372
- H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
373
- }
374
- const out = new Uint8Array(32);
375
- const odv = new DataView(out.buffer);
376
- for (let i = 0; i < 8; i++) odv.setUint32(i * 4, H[i]);
377
- return out;
378
- }
379
-
380
- const bytesToHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
381
- const utf8 = (s) => new TextEncoder().encode(s);
382
- const sha256Hex = (s) => bytesToHex(sha256Bytes(utf8(s)));
383
-
384
- function hmacSha256Hex(keyStr, msgStr) {
385
- let key = utf8(keyStr);
386
- if (key.length > 64) key = sha256Bytes(key);
387
- const msg = utf8(msgStr);
388
- const inner = new Uint8Array(64 + msg.length);
389
- const outer = new Uint8Array(64 + 32);
390
- for (let i = 0; i < 64; i++) {
391
- const k = i < key.length ? key[i] : 0;
392
- inner[i] = k ^ 0x36;
393
- outer[i] = k ^ 0x5c;
394
- }
395
- inner.set(msg, 64);
396
- outer.set(sha256Bytes(inner), 64);
397
- return bytesToHex(sha256Bytes(outer));
398
- }
399
-
400
- // ---------- 请求签名(与 server/worker 的签名串格式严格一致)----------
401
- function makeNonce() {
402
- const bytes = new Uint8Array(16);
403
- if (window.crypto && crypto.getRandomValues) crypto.getRandomValues(bytes);
404
- else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
405
- return bytesToHex(bytes);
406
- }
407
-
408
- function signHeaders(method, path, bodyStr) {
409
- const ts = Math.floor(Date.now() / 1000);
410
- const nonce = makeNonce();
411
- const sig = hmacSha256Hex(secretInput.value.trim(), [method, path, ts, nonce, sha256Hex(bodyStr)].join('\n'));
412
- return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
413
- }
414
-
415
- // 未配置密钥时禁止发起任何需要签名的请求:空密钥签名必 401,
416
- // 还会白刷服务端验签失败限流计数,统一在本地拦截
417
- function hasSecret() {
418
- return secretInput.value.trim() !== '';
419
- }
420
- function requireSecret() {
421
- if (!hasSecret()) throw new Error('请先配置 API Secret');
422
- }
423
-
424
- async function api(path, body) {
425
- requireSecret();
426
- const method = body === undefined ? 'GET' : 'POST';
427
- const bodyStr = body === undefined ? '' : JSON.stringify(body);
428
- const res = await fetch(path, {
429
- method,
430
- headers: {
431
- 'Content-Type': 'application/json',
432
- ...signHeaders(method, path, bodyStr),
433
- },
434
- body: body === undefined ? undefined : bodyStr,
435
- });
436
- const data = await res.json().catch(() => ({}));
437
- if (res.status === 401) throw new Error('签名验证失败(检查密钥与本机时钟)');
438
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
439
- return data;
440
- }
441
-
442
- // ---------- 本地状态 ----------
443
- const taskCache = new Map(); // id -> 任务摘要
444
- const logCache = new Map(); // id -> 日志全文字符串(展开过的任务才有)
445
- const openTasks = new Set(); // 展开查看日志的任务 id
446
- let wsReady = false;
447
- let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变更
448
-
449
- secretInput.addEventListener('change', () => {
450
- localStorage.setItem('rw_secret', secretInput.value.trim());
451
- wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
452
- if (hasSecret()) {
453
- if (!wsReady) connectWS();
454
- refresh(); // 之前无密钥没拉过数据,配好后补一次
455
- }
456
- });
457
-
458
- // 密钥已配置时收起输入区,只留一行状态条;点状态条可展开修改
459
- function toggleAuth(collapsed) {
460
- $('auth-bar').classList.toggle('hidden', !collapsed);
461
- $('auth-form').classList.toggle('hidden', collapsed);
462
- if (collapsed) setSecretVisible(false); // 收起后恢复掩码,避免下次展开直接露出明文
463
- }
464
- $('auth-bar').addEventListener('click', () => {
465
- toggleAuth(false);
466
- secretInput.focus();
467
- });
468
- // 退出编辑:无论是否改动都可收起;内容被清空则保持展开(没有可用密钥)
469
- $('btn-done').addEventListener('click', () => {
470
- toggleAuth(secretInput.value.trim() !== '');
471
- });
472
- toggleAuth(secretInput.value.trim() !== '');
473
-
474
- // 明文/掩码切换,仅改变本地展示方式,不影响签名
475
- function setSecretVisible(visible) {
476
- secretInput.type = visible ? 'text' : 'password';
477
- const btn = $('btn-eye');
478
- btn.textContent = visible ? '隐藏' : '显示';
479
- btn.classList.toggle('on', visible);
480
- }
481
- $('btn-eye').addEventListener('click', () => setSecretVisible(secretInput.type === 'password'));
482
-
483
- // ---------- WebSocket 实时通道(断线自动回退轮询)----------
484
- let curWs = null; // 当前实时连接,pty 终端输入经它上行
485
-
486
- function sendWs(msg) {
487
- if (curWs && curWs.readyState === WebSocket.OPEN) curWs.send(JSON.stringify(msg));
488
- else toast('实时通道未连接,输入无法送达');
489
- }
490
-
491
- function setConnMode(realtime) {
492
- const el = $('conn-mode');
493
- el.textContent = realtime ? '实时' : '轮询';
494
- el.className = realtime ? 'realtime' : 'polling';
495
- }
496
-
497
- function connectWS() {
498
- if (wsAuthRejected || !hasSecret()) return; // 无密钥时握手必被 4003 拒绝,不发起
499
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
500
- const sock = new WebSocket(`${proto}://${location.host}/ws`);
501
- curWs = sock;
502
-
503
- sock.addEventListener('open', () => {
504
- // 签名鉴权:sig = HMAC(密钥, "WS\nTS\nNONCE"),密钥本身不进网络
505
- const ts = Math.floor(Date.now() / 1000);
506
- const nonce = makeNonce();
507
- const sig = hmacSha256Hex(secretInput.value.trim(), ['WS', ts, nonce].join('\n'));
508
- sock.send(JSON.stringify({ type: 'auth', ts, nonce, sig }));
509
- });
510
- sock.addEventListener('message', (ev) => {
511
- let msg;
512
- try { msg = JSON.parse(ev.data); } catch { return; }
513
- switch (msg.type) {
514
- case 'hello':
515
- wsReady = true;
516
- setConnMode(true);
517
- taskCache.clear();
518
- for (const t of msg.tasks) taskCache.set(t.id, t);
519
- renderTasks();
520
- renderWorkers(msg.workers);
521
- // 重连后给打开着的终端补回看缓冲与会话存活状态
522
- for (const id of termEntries.keys()) {
523
- const ent = termEntries.get(id);
524
- ent.pendingAttach = true;
525
- sock.send(JSON.stringify({ type: 'pty.attach', taskId: id }));
526
- }
527
- break;
528
- case 'task.created':
529
- case 'task.updated':
530
- taskCache.set(msg.task.id, msg.task);
531
- if (msg.result !== undefined) logCache.delete(msg.task.id); // 结果变了,展开时重新拉全量
532
- renderTasks();
533
- break;
534
- case 'task.logs':
535
- appendLogs(msg.id, msg.lines);
536
- break;
537
- case 'workers':
538
- renderWorkers(msg.workers);
539
- break;
540
- // --- pty 交互终端流 ---
541
- case 'pty.data': {
542
- const ent = termEntries.get(msg.id);
543
- if (ent) {
544
- ent.xt.write(msg.data);
545
- ent.live = true;
546
- // 非当前终端有新输出:tab 亮活动圆点,切过去后熄灭
547
- if (msg.id !== activeTermId) { ent.dirty = true; ent.tab.classList.add('dirty'); }
548
- termStatus(ent);
549
- }
550
- break;
551
- }
552
- case 'pty.started': {
553
- const ent = termEntries.get(msg.id);
554
- if (ent && !ent.live) {
555
- ent.live = true;
556
- ent.xt.write('\r\n\x1b[32m[终端会话已就绪]\x1b[0m\r\n');
557
- termStatus(ent);
558
- }
559
- break;
560
- }
561
- case 'pty.exited': {
562
- const ent = termEntries.get(msg.id);
563
- if (ent) {
564
- ent.live = false;
565
- ent.xt.write(`\r\n\x1b[31m[会话结束:${msg.reason || ''}]\x1b[0m\r\n`);
566
- termStatus(ent);
567
- }
568
- break;
569
- }
570
- case 'pty.attach': {
571
- const ent = termEntries.get(msg.id);
572
- if (ent && ent.pendingAttach) {
573
- ent.pendingAttach = false;
574
- if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
575
- ent.live = msg.live;
576
- ent.liveHint = msg.live ? '' : (ATTACH_HINTS[msg.reason] || ATTACH_HINTS.unknown);
577
- if (!msg.live) ent.xt.write(`\r\n\x1b[33m[会话未就绪:${ent.liveHint}]\x1b[0m\r\n`);
578
- termStatus(ent);
579
- }
580
- break;
581
- }
582
- }
583
- });
584
- sock.addEventListener('close', (ev) => {
585
- wsReady = false;
586
- setConnMode(false);
587
- for (const ent of termEntries.values()) termStatus(ent); // 各打开终端的状态条都显示重连中
588
- if (ev.code === 4003) {
589
- // 验签被拒:无意义重试只会刷服务端日志,等用户改密钥
590
- wsAuthRejected = true;
591
- toggleAuth(false); // 展开密钥输入区,方便直接修改
592
- toast('密钥无效或时钟偏差过大,实时连接已停止');
593
- return;
594
- }
595
- setTimeout(connectWS, 3000); // 锁屏/切网导致的断线,3 秒后重连
596
- });
597
- sock.addEventListener('error', () => sock.close());
598
- }
599
-
600
- // ---------- 渲染(增量 diff:节点按 id 复用,只更新变化的部分) ----------
601
- const STATUS_TEXT = { pending: '等待中', running: '执行中', done: '完成', failed: '失败', canceled: '已取消' };
602
- const taskEls = new Map(); // taskId -> { el, badge, meta, logsEl, toggle, cancel, status, metaText }
603
- function fmtTime(ts) {
604
- if (!ts) return '-';
605
- const d = new Date(ts);
606
- return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ` +
607
- `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
608
- }
609
-
610
- let workerList = []; // 心跳下发的 Worker 快照;目标 Worker 与项目目录下拉都基于它
611
-
612
- function relTime(ts) {
613
- const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
614
- if (s < 60) return `${s} 秒前`;
615
- if (s < 3600) return `${Math.round(s / 60)} 分钟前`;
616
- if (s < 86400) return `${Math.round(s / 3600)} 小时前`;
617
- return `${Math.round(s / 86400)} 天前`;
618
- }
619
-
620
- function renderWorkers(workers) {
621
- workerList = workers;
622
- const ws = $('worker-status');
623
- const onlineCount = workers.filter((w) => w.online).length;
624
- const text = workers.length ? `${onlineCount}/${workers.length} 台 Worker 在线` : '无已注册 Worker';
625
- if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
626
- ws.className = onlineCount ? 'online' : 'offline';
627
- // 当前所在二级视图的 Worker 已注销(如配置移除):退回一级列表
628
- if (currentWorkerId && !workers.some((w) => w.workerId === currentWorkerId)) navBack();
629
- renderWorkerCards();
630
- // 首份快照到达后按地址栏 hash 恢复视图(刷新进入二级页/展开的长任务)
631
- if (!routeInited) { routeInited = true; applyRoute(); }
632
- }
633
-
634
- // 一级视图 Worker 卡片:sig 去重,心跳快照没变就不重建(避免打断点击)
635
- let cardsSig = '';
636
- function renderWorkerCards() {
637
- $('workers-empty').classList.toggle('hidden', workerList.length > 0);
638
- const sig = JSON.stringify(workerList.map((w) => [
639
- w.workerId, w.hostname, w.online, w.lastSeenAt, w.projects || [],
640
- ]));
641
- if (sig === cardsSig) return;
642
- cardsSig = sig;
643
- const box = $('worker-cards');
644
- box.textContent = '';
645
- for (const w of workerList) {
646
- const card = document.createElement('div');
647
- card.className = 'worker-card';
648
- const name = w.hostname && w.hostname !== w.workerId ? `${w.workerId}(${w.hostname})` : w.workerId;
649
- const status = w.online ? '在线' : `离线(最近心跳 ${relTime(w.lastSeenAt)})`;
650
- card.innerHTML = `
651
- <div class="wc-row1">
652
- <span class="wc-name"></span>
653
- <span class="${w.online ? 'w-online' : 'w-offline'}"></span>
654
- </div>
655
- <div class="wc-meta"></div>`;
656
- card.querySelector('.wc-name').textContent = name; // textContent 天然防注入
657
- card.querySelector('.wc-row1 span:last-child').textContent = status;
658
- const projects = [...new Set(w.projects || [])];
659
- card.querySelector('.wc-meta').textContent = projects.length ? `项目目录:${projects.join(' / ')}` : '未上报项目目录';
660
- card.addEventListener('click', () => navToWorker(w.workerId));
661
- box.appendChild(card);
662
- }
663
- }
664
-
665
- // ---------- 两级视图导航:一级 Worker 列表 / 二级所选 Worker 详情 ----------
666
- let currentWorkerId = null;
667
- let routeInited = false; // 首份 Worker 快照到达前 hash 恢复无法执行(卡片还没渲染)
668
- // pushRoute=false 时只切视图不动地址栏(来自 hashchange 的同步,避免二次入栈)
669
- function navToWorker(workerId, pushRoute = true) {
670
- const w = workerList.find((x) => x.workerId === workerId);
671
- if (!w) return;
672
- currentWorkerId = workerId;
673
- $('worker-title').textContent = w.hostname && w.hostname !== workerId ? `${workerId}(${w.hostname})` : workerId;
674
- $('view-workers').classList.add('hidden');
675
- $('view-worker').classList.remove('hidden');
676
- $('task-list').classList.remove('hidden'); // 二级视图才展示右侧任务列表
677
- $('layout').classList.remove('one-col'); // 进入二级视图恢复双栏
678
- if (pushRoute) pushRoute_();
679
- updateSubmitState();
680
- renderProjectOptions();
681
- renderTasks(); // 切换到该 Worker 的任务过滤
682
- }
683
- function navBack(pushRoute = true) {
684
- currentWorkerId = null;
685
- $('view-worker').classList.add('hidden');
686
- $('view-workers').classList.remove('hidden');
687
- $('task-list').classList.add('hidden');
688
- $('layout').classList.add('one-col'); // 回到一级视图:全宽铺开 Worker 卡片
689
- if (pushRoute) pushRoute_();
690
- renderTasks(); // 离开二级视图时清掉过滤后的任务节点
691
- }
692
- $('btn-back').addEventListener('click', () => navBack());
693
-
694
- // ---------- hash 路由:#/w/<workerId>(附 /t/<展开的任务 id>)----------
695
- // 进入二级视图同步地址栏:刷新留在当前页面;长任务的展开状态也随 URL 保留
696
- function route() {
697
- const parts = location.hash.slice(1).split('/').filter(Boolean);
698
- return {
699
- worker: parts[0] === 'w' ? decodeURIComponent(parts[1] || '') : null,
700
- tasks: parts[2] === 't' ? (parts[3] || '').split('.').filter(Boolean) : [],
701
- };
702
- }
703
- function buildHash() {
704
- if (!currentWorkerId) return '#/';
705
- // 只把当前视图可见(属于该 Worker)的展开任务写进 URL
706
- const open = [...openTasks].filter((id) => {
707
- const t = taskCache.get(id);
708
- return t && (t.workerId === currentWorkerId || t.targetWorker === currentWorkerId);
709
- });
710
- const base = `#/w/${encodeURIComponent(currentWorkerId)}`;
711
- return open.length ? `${base}/t/${open.join('.')}` : base;
712
- }
713
- function pushRoute_() {
714
- const h = buildHash();
715
- if (location.hash === h) return;
716
- history.pushState(null, '', h);
717
- }
718
- // 展开/收起只替换当前历史条目:不给返回键堆一堆中间状态
719
- function syncTaskRoute() {
720
- history.replaceState(null, '', buildHash());
721
- }
722
- function applyRoute() {
723
- const r = route();
724
- if (r.worker && r.worker !== currentWorkerId) navToWorker(r.worker, false);
725
- else if (!r.worker && currentWorkerId) navBack(false);
726
- for (const id of r.tasks) { // 刷新后恢复长任务的展开(日志区)
727
- if (openTasks.has(id) || !taskEls.has(id)) continue;
728
- openTasks.add(id);
729
- const entry = taskEls.get(id);
730
- entry.el.classList.add('open');
731
- entry.toggle.textContent = '收起';
732
- loadLogs(id);
733
- }
734
- }
735
- window.addEventListener('hashchange', applyRoute);
736
-
737
- function updateSubmitState() {
738
- $('btn-submit').disabled = !currentWorkerId;
739
- }
740
-
741
- // 项目目录下拉数据源:只有所选 Worker 的上报(显式指派,不做跨机并集)
742
- function selectedWorker() {
743
- return workerList.find((w) => w.workerId === currentWorkerId) || null;
744
- }
745
-
746
- // 项目目录下拉:列 Worker 心跳上报的 workspace 已有目录;末尾常驻
747
- // 「+ 新建目录…」,输入的新目录在任务执行时由 Worker 自动创建
748
- // (pty 执行前会 mkdir)
749
- const NEW_PROJECT = '__new__';
750
- const projectSels = ['f-pty-project'];
751
-
752
- function renderProjectOptions() {
753
- const w = selectedWorker();
754
- const projects = [...new Set(w ? (w.projects || []) : [])].sort();
755
- if (!projects.length) return; // Worker 尚未上报,保留默认 default 选项
756
- for (const id of projectSels) {
757
- const sel = $(id);
758
- const prev = sel.value === NEW_PROJECT ? 'default' : sel.value;
759
- sel.textContent = '';
760
- for (const p of projects) {
761
- const opt = document.createElement('option');
762
- opt.value = p;
763
- opt.textContent = p;
764
- sel.appendChild(opt);
765
- }
766
- const newOpt = document.createElement('option');
767
- newOpt.value = NEW_PROJECT;
768
- newOpt.textContent = '+ 新建目录…';
769
- sel.appendChild(newOpt);
770
- if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
771
- }
772
- }
773
-
774
- // 选「+ 新建目录…」:弹输入框收目录名,合法则加入两个下拉并选中;
775
- // 取消或非法则退回原选项,NEW_PROJECT 值不会进入提交 payload
776
- function handleNewProject(ev) {
777
- const sel = ev.target;
778
- if (sel.value !== NEW_PROJECT) return;
779
- const fallback = [...sel.options].find((o) => o.value !== NEW_PROJECT)?.value || '';
780
- const name = (window.prompt('新项目目录名(任务执行时在 Worker workspace 下自动创建)') || '').trim();
781
- if (!name || !/^[\w.-]+$/.test(name) || name.includes('..')) {
782
- if (name) toast('目录名只能含字母/数字/._-,且不含 ..');
783
- sel.value = fallback;
784
- return;
785
- }
786
- for (const id of projectSels) {
787
- const s = $(id);
788
- if (![...s.options].some((o) => o.value === name)) {
789
- const opt = document.createElement('option');
790
- opt.value = name;
791
- opt.textContent = name;
792
- s.insertBefore(opt, s.querySelector(`option[value="${NEW_PROJECT}"]`));
793
- }
794
- s.value = name;
795
- }
796
- }
797
- for (const id of projectSels) $(id).addEventListener('change', handleNewProject);
798
-
799
- function createTaskEl(t) {
800
- const div = document.createElement('div');
801
- div.className = 'task new' + (openTasks.has(t.id) ? ' open' : '');
802
- div.innerHTML = `
803
- <div class="row1">
804
- <span class="title"></span>
805
- <span class="badge"></span>
806
- </div>
807
- <div class="meta"></div>
808
- <div class="logs"></div>
809
- <div class="actions">
810
- <button data-act="toggle">展开</button>
811
- <button data-act="term" class="hidden term-btn">进入终端</button>
812
- <button data-act="cancel" class="hidden">取消</button>
813
- </div>`;
814
- div.querySelector('.title').textContent = t.title || t.id; // textContent 天然防注入
815
- const entry = {
816
- el: div,
817
- badge: div.querySelector('.badge'),
818
- meta: div.querySelector('.meta'),
819
- logsEl: div.querySelector('.logs'),
820
- toggle: div.querySelector('[data-act=toggle]'),
821
- term: div.querySelector('[data-act=term]'),
822
- cancel: div.querySelector('[data-act=cancel]'),
823
- status: null,
824
- metaText: null,
825
- };
826
- entry.term.addEventListener('click', () => openTerm(t));
827
- entry.toggle.addEventListener('click', async () => {
828
- if (openTasks.has(t.id)) {
829
- openTasks.delete(t.id);
830
- div.classList.remove('open');
831
- } else {
832
- openTasks.add(t.id);
833
- div.classList.add('open');
834
- await loadLogs(t.id);
835
- }
836
- entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
837
- syncTaskRoute(); // 展开状态入 URL:长任务执行中刷新仍留在任务页面
838
- });
839
- entry.cancel.addEventListener('click', async () => {
840
- try {
841
- entry.cancel.disabled = true;
842
- await api(`/api/tasks/${t.id}/cancel`, {});
843
- if (!wsReady) await refresh();
844
- } catch (e) { toast(e.message); } finally { entry.cancel.disabled = false; }
845
- });
846
- setTimeout(() => div.classList.remove('new'), 300);
847
- return entry;
848
- }
849
-
850
- function patchTask(t, entry) {
851
- if (entry.status !== t.status) {
852
- entry.status = t.status;
853
- entry.badge.textContent = STATUS_TEXT[t.status] || t.status;
854
- entry.badge.className = `badge b-${t.status}`;
855
- entry.cancel.classList.toggle('hidden', t.status !== 'pending');
856
- entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
857
- }
858
- entry.term.classList.toggle('hidden', t.type !== 'pty'); // pty 任务入口
859
- // Worker 标注:已领取显示执行者,pending 显示指派目标
860
- const workerPart = t.workerId ? `@${t.workerId}` : (t.targetWorker ? `→ ${t.targetWorker}` : '');
861
- const meta = `${t.type}${workerPart ? ' · ' + workerPart : ''} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
862
- if (entry.metaText !== meta) { entry.metaText = meta; entry.meta.textContent = meta; }
863
- entry.el.classList.toggle('open', openTasks.has(t.id));
864
- // 展开且有缓存:内容没变就不动 DOM,保留滚动位置
865
- if (openTasks.has(t.id) && logCache.has(t.id)) writeLogs(t.id, logCache.get(t.id));
866
- }
867
-
868
- function renderTasks() {
869
- const list = $('task-list');
870
- const tasks = [...taskCache.values()]
871
- .filter((t) => !currentWorkerId || t.workerId === currentWorkerId || t.targetWorker === currentWorkerId)
872
- .sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
873
- // 清理已不存在的任务节点
874
- const ids = new Set(tasks.map((t) => t.id));
875
- for (const [id, entry] of taskEls) {
876
- if (!ids.has(id)) { entry.el.remove(); taskEls.delete(id); disposeTerm(id); }
877
- }
878
- // 增量更新/插入,并保证顺序(新任务插到正确位置,而不是闪到末尾)
879
- let prev = null;
880
- for (const t of tasks) {
881
- let entry = taskEls.get(t.id);
882
- if (!entry) { entry = createTaskEl(t); taskEls.set(t.id, entry); }
883
- patchTask(t, entry);
884
- const expected = prev ? prev.nextSibling : list.firstChild;
885
- if (entry.el !== expected) list.insertBefore(entry.el, expected);
886
- prev = entry.el;
887
- }
888
- }
889
-
890
- // 写日志到指定任务的日志区;仅在内容变化时写入,贴底时才自动滚动
891
- function writeLogs(id, text) {
892
- const entry = taskEls.get(id);
893
- if (!entry || entry.logsEl.textContent === text) return;
894
- const el = entry.logsEl;
895
- const stickBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 20;
896
- el.textContent = text;
897
- if (stickBottom) el.scrollTop = el.scrollHeight;
898
- }
899
-
900
- // 展开任务时经 REST 拉全量日志,之后增量靠 WS 的 task.logs 事件追加
901
- async function loadLogs(id) {
902
- try {
903
- const task = await api(`/api/tasks/${id}`);
904
- let text = task.logs.join('\n');
905
- if (task.result) text += `\n\n--- 结果 ---\n${JSON.stringify(task.result, null, 2)}`;
906
- logCache.set(id, text);
907
- writeLogs(id, text);
908
- } catch { /* 任务可能已被清理 */ }
909
- }
910
-
911
- function appendLogs(id, lines) {
912
- if (!openTasks.has(id)) return;
913
- const task = taskCache.get(id);
914
- if (task && ['done', 'failed'].includes(task.status)) {
915
- loadLogs(id); // 终态时重拉一次,确保结果块出现
916
- return;
917
- }
918
- if (!logCache.has(id)) { loadLogs(id); return; } // 尚未拉过全量,直接重拉
919
- const text = logCache.get(id) + '\n' + lines.join('\n');
920
- logCache.set(id, text);
921
- writeLogs(id, text);
922
- }
923
-
924
- // ---------- 交互终端(pty 任务:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
925
- // PC 有物理键盘:xterm 聚焦后直接承接按键,无需手机端的隐藏输入代理与按键工具条
926
- // 布局借鉴 mwt LayoutManager:单屏(叠层只显示一个)/ 并排(grid 平铺同盯多个 pty),
927
- // tab 活动圆点标记非当前终端的新输出;偏好存 localStorage
928
- const termEntries = new Map(); // taskId -> { xt, fit, pane, tab, statusEl, live, pendingAttach, liveHint, dirty, attached }
929
- let activeTermId = null;
930
- let termLayout = localStorage.getItem('rw_termLayout') === 'grid' ? 'grid' : 'single';
931
- // 会话未就绪时的原因文案(与服务端 pty.attach 应答的 reason 对应)
932
- const ATTACH_HINTS = {
933
- not_claimed: '任务尚未被领取,等待 Worker 上线领取…',
934
- worker_offline: 'Worker 离线(通道断开),重连后可自动恢复…',
935
- starting: '任务已被领取,会话启动中,请稍候…',
936
- ended: '会话已结束或已被回收',
937
- unknown: '任务尚未被领取或 Worker 离线,请稍候…',
938
- };
939
-
940
- // 终端 tab/pane 的显示名要有区分度:自定义标题照用;默认标题(空或「交互终端…」)
941
- // 退化为「项目目录 · #任务短号」,同项目开多个终端也不会重名
942
- function termLabel(t) {
943
- if (t.title && !/^交互终端/.test(t.title)) return t.title;
944
- const short = String(t.id).slice(-4);
945
- const proj = t.project || (t.payload && t.payload.project);
946
- return proj ? `${proj} · #${short}` : `终端 · #${short}`;
947
- }
948
-
949
- // 状态条挂在各终端自己的 pane 头部(并排时各有各的状态)
950
- function termStatus(ent) {
951
- if (!ent || !ent.statusEl) return;
952
- if (!wsReady) ent.statusEl.textContent = '实时通道断开,重连中…';
953
- else if (ent.live) ent.statusEl.textContent = '已连接';
954
- else ent.statusEl.textContent = `会话未就绪(${ent.liveHint || ATTACH_HINTS.unknown})`;
955
- }
956
-
957
- function ensureTerm(t) {
958
- let ent = termEntries.get(t.id);
959
- if (ent) return ent;
960
- if (!window.Terminal || !window.FitAddon) {
961
- toast('xterm.js 加载失败(vendor/ 资源缺失,请刷新重试)');
962
- return null;
963
- }
964
- const xt = new Terminal({
965
- fontSize: 14, scrollback: 5000, cursorBlink: true,
966
- theme: { ...TERM_THEME[currentTheme()] },
967
- });
968
- const fit = new FitAddon.FitAddon();
969
- xt.loadAddon(fit);
970
- // 每个终端独占一个 pane(标题栏 + 渲染区):挂在同一容器里会让多实例叠在一起串台
971
- const pane = document.createElement('div');
972
- pane.className = 'term-pane';
973
- pane.innerHTML = `
974
- <div class="tp-head">
975
- <span class="tp-title"></span>
976
- <span class="tp-status"></span>
977
- <button class="tp-close" type="button" title="关闭该终端">✕</button>
978
- </div>
979
- <div class="tp-term"></div>`;
980
- pane.querySelector('.tp-title').textContent = termLabel(t); // textContent 天然防注入
981
- pane.querySelector('.tp-close').addEventListener('click', (ev) => {
982
- ev.stopPropagation();
983
- disposeTerm(t.id); // 只释放本地实例,任务卡上「进入终端」可随时重进(pty.attach 回补缓冲)
984
- });
985
- pane.addEventListener('mousedown', () => { // 并排时点非当前 pane 即切焦点过去
986
- if (activeTermId !== t.id) activateTerm(t.id);
987
- });
988
- $('term-body').appendChild(pane);
989
- xt.open(pane.querySelector('.tp-term'));
990
- // 顶部同步一个 tab:点击切换,活动圆点标记后台新输出
991
- const tab = document.createElement('div');
992
- tab.className = 'term-tab';
993
- tab.innerHTML = '<span class="tt-dot"></span><span class="tt-title"></span>';
994
- tab.querySelector('.tt-title').textContent = termLabel(t);
995
- tab.addEventListener('click', () => activateTerm(t.id));
996
- $('term-tabs').appendChild(tab);
997
- ent = {
998
- xt, fit, pane, tab, statusEl: pane.querySelector('.tp-status'),
999
- live: false, pendingAttach: false, liveHint: '', dirty: false, attached: false,
1000
- };
1001
- termEntries.set(t.id, ent);
1002
- xt.onData((data) => sendWs({ type: 'session.input', taskId: t.id, data }));
1003
- return ent;
1004
- }
1005
-
1006
- // 切换当前终端:点亮对应 tab 与 pane,熄灭活动圆点,聚焦承接输入
1007
- function activateTerm(id) {
1008
- const ent = termEntries.get(id);
1009
- if (!ent) return;
1010
- activeTermId = id;
1011
- ent.dirty = false;
1012
- for (const [tid, e] of termEntries) {
1013
- const on = tid === id;
1014
- e.tab.classList.toggle('active', on);
1015
- e.tab.classList.toggle('dirty', e.dirty); // active 的 dirty 已清,顺带同步
1016
- // 单屏模式 pane 叠层只显示当前;并排模式 CSS 让全部可见,active 只高亮边框
1017
- e.pane.classList.toggle('active', on);
1018
- }
1019
- fitTerm(id, ent); // pane 可见后再 fit(隐藏状态下拿不到尺寸)
1020
- termStatus(ent);
1021
- ent.xt.focus();
1022
- }
1023
-
1024
- // 按容器尺寸重算 cols/rows,并通知 Worker 端 PTY 同步窗口大小
1025
- function fitTerm(id, ent) {
1026
- try { ent.fit.fit(); } catch { return; }
1027
- if (wsReady) sendWs({ type: 'session.resize', taskId: id, cols: ent.xt.cols, rows: ent.xt.rows });
1028
- }
1029
-
1030
- // fit 所有当前可见终端(并排:全部;单屏:仅 active)
1031
- function fitVisibleTerms() {
1032
- if ($('term-overlay').classList.contains('hidden')) return;
1033
- for (const [id, ent] of termEntries) {
1034
- if (termLayout === 'grid' || id === activeTermId) fitTerm(id, ent);
1035
- }
1036
- }
1037
-
1038
- // 应用布局模式:并排按终端数重算 grid 列数(至多 3 列),并重新 fit 全部可见终端
1039
- function applyTermLayout() {
1040
- const body = $('term-body');
1041
- body.classList.toggle('grid', termLayout === 'grid');
1042
- document.querySelectorAll('#term-layout-btns button').forEach((b) => {
1043
- b.classList.toggle('active', b.dataset.layout === termLayout);
1044
- });
1045
- if (termLayout === 'grid') {
1046
- const n = termEntries.size;
1047
- body.style.setProperty('--cols', String(n <= 1 ? 1 : (n <= 4 ? 2 : 3)));
1048
- } else {
1049
- body.style.removeProperty('--cols');
1050
- }
1051
- setTimeout(fitVisibleTerms, 60); // 等布局切换稳定再重 fit
1052
- }
1053
-
1054
- function openTerm(t) {
1055
- const ent = ensureTerm(t);
1056
- if (!ent) return;
1057
- $('term-overlay').classList.remove('hidden');
1058
- applyTermLayout(); // 距上次收起终端数可能有变,确保布局类与列数正确
1059
- activateTerm(t.id);
1060
- // 重复打开不重复 attach:回看缓冲只回补一次,避免终端内容翻倍
1061
- if (!ent.attached) {
1062
- ent.attached = true;
1063
- ent.pendingAttach = true;
1064
- sendWs({ type: 'pty.attach', taskId: t.id }); // 取回看缓冲 + 会话存活状态
1065
- }
1066
- termStatus(ent);
1067
- }
1068
-
1069
- function closeTermOverlay() {
1070
- // 覆盖层自身处于全屏时先退出全屏,否则会留一个隐藏的黑屏全屏层
1071
- if (fsElement() === $('term-overlay')) exitFs();
1072
- $('term-overlay').classList.add('hidden');
1073
- activeTermId = null;
1074
- if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
1075
- }
1076
-
1077
- // 终端实例随会话常驻:收起不销毁,后台继续接收输出;任务卡被清理时才释放
1078
- function disposeTerm(id) {
1079
- const ent = termEntries.get(id);
1080
- if (!ent) return;
1081
- termEntries.delete(id);
1082
- ent.tab.remove();
1083
- try { ent.xt.dispose(); } catch { /* 已释放 */ }
1084
- ent.pane.remove();
1085
- if (activeTermId === id) {
1086
- const next = termEntries.keys().next().value;
1087
- if (next && !$('term-overlay').classList.contains('hidden')) activateTerm(next);
1088
- else closeTermOverlay();
1089
- }
1090
- applyTermLayout(); // 终端数有变:重算并排列数(覆盖层收起时 fit 有兜底不执行)
1091
- }
1092
-
1093
- $('term-close').addEventListener('click', closeTermOverlay);
1094
- $('term-body').addEventListener('click', () => {
1095
- const ent = termEntries.get(activeTermId);
1096
- if (ent) ent.xt.focus();
1097
- });
1098
- // 布局切换:单屏 / 并排,偏好持久化
1099
- document.querySelectorAll('#term-layout-btns button').forEach((btn) => {
1100
- btn.addEventListener('click', () => {
1101
- termLayout = btn.dataset.layout;
1102
- localStorage.setItem('rw_termLayout', termLayout);
1103
- applyTermLayout();
1104
- });
1105
- });
1106
- // ---------- 全屏(Fullscreen API:页头整页全屏 / 终端覆盖层单独全屏)----------
1107
- function fsElement() { return document.fullscreenElement || document.webkitFullscreenElement; }
1108
- function exitFs() {
1109
- if (!fsElement()) return;
1110
- (document.exitFullscreen || document.webkitExitFullscreen).call(document);
1111
- }
1112
- function toggleFs(el) {
1113
- if (fsElement()) { exitFs(); return; }
1114
- try {
1115
- const p = el.requestFullscreen ? el.requestFullscreen()
1116
- : (el.webkitRequestFullscreen ? el.webkitRequestFullscreen() : null);
1117
- if (p && p.catch) p.catch(() => toast('进入全屏失败(浏览器限制,请重试)'));
1118
- else if (!p) toast('当前浏览器不支持全屏');
1119
- } catch { toast('当前浏览器不支持全屏'); }
1120
- }
1121
- function syncFsButtons() {
1122
- const text = fsElement() ? '退出全屏' : '全屏';
1123
- $('btn-fullscreen').textContent = text;
1124
- $('term-fullscreen').textContent = text;
1125
- }
1126
- document.addEventListener('fullscreenchange', () => {
1127
- syncFsButtons();
1128
- setTimeout(fitVisibleTerms, 120); // 全屏进出后视口尺寸变了,终端重 fit
1129
- });
1130
- document.addEventListener('webkitfullscreenchange', syncFsButtons);
1131
- $('btn-fullscreen').addEventListener('click', () => toggleFs(document.documentElement));
1132
- $('term-fullscreen').addEventListener('click', () => toggleFs($('term-overlay')));
1133
- // Esc 收起终端覆盖层(终端聚焦时 Esc 会先被 xterm 吃掉,此处兜底非聚焦场景)
1134
- document.addEventListener('keydown', (ev) => {
1135
- if (ev.key === 'Escape' && activeTermId && !$('term-overlay').classList.contains('hidden')) {
1136
- if (fsElement()) return; // 全屏态 Esc 由浏览器用于退出全屏,不再联动收起
1137
- const ent = termEntries.get(activeTermId);
1138
- // 终端聚焦时 Esc 是有效按键(如退出 less),双击 Esc 才收起,避免误触
1139
- if (ent && document.activeElement && document.activeElement.closest
1140
- && document.activeElement.closest('#term-body')) return;
1141
- closeTermOverlay();
1142
- }
1143
- });
1144
- // 窗口尺寸变化后重新 fit 所有可见终端
1145
- window.addEventListener('resize', () => { if (termEntries.size) setTimeout(fitVisibleTerms, 120); });
1146
-
1147
- // ---------- 轮询回退(仅 WS 不可用时生效)----------
1148
- async function refresh() {
1149
- try {
1150
- const [{ tasks }, { workers }] = await Promise.all([
1151
- api('/api/tasks?limit=50'),
1152
- api('/api/worker/status'),
1153
- ]);
1154
- taskCache.clear();
1155
- for (const t of tasks) taskCache.set(t.id, t);
1156
- renderTasks();
1157
- renderWorkers(workers);
1158
- } catch (err) {
1159
- toast(err.message);
1160
- }
1161
- }
1162
- setInterval(() => { if (!wsReady && !wsAuthRejected && hasSecret()) refresh(); }, 4000);
1163
-
1164
- // ---------- 提交任务(REST,WS 负责推送变更)----------
1165
- $('btn-submit').addEventListener('click', async () => {
1166
- // 显式指派:二级视图下任务固定提交给当前 Worker
1167
- const targetWorker = currentWorkerId;
1168
- if (!targetWorker) return toast('请先在 Worker 列表中选择 Worker');
1169
- const title = $('f-title').value.trim();
1170
- const payload = { project: $('f-pty-project').value.trim() || 'default' };
1171
- if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
1172
- try {
1173
- $('btn-submit').disabled = true;
1174
- await api('/api/tasks', { type: 'pty', title: title || `交互终端(${payload.project})`, payload, targetWorker });
1175
- toast('已提交,任务运行后点「进入终端」');
1176
- $('f-title').value = '';
1177
- if (!wsReady) await refresh();
1178
- } catch (err) {
1179
- toast(err.message);
1180
- } finally {
1181
- updateSubmitState();
1182
- }
1183
- });
1184
-
1185
- applyTheme(currentTheme()); // 主题初始化:同步按钮文案与 theme-color(head 已设好 data-theme)
1186
- connectWS();
1187
- // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据(无密钥时不发请求)
1188
- if (hasSecret()) refresh();
1189
- else setConnMode(false); // 无密钥不发起任何连接,状态条直接落到轮询态
1190
- </script>
1191
- </body>
1192
- </html>