@jacksontian/kite-server 0.3.0 → 0.4.1
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 +30 -0
- package/lib/util.js +37 -0
- package/lib/websocket.js +261 -0
- package/package.json +22 -6
- package/web/index.html +312 -222
- package/web/pc.html +533 -219
- package/config.js +0 -540
- package/server.js +0 -1103
package/web/pc.html
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
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>
|
|
7
12
|
<title>Kite 控制台</title>
|
|
8
13
|
<!-- xterm 随包自托管(vendor/ 为锁版本的官方产物):不依赖 CDN,
|
|
9
14
|
杜绝 CDN 投毒/HTTP 下被篡改注入窃取密钥的供应链风险 -->
|
|
@@ -13,13 +18,21 @@
|
|
|
13
18
|
--bg: #111827; --panel: #1f2937; --line: #374151;
|
|
14
19
|
--text: #e5e7eb; --dim: #9ca3af; --accent: #3b82f6;
|
|
15
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;
|
|
16
29
|
}
|
|
17
30
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
18
31
|
body {
|
|
19
32
|
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
|
|
20
33
|
background: var(--bg); color: var(--text);
|
|
21
|
-
padding: 20px
|
|
22
|
-
max-width:
|
|
34
|
+
padding: 20px 32px 40px;
|
|
35
|
+
max-width: 1600px; margin: 0 auto;
|
|
23
36
|
}
|
|
24
37
|
header { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px 16px; gap: 8px; }
|
|
25
38
|
h1 { font-size: 20px; font-weight: 600; }
|
|
@@ -30,6 +43,11 @@
|
|
|
30
43
|
#worker-status { font-size: 13px; padding: 4px 12px; border-radius: 999px; background: var(--panel); color: var(--dim); white-space: nowrap; }
|
|
31
44
|
#worker-status.online { color: var(--ok); }
|
|
32
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); }
|
|
33
51
|
|
|
34
52
|
/* 明文 HTTP 部署的风险提示(仅非 localhost 的 http: 环境显示) */
|
|
35
53
|
#http-warn {
|
|
@@ -37,10 +55,14 @@
|
|
|
37
55
|
border-radius: 10px; padding: 10px 14px; font-size: 13px; line-height: 1.6; margin-bottom: 16px;
|
|
38
56
|
}
|
|
39
57
|
|
|
40
|
-
/*
|
|
41
|
-
|
|
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); }
|
|
42
62
|
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
|
43
63
|
.col-left { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 16px; }
|
|
64
|
+
/* 全宽单栏时密钥面板拉通一行太长,限个舒适宽度 */
|
|
65
|
+
.layout.one-col #auth-panel { max-width: 640px; }
|
|
44
66
|
|
|
45
67
|
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }
|
|
46
68
|
label { display: block; font-size: 12px; color: var(--dim); margin: 12px 0 5px; }
|
|
@@ -52,13 +74,6 @@
|
|
|
52
74
|
textarea { min-height: 84px; resize: vertical; }
|
|
53
75
|
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
|
|
54
76
|
|
|
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
77
|
.submit {
|
|
63
78
|
width: 100%; margin-top: 16px; padding: 11px;
|
|
64
79
|
background: var(--accent); color: #fff; border: none; border-radius: 10px;
|
|
@@ -78,21 +93,12 @@
|
|
|
78
93
|
.b-canceled { background: rgba(156,163,175,.15); color: var(--dim); }
|
|
79
94
|
|
|
80
95
|
.logs {
|
|
81
|
-
margin-top: 10px; background:
|
|
96
|
+
margin-top: 10px; background: var(--term-bg); border-radius: 8px; padding: 10px;
|
|
82
97
|
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.6;
|
|
83
|
-
max-height:
|
|
84
|
-
color:
|
|
98
|
+
max-height: min(60vh, 640px); overflow-y: auto; white-space: pre-wrap; word-break: break-all;
|
|
99
|
+
color: var(--log-text); display: none;
|
|
85
100
|
}
|
|
86
101
|
.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
102
|
.task .actions { margin-top: 10px; display: flex; gap: 8px; }
|
|
97
103
|
.task .actions button {
|
|
98
104
|
font-size: 13px; padding: 5px 16px; border-radius: 6px;
|
|
@@ -103,15 +109,51 @@
|
|
|
103
109
|
|
|
104
110
|
/* 交互终端全屏覆盖层(pty 任务);PC 有物理键盘,输入直接由 xterm 承接 */
|
|
105
111
|
#term-overlay {
|
|
106
|
-
position: fixed; inset: 0; z-index: 50; background:
|
|
112
|
+
position: fixed; inset: 0; z-index: 50; background: var(--term-bg);
|
|
107
113
|
display: flex; flex-direction: column;
|
|
108
114
|
}
|
|
109
115
|
#term-overlay.hidden { display: none; }
|
|
110
|
-
#term-head { display: flex; align-items: center;
|
|
111
|
-
|
|
112
|
-
#term-
|
|
113
|
-
|
|
114
|
-
|
|
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; }
|
|
115
157
|
#auth-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
|
|
116
158
|
#auth-bar .title { font-size: 13px; color: var(--ok); }
|
|
117
159
|
#auth-bar .edit { font-size: 12px; color: var(--dim); }
|
|
@@ -130,6 +172,27 @@
|
|
|
130
172
|
background: #000c; color: #fff; padding: 9px 18px; border-radius: 8px;
|
|
131
173
|
font-size: 13px; opacity: 0; transition: opacity .2s; pointer-events: none;
|
|
132
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
|
+
|
|
133
196
|
.hidden { display: none !important; }
|
|
134
197
|
</style>
|
|
135
198
|
</head>
|
|
@@ -139,6 +202,8 @@
|
|
|
139
202
|
<div class="header-right">
|
|
140
203
|
<span id="conn-mode">连接中</span>
|
|
141
204
|
<span id="worker-status">检测中…</span>
|
|
205
|
+
<button id="btn-theme" type="button" title="切换 浅色/深色 主题">浅色</button>
|
|
206
|
+
<button id="btn-fullscreen" type="button">全屏</button>
|
|
142
207
|
</div>
|
|
143
208
|
</header>
|
|
144
209
|
|
|
@@ -148,7 +213,7 @@
|
|
|
148
213
|
请勿在不信任的网络(如公共 WiFi)使用,并尽快为服务端配置 HTTPS(如 Caddy 自动证书)。
|
|
149
214
|
</div>
|
|
150
215
|
|
|
151
|
-
<div class="layout">
|
|
216
|
+
<div class="layout one-col" id="layout">
|
|
152
217
|
<div class="col-left">
|
|
153
218
|
|
|
154
219
|
<!-- 鉴权设置:密钥已存 localStorage 时折叠成一行,点「修改」才展开 -->
|
|
@@ -167,62 +232,49 @@
|
|
|
167
232
|
</div>
|
|
168
233
|
</div>
|
|
169
234
|
|
|
170
|
-
<!--
|
|
171
|
-
<div
|
|
172
|
-
<div
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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>
|
|
178
246
|
</div>
|
|
179
247
|
|
|
248
|
+
<!-- 新建任务(仅 pty 交互终端) -->
|
|
249
|
+
<div class="panel">
|
|
180
250
|
<label>标题</label>
|
|
181
251
|
<input id="f-title" placeholder="一句话描述这个任务">
|
|
182
252
|
|
|
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>
|
|
253
|
+
<label>项目目录(终端的起始目录,Worker workspace 下已有目录)</label>
|
|
254
|
+
<select id="f-pty-project"><option value="default">default</option></select>
|
|
255
|
+
<label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
|
|
210
256
|
|
|
211
257
|
<button class="submit" id="btn-submit">提交任务</button>
|
|
212
|
-
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div><!-- /view-worker -->
|
|
213
260
|
|
|
214
261
|
</div><!-- /.col-left -->
|
|
215
262
|
|
|
216
|
-
<!--
|
|
217
|
-
<div id="task-list"></div>
|
|
263
|
+
<!-- 任务列表(仅所选 Worker,进入二级视图后显示在右侧) -->
|
|
264
|
+
<div id="task-list" class="hidden"></div>
|
|
218
265
|
|
|
219
266
|
</div><!-- /.layout -->
|
|
220
267
|
|
|
221
|
-
<!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY
|
|
268
|
+
<!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY。
|
|
269
|
+
tab 条 + 单屏/并排布局切换(并排时多个 pty 同屏平铺),活动圆点标记非当前终端的新输出 -->
|
|
222
270
|
<div id="term-overlay" class="hidden">
|
|
223
271
|
<div id="term-head">
|
|
224
|
-
<
|
|
225
|
-
<
|
|
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>
|
|
226
278
|
<button id="term-close" type="button">收起</button>
|
|
227
279
|
</div>
|
|
228
280
|
<div id="term-body"></div>
|
|
@@ -248,6 +300,32 @@ function toast(msg) {
|
|
|
248
300
|
setTimeout(() => { el.style.opacity = '0'; }, 2200);
|
|
249
301
|
}
|
|
250
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
|
+
|
|
251
329
|
// ---------- 纯 JS SHA-256 / HMAC-SHA256 ----------
|
|
252
330
|
// 非 HTTPS 环境(http://VPS-IP)下浏览器的 crypto.subtle 不可用,故内联实现。
|
|
253
331
|
// 密钥只参与本地 HMAC 运算,从不随请求发送。
|
|
@@ -334,7 +412,17 @@ function signHeaders(method, path, bodyStr) {
|
|
|
334
412
|
return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
|
|
335
413
|
}
|
|
336
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
|
+
|
|
337
424
|
async function api(path, body) {
|
|
425
|
+
requireSecret();
|
|
338
426
|
const method = body === undefined ? 'GET' : 'POST';
|
|
339
427
|
const bodyStr = body === undefined ? '' : JSON.stringify(body);
|
|
340
428
|
const res = await fetch(path, {
|
|
@@ -361,7 +449,10 @@ let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变
|
|
|
361
449
|
secretInput.addEventListener('change', () => {
|
|
362
450
|
localStorage.setItem('rw_secret', secretInput.value.trim());
|
|
363
451
|
wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
|
|
364
|
-
if (
|
|
452
|
+
if (hasSecret()) {
|
|
453
|
+
if (!wsReady) connectWS();
|
|
454
|
+
refresh(); // 之前无密钥没拉过数据,配好后补一次
|
|
455
|
+
}
|
|
365
456
|
});
|
|
366
457
|
|
|
367
458
|
// 密钥已配置时收起输入区,只留一行状态条;点状态条可展开修改
|
|
@@ -404,7 +495,7 @@ function setConnMode(realtime) {
|
|
|
404
495
|
}
|
|
405
496
|
|
|
406
497
|
function connectWS() {
|
|
407
|
-
if (wsAuthRejected) return;
|
|
498
|
+
if (wsAuthRejected || !hasSecret()) return; // 无密钥时握手必被 4003 拒绝,不发起
|
|
408
499
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
409
500
|
const sock = new WebSocket(`${proto}://${location.host}/ws`);
|
|
410
501
|
curWs = sock;
|
|
@@ -441,7 +532,6 @@ function connectWS() {
|
|
|
441
532
|
renderTasks();
|
|
442
533
|
break;
|
|
443
534
|
case 'task.logs':
|
|
444
|
-
mergePreview(msg.id, msg.lines); // 未展开时也要刷新 shell 视图
|
|
445
535
|
appendLogs(msg.id, msg.lines);
|
|
446
536
|
break;
|
|
447
537
|
case 'workers':
|
|
@@ -450,7 +540,13 @@ function connectWS() {
|
|
|
450
540
|
// --- pty 交互终端流 ---
|
|
451
541
|
case 'pty.data': {
|
|
452
542
|
const ent = termEntries.get(msg.id);
|
|
453
|
-
if (ent) {
|
|
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
|
+
}
|
|
454
550
|
break;
|
|
455
551
|
}
|
|
456
552
|
case 'pty.started': {
|
|
@@ -477,7 +573,8 @@ function connectWS() {
|
|
|
477
573
|
ent.pendingAttach = false;
|
|
478
574
|
if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
|
|
479
575
|
ent.live = msg.live;
|
|
480
|
-
|
|
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`);
|
|
481
578
|
termStatus(ent);
|
|
482
579
|
}
|
|
483
580
|
break;
|
|
@@ -487,7 +584,7 @@ function connectWS() {
|
|
|
487
584
|
sock.addEventListener('close', (ev) => {
|
|
488
585
|
wsReady = false;
|
|
489
586
|
setConnMode(false);
|
|
490
|
-
|
|
587
|
+
for (const ent of termEntries.values()) termStatus(ent); // 各打开终端的状态条都显示重连中
|
|
491
588
|
if (ev.code === 4003) {
|
|
492
589
|
// 验签被拒:无意义重试只会刷服务端日志,等用户改密钥
|
|
493
590
|
wsAuthRejected = true;
|
|
@@ -510,32 +607,195 @@ function fmtTime(ts) {
|
|
|
510
607
|
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
511
608
|
}
|
|
512
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
|
+
|
|
513
620
|
function renderWorkers(workers) {
|
|
621
|
+
workerList = workers;
|
|
514
622
|
const ws = $('worker-status');
|
|
515
|
-
const
|
|
516
|
-
const text =
|
|
623
|
+
const onlineCount = workers.filter((w) => w.online).length;
|
|
624
|
+
const text = workers.length ? `${onlineCount}/${workers.length} 台 Worker 在线` : '无已注册 Worker';
|
|
517
625
|
if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
|
|
518
|
-
ws.className =
|
|
519
|
-
|
|
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(); }
|
|
520
632
|
}
|
|
521
633
|
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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);
|
|
535
662
|
}
|
|
536
|
-
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
|
|
537
663
|
}
|
|
538
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
|
+
|
|
539
799
|
function createTaskEl(t) {
|
|
540
800
|
const div = document.createElement('div');
|
|
541
801
|
div.className = 'task new' + (openTasks.has(t.id) ? ' open' : '');
|
|
@@ -545,7 +805,6 @@ function createTaskEl(t) {
|
|
|
545
805
|
<span class="badge"></span>
|
|
546
806
|
</div>
|
|
547
807
|
<div class="meta"></div>
|
|
548
|
-
<div class="term hidden"></div>
|
|
549
808
|
<div class="logs"></div>
|
|
550
809
|
<div class="actions">
|
|
551
810
|
<button data-act="toggle">展开</button>
|
|
@@ -557,18 +816,13 @@ function createTaskEl(t) {
|
|
|
557
816
|
el: div,
|
|
558
817
|
badge: div.querySelector('.badge'),
|
|
559
818
|
meta: div.querySelector('.meta'),
|
|
560
|
-
termEl: div.querySelector('.term'),
|
|
561
819
|
logsEl: div.querySelector('.logs'),
|
|
562
820
|
toggle: div.querySelector('[data-act=toggle]'),
|
|
563
821
|
term: div.querySelector('[data-act=term]'),
|
|
564
822
|
cancel: div.querySelector('[data-act=cancel]'),
|
|
565
823
|
status: null,
|
|
566
824
|
metaText: null,
|
|
567
|
-
termSig: null,
|
|
568
825
|
};
|
|
569
|
-
entry.termEl.addEventListener('click', () => {
|
|
570
|
-
if (!openTasks.has(t.id)) entry.toggle.click();
|
|
571
|
-
});
|
|
572
826
|
entry.term.addEventListener('click', () => openTerm(t));
|
|
573
827
|
entry.toggle.addEventListener('click', async () => {
|
|
574
828
|
if (openTasks.has(t.id)) {
|
|
@@ -580,7 +834,7 @@ function createTaskEl(t) {
|
|
|
580
834
|
await loadLogs(t.id);
|
|
581
835
|
}
|
|
582
836
|
entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
|
|
583
|
-
|
|
837
|
+
syncTaskRoute(); // 展开状态入 URL:长任务执行中刷新仍留在任务页面
|
|
584
838
|
});
|
|
585
839
|
entry.cancel.addEventListener('click', async () => {
|
|
586
840
|
try {
|
|
@@ -602,66 +856,20 @@ function patchTask(t, entry) {
|
|
|
602
856
|
entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
|
|
603
857
|
}
|
|
604
858
|
entry.term.classList.toggle('hidden', t.type !== 'pty'); // pty 任务入口
|
|
605
|
-
|
|
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) : ''}`;
|
|
606
862
|
if (entry.metaText !== meta) { entry.metaText = meta; entry.meta.textContent = meta; }
|
|
607
863
|
entry.el.classList.toggle('open', openTasks.has(t.id));
|
|
608
|
-
patchTerm(t, entry);
|
|
609
864
|
// 展开且有缓存:内容没变就不动 DOM,保留滚动位置
|
|
610
865
|
if (openTasks.has(t.id) && logCache.has(t.id)) writeLogs(t.id, logCache.get(t.id));
|
|
611
866
|
}
|
|
612
867
|
|
|
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
868
|
function renderTasks() {
|
|
663
869
|
const list = $('task-list');
|
|
664
|
-
const tasks = [...taskCache.values()]
|
|
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);
|
|
665
873
|
// 清理已不存在的任务节点
|
|
666
874
|
const ids = new Set(tasks.map((t) => t.id));
|
|
667
875
|
for (const [id, entry] of taskEls) {
|
|
@@ -715,15 +923,35 @@ function appendLogs(id, lines) {
|
|
|
715
923
|
|
|
716
924
|
// ---------- 交互终端(pty 任务:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
|
|
717
925
|
// PC 有物理键盘:xterm 聚焦后直接承接按键,无需手机端的隐藏输入代理与按键工具条
|
|
718
|
-
|
|
926
|
+
// 布局借鉴 mwt LayoutManager:单屏(叠层只显示一个)/ 并排(grid 平铺同盯多个 pty),
|
|
927
|
+
// tab 活动圆点标记非当前终端的新输出;偏好存 localStorage
|
|
928
|
+
const termEntries = new Map(); // taskId -> { xt, fit, pane, tab, statusEl, live, pendingAttach, liveHint, dirty, attached }
|
|
719
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
|
+
}
|
|
720
948
|
|
|
949
|
+
// 状态条挂在各终端自己的 pane 头部(并排时各有各的状态)
|
|
721
950
|
function termStatus(ent) {
|
|
722
|
-
|
|
723
|
-
if (!
|
|
724
|
-
if (
|
|
725
|
-
else
|
|
726
|
-
else el.textContent = '会话未就绪(任务未领取或 Worker 离线)';
|
|
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})`;
|
|
727
955
|
}
|
|
728
956
|
|
|
729
957
|
function ensureTerm(t) {
|
|
@@ -735,41 +963,114 @@ function ensureTerm(t) {
|
|
|
735
963
|
}
|
|
736
964
|
const xt = new Terminal({
|
|
737
965
|
fontSize: 14, scrollback: 5000, cursorBlink: true,
|
|
738
|
-
theme: {
|
|
966
|
+
theme: { ...TERM_THEME[currentTheme()] },
|
|
739
967
|
});
|
|
740
968
|
const fit = new FitAddon.FitAddon();
|
|
741
969
|
xt.loadAddon(fit);
|
|
742
|
-
|
|
743
|
-
|
|
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
|
+
};
|
|
744
1001
|
termEntries.set(t.id, ent);
|
|
745
1002
|
xt.onData((data) => sendWs({ type: 'session.input', taskId: t.id, data }));
|
|
746
1003
|
return ent;
|
|
747
1004
|
}
|
|
748
1005
|
|
|
749
|
-
|
|
750
|
-
|
|
1006
|
+
// 切换当前终端:点亮对应 tab 与 pane,熄灭活动圆点,聚焦承接输入
|
|
1007
|
+
function activateTerm(id) {
|
|
1008
|
+
const ent = termEntries.get(id);
|
|
751
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) {
|
|
752
1026
|
try { ent.fit.fit(); } catch { return; }
|
|
753
|
-
sendWs({ type: 'session.resize', taskId:
|
|
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
|
|
754
1052
|
}
|
|
755
1053
|
|
|
756
1054
|
function openTerm(t) {
|
|
757
1055
|
const ent = ensureTerm(t);
|
|
758
1056
|
if (!ent) return;
|
|
759
|
-
activeTermId = t.id;
|
|
760
|
-
$('term-title').textContent = t.title || '交互终端';
|
|
761
1057
|
$('term-overlay').classList.remove('hidden');
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
ent.
|
|
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
|
+
}
|
|
766
1066
|
termStatus(ent);
|
|
767
1067
|
}
|
|
768
1068
|
|
|
769
1069
|
function closeTermOverlay() {
|
|
1070
|
+
// 覆盖层自身处于全屏时先退出全屏,否则会留一个隐藏的黑屏全屏层
|
|
1071
|
+
if (fsElement() === $('term-overlay')) exitFs();
|
|
770
1072
|
$('term-overlay').classList.add('hidden');
|
|
771
1073
|
activeTermId = null;
|
|
772
|
-
termStatus(null);
|
|
773
1074
|
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
|
774
1075
|
}
|
|
775
1076
|
|
|
@@ -778,8 +1079,15 @@ function disposeTerm(id) {
|
|
|
778
1079
|
const ent = termEntries.get(id);
|
|
779
1080
|
if (!ent) return;
|
|
780
1081
|
termEntries.delete(id);
|
|
781
|
-
|
|
1082
|
+
ent.tab.remove();
|
|
782
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 有兜底不执行)
|
|
783
1091
|
}
|
|
784
1092
|
|
|
785
1093
|
$('term-close').addEventListener('click', closeTermOverlay);
|
|
@@ -787,9 +1095,45 @@ $('term-body').addEventListener('click', () => {
|
|
|
787
1095
|
const ent = termEntries.get(activeTermId);
|
|
788
1096
|
if (ent) ent.xt.focus();
|
|
789
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')));
|
|
790
1133
|
// Esc 收起终端覆盖层(终端聚焦时 Esc 会先被 xterm 吃掉,此处兜底非聚焦场景)
|
|
791
1134
|
document.addEventListener('keydown', (ev) => {
|
|
792
1135
|
if (ev.key === 'Escape' && activeTermId && !$('term-overlay').classList.contains('hidden')) {
|
|
1136
|
+
if (fsElement()) return; // 全屏态 Esc 由浏览器用于退出全屏,不再联动收起
|
|
793
1137
|
const ent = termEntries.get(activeTermId);
|
|
794
1138
|
// 终端聚焦时 Esc 是有效按键(如退出 less),双击 Esc 才收起,避免误触
|
|
795
1139
|
if (ent && document.activeElement && document.activeElement.closest
|
|
@@ -797,8 +1141,8 @@ document.addEventListener('keydown', (ev) => {
|
|
|
797
1141
|
closeTermOverlay();
|
|
798
1142
|
}
|
|
799
1143
|
});
|
|
800
|
-
// 窗口尺寸变化后重新 fit
|
|
801
|
-
window.addEventListener('resize', () => { if (
|
|
1144
|
+
// 窗口尺寸变化后重新 fit 所有可见终端
|
|
1145
|
+
window.addEventListener('resize', () => { if (termEntries.size) setTimeout(fitVisibleTerms, 120); });
|
|
802
1146
|
|
|
803
1147
|
// ---------- 轮询回退(仅 WS 不可用时生效)----------
|
|
804
1148
|
async function refresh() {
|
|
@@ -815,64 +1159,34 @@ async function refresh() {
|
|
|
815
1159
|
toast(err.message);
|
|
816
1160
|
}
|
|
817
1161
|
}
|
|
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
|
-
});
|
|
1162
|
+
setInterval(() => { if (!wsReady && !wsAuthRejected && hasSecret()) refresh(); }, 4000);
|
|
831
1163
|
|
|
832
1164
|
// ---------- 提交任务(REST,WS 负责推送变更)----------
|
|
833
1165
|
$('btn-submit').addEventListener('click', async () => {
|
|
1166
|
+
// 显式指派:二级视图下任务固定提交给当前 Worker
|
|
1167
|
+
const targetWorker = currentWorkerId;
|
|
1168
|
+
if (!targetWorker) return toast('请先在 Worker 列表中选择 Worker');
|
|
834
1169
|
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
|
-
}
|
|
1170
|
+
const payload = { project: $('f-pty-project').value.trim() || 'default' };
|
|
1171
|
+
if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
|
|
859
1172
|
try {
|
|
860
1173
|
$('btn-submit').disabled = true;
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
await api('/api/tasks', { type: currentType, title: title || autoTitle, payload });
|
|
864
|
-
toast(currentType === 'pty' ? '已提交,任务运行后点「进入终端」' : '任务已提交');
|
|
1174
|
+
await api('/api/tasks', { type: 'pty', title: title || `交互终端(${payload.project})`, payload, targetWorker });
|
|
1175
|
+
toast('已提交,任务运行后点「进入终端」');
|
|
865
1176
|
$('f-title').value = '';
|
|
866
1177
|
if (!wsReady) await refresh();
|
|
867
1178
|
} catch (err) {
|
|
868
1179
|
toast(err.message);
|
|
869
1180
|
} finally {
|
|
870
|
-
|
|
1181
|
+
updateSubmitState();
|
|
871
1182
|
}
|
|
872
1183
|
});
|
|
873
1184
|
|
|
1185
|
+
applyTheme(currentTheme()); // 主题初始化:同步按钮文案与 theme-color(head 已设好 data-theme)
|
|
874
1186
|
connectWS();
|
|
875
|
-
|
|
1187
|
+
// 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据(无密钥时不发请求)
|
|
1188
|
+
if (hasSecret()) refresh();
|
|
1189
|
+
else setConnMode(false); // 无密钥不发起任何连接,状态条直接落到轮询态
|
|
876
1190
|
</script>
|
|
877
1191
|
</body>
|
|
878
1192
|
</html>
|