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