coxpit 5.4.0 → 5.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/package.json +1 -1
- package/src/cockpit.ts +284 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cockpit.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Cockpit — 터미널 우선 셸 (병행
|
|
1
|
+
// Cockpit — 터미널 우선 셸 (병행 개발, /cockpit). board.ts 처럼 자가완결 단일 HTML(빌드 0).
|
|
2
2
|
// 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
|
|
3
|
-
// Phase
|
|
3
|
+
// Phase 2 = 워크스페이스 트리(/api/fleet 라이브) + 페인 그리드 터미널(오토타일=창분할, 각 페인 /ws/term attach).
|
|
4
4
|
export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
5
5
|
<html lang="en">
|
|
6
6
|
<head>
|
|
@@ -10,6 +10,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
10
10
|
<link rel="icon" href="/brand/favicon.ico" sizes="any" />
|
|
11
11
|
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
|
|
12
12
|
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
|
|
13
|
+
<link rel="stylesheet" href="/vendor/xterm.css" />
|
|
13
14
|
<style>
|
|
14
15
|
@font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
|
|
15
16
|
:root{
|
|
@@ -17,7 +18,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
17
18
|
--line:#222835; --line-hi:#2f3648;
|
|
18
19
|
--ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
|
|
19
20
|
--brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
|
|
20
|
-
--running:#55a7e0; --done:#58b368; --blocked:#d6a249; --failed:#e25b67;
|
|
21
|
+
--running:#55a7e0; --done:#58b368; --blocked:#d6a249; --failed:#e25b67; --merged:#4ec9b0; --open:#7f9cf5; --stopped:#b58be0;
|
|
21
22
|
--mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
|
|
22
23
|
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
|
|
23
24
|
}
|
|
@@ -25,8 +26,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
25
26
|
html,body{height:100%}
|
|
26
27
|
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
|
|
27
28
|
-webkit-font-smoothing:antialiased;overflow:hidden}
|
|
29
|
+
button{font-family:var(--sans)}
|
|
30
|
+
:focus-visible{outline:2px solid rgba(78,201,176,.5);outline-offset:1px;border-radius:4px}
|
|
28
31
|
|
|
29
|
-
/* ── topbar ── */
|
|
30
32
|
header{display:flex;align-items:center;gap:13px;height:46px;padding:0 14px;
|
|
31
33
|
border-bottom:1px solid var(--line);background:rgba(18,21,28,.92);font-family:var(--mono)}
|
|
32
34
|
.brand{display:inline-flex;align-items:center;gap:8px;text-decoration:none}
|
|
@@ -38,29 +40,72 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
38
40
|
.vtab{font-size:12px;color:var(--muted);padding:6px 11px;border-radius:7px;display:inline-flex;align-items:center;gap:7px;cursor:pointer;background:none;border:none;font-family:var(--mono)}
|
|
39
41
|
.vtab.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.28)}
|
|
40
42
|
.vtab .g{color:var(--brand)}
|
|
43
|
+
.vtab[disabled]{opacity:.5;cursor:default}
|
|
41
44
|
.right{margin-left:auto;display:flex;align-items:center;gap:9px}
|
|
45
|
+
.ws{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}
|
|
46
|
+
.ws .dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}
|
|
47
|
+
.ws.on .dot{background:var(--done)}
|
|
42
48
|
.wip{font-size:10.5px;color:var(--blocked);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:2px 9px}
|
|
43
49
|
.toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px}
|
|
44
50
|
.toggle:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
.layout{
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.
|
|
56
|
-
.
|
|
57
|
-
.
|
|
58
|
-
.
|
|
59
|
-
.
|
|
60
|
-
.
|
|
61
|
-
.
|
|
62
|
-
|
|
63
|
-
.
|
|
52
|
+
.layout{display:grid;grid-template-columns:270px 1fr;height:calc(100vh - 46px)}
|
|
53
|
+
.layout > *{min-height:0;min-width:0}
|
|
54
|
+
|
|
55
|
+
/* ── workspace tree ── */
|
|
56
|
+
.rail{border-right:1px solid var(--line);overflow:auto;padding:10px 8px;font-family:var(--mono);font-size:12.5px;display:flex;flex-direction:column}
|
|
57
|
+
.lbl{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);padding:6px 8px 10px;display:flex;justify-content:space-between}
|
|
58
|
+
.tnode{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;color:var(--muted);white-space:nowrap;cursor:default}
|
|
59
|
+
.tnode .car{color:var(--faint);width:9px;display:inline-block;text-align:center;cursor:pointer}
|
|
60
|
+
.tnode .n{overflow:hidden;text-overflow:ellipsis;flex:1}
|
|
61
|
+
.tnode .meta{color:var(--faint);font-size:11px}
|
|
62
|
+
.tnode.repo{color:var(--ink)}
|
|
63
|
+
.tnode.goal{padding-left:20px} .tnode.goal .gi{color:var(--brand)}
|
|
64
|
+
.tnode.task{padding-left:30px}
|
|
65
|
+
.tnode.run{padding-left:44px;font-size:12px;cursor:pointer}
|
|
66
|
+
.tnode.run:hover{background:var(--surface)}
|
|
67
|
+
.tnode.run.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
|
|
68
|
+
.tnode.empty{color:var(--faint);padding:8px}
|
|
69
|
+
.st{width:6px;height:6px;border-radius:50%;flex:none}
|
|
70
|
+
.st.running{background:var(--running);box-shadow:0 0 0 3px rgba(85,167,224,.16)}
|
|
71
|
+
.st.done{background:var(--done)} .st.blocked{background:var(--blocked)} .st.failed,.st.error{background:var(--failed)}
|
|
72
|
+
.st.merged{background:var(--merged)} .st.open{background:var(--open)} .st.stopped{background:var(--stopped)}
|
|
73
|
+
.st.preparing,.st.pending,.st.starting{background:var(--blocked)}
|
|
74
|
+
.tree-sep{height:1px;background:var(--line);margin:8px 4px}
|
|
75
|
+
.railfoot{margin-top:auto;padding:8px;color:var(--faint);font-size:11px;border-top:1px solid var(--line)}
|
|
76
|
+
|
|
77
|
+
/* ── pane grid (창분할) ── */
|
|
78
|
+
.stage{display:flex;flex-direction:column;min-width:0;background:var(--bg)}
|
|
79
|
+
.panebar{display:flex;align-items:center;gap:8px;height:36px;padding:0 12px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono);font-size:11.5px;color:var(--faint)}
|
|
80
|
+
.panebar .grow{flex:1}
|
|
81
|
+
.pb-btn{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:11px;padding:3px 9px;cursor:pointer;font-family:var(--mono)}
|
|
82
|
+
.pb-btn:hover:not([disabled]){color:var(--ink);border-color:var(--line-hi)}
|
|
83
|
+
.pb-btn[disabled]{opacity:.4;cursor:default}
|
|
84
|
+
.panes{flex:1;display:grid;gap:1px;background:var(--line);padding:1px;min-height:0}
|
|
85
|
+
.pane{display:flex;flex-direction:column;background:var(--bg);min-width:0;min-height:0;overflow:hidden}
|
|
86
|
+
.pane.focus .pane-h{background:var(--brand-dim)}
|
|
87
|
+
.pane.focus{box-shadow:inset 0 0 0 1px rgba(78,201,176,.4)}
|
|
88
|
+
.pane-h{display:flex;align-items:center;gap:8px;height:28px;padding:0 10px;background:var(--surface);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:11px;color:var(--muted);cursor:pointer;flex:none}
|
|
89
|
+
.pane-h .rid{color:var(--ink);font-weight:600}
|
|
90
|
+
.pane-h .chip{font-size:9px;text-transform:uppercase;letter-spacing:.04em;padding:1px 6px;border-radius:999px}
|
|
91
|
+
.chip.running{color:var(--running);background:rgba(85,167,224,.14)}
|
|
92
|
+
.chip.done,.chip.merged{color:var(--done);background:rgba(88,179,104,.14)}
|
|
93
|
+
.chip.blocked,.chip.preparing,.chip.pending,.chip.starting{color:var(--blocked);background:rgba(214,162,73,.16)}
|
|
94
|
+
.chip.failed,.chip.error{color:var(--failed);background:rgba(226,91,103,.14)}
|
|
95
|
+
.chip.open{color:var(--open);background:rgba(127,156,245,.14)}
|
|
96
|
+
.chip.stopped{color:var(--stopped);background:rgba(181,139,224,.14)}
|
|
97
|
+
.pane-h .title{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint)}
|
|
98
|
+
.pane-h .x{color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;padding:0 3px}
|
|
99
|
+
.pane-h .x:hover{color:var(--failed)}
|
|
100
|
+
.pane-term{flex:1;min-height:0;padding:4px 2px 2px 8px}
|
|
101
|
+
.pane-term .xterm{height:100%}
|
|
102
|
+
|
|
103
|
+
.empty{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
|
|
104
|
+
.empty .card{max-width:440px}
|
|
105
|
+
.empty .glyph{font-family:var(--mono);font-size:24px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
|
|
106
|
+
.empty h1{font-family:var(--mono);font-size:16px;margin:0 0 8px;color:var(--ink)}
|
|
107
|
+
.empty p{color:var(--muted);font-size:13px;margin:0}
|
|
108
|
+
|
|
64
109
|
.reqbar{display:flex;align-items:center;gap:10px;border-top:1px solid var(--line);background:var(--surface);padding:11px 14px;font-family:var(--mono)}
|
|
65
110
|
.reqbar .ctx{font-size:11px;color:var(--brand-ink);background:var(--brand);border-radius:6px;padding:3px 9px;font-weight:600}
|
|
66
111
|
.reqbar .caret{color:var(--brand)}
|
|
@@ -74,52 +119,246 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
74
119
|
<span class="mach"><span class="dot"></span><span id="mach">local</span></span>
|
|
75
120
|
<div class="vtabs">
|
|
76
121
|
<button type="button" class="vtab on"><span class="g">⌗</span>Terminal</button>
|
|
77
|
-
<button type="button" class="vtab"><span class="g">⧉</span>Review</button>
|
|
78
|
-
<button type="button" class="vtab"><span class="g">▤</span>Docs</button>
|
|
122
|
+
<button type="button" class="vtab" disabled title="Phase 3"><span class="g">⧉</span>Review</button>
|
|
123
|
+
<button type="button" class="vtab" disabled title="Phase 3"><span class="g">▤</span>Docs</button>
|
|
79
124
|
</div>
|
|
80
125
|
<div class="right">
|
|
81
|
-
<span class="
|
|
126
|
+
<span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
|
|
127
|
+
<span class="wip">preview · Phase 2</span>
|
|
82
128
|
<a class="toggle" href="/">← Board</a>
|
|
83
129
|
</div>
|
|
84
130
|
</header>
|
|
85
131
|
|
|
86
132
|
<div class="layout">
|
|
87
133
|
<aside class="rail">
|
|
88
|
-
<div class="lbl">Workspace</div>
|
|
89
|
-
<div
|
|
90
|
-
<div class="
|
|
91
|
-
<div class="skel" style="width:60%"></div>
|
|
92
|
-
<div class="skel" style="width:44%"></div>
|
|
134
|
+
<div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
|
|
135
|
+
<div id="tree"></div>
|
|
136
|
+
<div class="railfoot">클릭한 run 은 오른쪽 페인에서 열립니다 · 분할(⊞)로 여러 에이전트를 나란히</div>
|
|
93
137
|
</aside>
|
|
94
138
|
|
|
95
|
-
<main class="
|
|
96
|
-
<div class="
|
|
139
|
+
<main class="stage">
|
|
140
|
+
<div class="panebar">
|
|
141
|
+
<span id="paneCount">0 panes</span>
|
|
142
|
+
<span style="color:var(--faint)">· run 을 열수록 자동 분할(타일)</span>
|
|
143
|
+
<span class="grow"></span>
|
|
144
|
+
<button class="pb-btn" id="closeBtn" disabled title="포커스 페인 닫기">× close focused</button>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="panes" id="panes"></div>
|
|
147
|
+
<div class="empty" id="empty">
|
|
97
148
|
<div class="card">
|
|
98
149
|
<div class="glyph">⌗ ⌗ ⌗</div>
|
|
99
|
-
<h1
|
|
100
|
-
<p
|
|
101
|
-
<div class="steps">
|
|
102
|
-
<div><b>Phase 1</b> · tmux 재-adopt (재시작 내성 토대)</div>
|
|
103
|
-
<div><b>Phase 2</b> · 워크스페이스 트리 + 페인 그리드 터미널</div>
|
|
104
|
-
<div><b>Phase 3</b> · 요청바 · 팬아웃 타일 · Review · 브로드캐스트</div>
|
|
105
|
-
<div><b>Phase 4</b> · verify in-loop</div>
|
|
106
|
-
</div>
|
|
150
|
+
<h1>페인이 비어 있습니다</h1>
|
|
151
|
+
<p>왼쪽 트리에서 <b>run</b> 을 클릭하면 그 에이전트 터미널이 여기 페인으로 열립니다. 여러 개 열면 자동으로 분할(타일)됩니다.</p>
|
|
107
152
|
</div>
|
|
108
153
|
</div>
|
|
109
154
|
<div class="reqbar">
|
|
110
155
|
<span class="ctx">cockpit</span><span class="caret">›</span>
|
|
111
|
-
<span class="ph">요청바 — Phase 3</span>
|
|
156
|
+
<span class="ph">요청바 — Phase 3 (repo 문맥에서 팬아웃)</span>
|
|
112
157
|
<span class="kbd">⏎</span>
|
|
113
158
|
</div>
|
|
114
159
|
</main>
|
|
115
160
|
</div>
|
|
116
161
|
|
|
162
|
+
<script src="/vendor/xterm.js"></script>
|
|
163
|
+
<script src="/vendor/addon-fit.js"></script>
|
|
164
|
+
<script src="/vendor/addon-unicode11.js"></script>
|
|
117
165
|
<script>
|
|
118
|
-
// 모바일은 터미널 우선 대신 보드(모니터)
|
|
119
|
-
// (뷰포트 폭 + 터치 힌트로 판정 — screen.width 는 헤드리스/멀티모니터서 부정확)
|
|
166
|
+
// 모바일은 터미널 우선 대신 보드(모니터)로.
|
|
120
167
|
if (window.innerWidth <= 860 && matchMedia('(pointer:coarse)').matches) location.replace('/');
|
|
121
|
-
|
|
122
|
-
|
|
168
|
+
var esc = function(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return ({'&':'&','<':'<','>':'>','"':'"'})[c]; }); };
|
|
169
|
+
var $ = function(id){ return document.getElementById(id); };
|
|
170
|
+
|
|
171
|
+
// ── fleet 상태 ──
|
|
172
|
+
var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[] };
|
|
173
|
+
var runById = {};
|
|
174
|
+
var fold = {}; // 접힘 상태(repo/goal/task 노드)
|
|
175
|
+
function isFold(k){ return fold[k]===true; }
|
|
176
|
+
|
|
177
|
+
async function hydrate(){
|
|
178
|
+
try{
|
|
179
|
+
var d = await (await fetch('/api/fleet?view=all')).json();
|
|
180
|
+
fleet = d; runById = {};
|
|
181
|
+
(d.runs||[]).forEach(function(r){ runById[r.id]=r; });
|
|
182
|
+
if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
|
|
183
|
+
renderTree();
|
|
184
|
+
syncPanes();
|
|
185
|
+
}catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 트리 렌더: Machine ▸ Repo ▸ Goal ▸ Task ▸ Run ──
|
|
189
|
+
function renderTree(){
|
|
190
|
+
var el = $('tree');
|
|
191
|
+
var repos = fleet.repos||[], tasks = fleet.tasks||[], groups = fleet.groups||[], runs = fleet.runs||[];
|
|
192
|
+
var groupById = {}; groups.forEach(function(g){ groupById[g.id]=g; });
|
|
193
|
+
var tasksByRepo = {}; tasks.forEach(function(t){ (tasksByRepo[t.repoId]=tasksByRepo[t.repoId]||[]).push(t); });
|
|
194
|
+
var runsByTask = {}; runs.forEach(function(r){ (runsByTask[r.taskId]=runsByTask[r.taskId]||[]).push(r); });
|
|
195
|
+
if (!repos.length){ el.innerHTML = '<div class="tnode empty">등록된 repo 가 없습니다 — 보드에서 추가하세요.</div>'; return; }
|
|
196
|
+
var html = '';
|
|
197
|
+
repos.forEach(function(repo){
|
|
198
|
+
var rk = 'repo'+repo.id;
|
|
199
|
+
var rTasks = (tasksByRepo[repo.id]||[]).filter(function(t){ return t.status!=='closed'; });
|
|
200
|
+
var runCount = rTasks.reduce(function(n,t){ return n+((runsByTask[t.id]||[]).length); }, 0);
|
|
201
|
+
html += '<div class="tnode repo" data-fold="'+rk+'"><span class="car">'+(isFold(rk)?'▸':'▾')+'</span>'
|
|
202
|
+
+ '<span class="n">'+esc(repo.name)+'</span><span class="meta">'+runCount+' run'+(runCount===1?'':'s')+'</span></div>';
|
|
203
|
+
if (isFold(rk)) return;
|
|
204
|
+
// goal(group) 로 묶기
|
|
205
|
+
var byGroup = {}, ungrouped = [];
|
|
206
|
+
rTasks.forEach(function(t){ if (t.groupId && groupById[t.groupId]) (byGroup[t.groupId]=byGroup[t.groupId]||[]).push(t); else ungrouped.push(t); });
|
|
207
|
+
Object.keys(byGroup).forEach(function(gid){
|
|
208
|
+
var g = groupById[gid]; var gk='goal'+gid;
|
|
209
|
+
html += '<div class="tnode goal" data-fold="'+gk+'"><span class="car">'+(isFold(gk)?'▸':'▾')+'</span>'
|
|
210
|
+
+ '<span class="gi">⌁</span><span class="n">'+esc(g.title)+'</span><span class="meta">'+(g.kind||'goal')+'</span></div>';
|
|
211
|
+
if (!isFold(gk)) byGroup[gid].forEach(function(t){ html += taskHTML(t, runsByTask[t.id]||[]); });
|
|
212
|
+
});
|
|
213
|
+
ungrouped.forEach(function(t){ html += taskHTML(t, runsByTask[t.id]||[]); });
|
|
214
|
+
if (!rTasks.length) html += '<div class="tnode empty" style="padding-left:30px">태스크 없음</div>';
|
|
215
|
+
});
|
|
216
|
+
el.innerHTML = html;
|
|
217
|
+
}
|
|
218
|
+
function taskHTML(t, rns){
|
|
219
|
+
var tk='task'+t.id;
|
|
220
|
+
var s = '<div class="tnode task" data-fold="'+tk+'"><span class="car">'+(rns.length?(isFold(tk)?'▸':'▾'):' ')+'</span>'
|
|
221
|
+
+ '<span class="n">'+esc(t.title)+'</span></div>';
|
|
222
|
+
if (!isFold(tk)) rns.sort(function(a,b){return a.id-b.id;}).forEach(function(r){
|
|
223
|
+
var open = paneByRun[r.id] ? ' open' : '';
|
|
224
|
+
s += '<div class="tnode run'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
|
|
225
|
+
+ '<span class="n">r'+r.id+' · '+esc(r.status)+'</span></div>';
|
|
226
|
+
});
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
$('tree').addEventListener('click', function(e){
|
|
230
|
+
var run = e.target.closest('.tnode.run');
|
|
231
|
+
if (run){ openRunPane(+run.dataset.run); return; }
|
|
232
|
+
var fn = e.target.closest('[data-fold]');
|
|
233
|
+
if (fn){ var k = fn.dataset.fold; fold[k] = !isFold(k); renderTree(); }
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ── 페인 그리드(창분할, 오토타일) ──
|
|
237
|
+
var panes = []; // { id, runId, el, term, fit, ws, retry, closing, ro }
|
|
238
|
+
var paneByRun = {}; // runId -> pane
|
|
239
|
+
var focusId = null;
|
|
240
|
+
var paneSeq = 0;
|
|
241
|
+
var MAX_PANES = 8;
|
|
242
|
+
|
|
243
|
+
function layoutPanes(){
|
|
244
|
+
var host = $('panes'); var n = panes.length;
|
|
245
|
+
$('empty').style.display = n ? 'none' : 'flex';
|
|
246
|
+
host.style.display = n ? 'grid' : 'none';
|
|
247
|
+
$('paneCount').textContent = n + ' pane' + (n===1?'':'s');
|
|
248
|
+
$('closeBtn').disabled = !focusId;
|
|
249
|
+
if (!n) return;
|
|
250
|
+
var cols = Math.ceil(Math.sqrt(n));
|
|
251
|
+
var rows = Math.ceil(n / cols);
|
|
252
|
+
host.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
|
253
|
+
host.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
|
|
254
|
+
// 레이아웃 후 각 터미널 리핏
|
|
255
|
+
requestAnimationFrame(function(){ panes.forEach(fitPane); });
|
|
256
|
+
}
|
|
257
|
+
function fitPane(p){
|
|
258
|
+
if (!p.fit || !p.term) return;
|
|
259
|
+
try{ p.fit.fit(); if (p.ws && p.ws.readyState===1) p.ws.send(JSON.stringify({t:'r',cols:p.term.cols,rows:p.term.rows})); }catch(e){}
|
|
260
|
+
}
|
|
261
|
+
function setFocus(id){
|
|
262
|
+
focusId = id;
|
|
263
|
+
panes.forEach(function(p){ p.el.classList.toggle('focus', p.id===id); });
|
|
264
|
+
$('closeBtn').disabled = !focusId;
|
|
265
|
+
var p = panes.find(function(x){return x.id===id;});
|
|
266
|
+
if (p && p.term) try{ p.term.focus(); }catch(e){}
|
|
267
|
+
}
|
|
268
|
+
function openRunPane(runId){
|
|
269
|
+
var existing = paneByRun[runId];
|
|
270
|
+
if (existing){ setFocus(existing.id); return; }
|
|
271
|
+
if (panes.length >= MAX_PANES){ return; }
|
|
272
|
+
var r = runById[runId];
|
|
273
|
+
var p = { id: ++paneSeq, runId: runId, retry: 0, closing: false };
|
|
274
|
+
var el = document.createElement('div'); el.className = 'pane'; p.el = el;
|
|
275
|
+
el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
|
|
276
|
+
+ '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
|
|
277
|
+
+ '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
|
|
278
|
+
+ '<button class="x" title="close">×</button></div>'
|
|
279
|
+
+ '<div class="pane-term"></div>';
|
|
280
|
+
$('panes').appendChild(el);
|
|
281
|
+
el.querySelector('.pane-h').addEventListener('click', function(ev){
|
|
282
|
+
if (ev.target.closest('.x')) return; setFocus(p.id);
|
|
283
|
+
});
|
|
284
|
+
el.querySelector('.x').addEventListener('click', function(){ closePane(p.id); });
|
|
285
|
+
// xterm
|
|
286
|
+
var term = new window.Terminal({
|
|
287
|
+
fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', monospace",
|
|
288
|
+
fontSize: 12, cursorBlink: true, allowProposedApi: true, scrollback: 4000,
|
|
289
|
+
theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
|
|
290
|
+
});
|
|
291
|
+
p.term = term;
|
|
292
|
+
p.fit = new window.FitAddon.FitAddon(); term.loadAddon(p.fit);
|
|
293
|
+
try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
|
|
294
|
+
term.open(el.querySelector('.pane-term'));
|
|
295
|
+
term.onData(function(d){ if (p.ws && p.ws.readyState===1) p.ws.send(JSON.stringify({t:'i',d:d})); });
|
|
296
|
+
p.ro = new ResizeObserver(function(){ fitPane(p); }); p.ro.observe(el.querySelector('.pane-term'));
|
|
297
|
+
panes.push(p); paneByRun[runId] = p;
|
|
298
|
+
layoutPanes();
|
|
299
|
+
connectPane(p);
|
|
300
|
+
setFocus(p.id);
|
|
301
|
+
renderTree();
|
|
302
|
+
}
|
|
303
|
+
function connectPane(p){
|
|
304
|
+
if (p.closing || !p.term) return;
|
|
305
|
+
try{ p.fit.fit(); }catch(e){}
|
|
306
|
+
var proto = location.protocol==='https:'?'wss':'ws';
|
|
307
|
+
var sock = new WebSocket(proto+'://'+location.host+'/ws/term/'+p.runId+'?cols='+p.term.cols+'&rows='+p.term.rows);
|
|
308
|
+
p.ws = sock;
|
|
309
|
+
sock.onopen = function(){ if (sock!==p.ws){ try{sock.close();}catch(e){} return; } p.retry=0; };
|
|
310
|
+
sock.onmessage = function(m){
|
|
311
|
+
if (sock!==p.ws || !p.term) return;
|
|
312
|
+
try{ var d = JSON.parse(m.data);
|
|
313
|
+
if (d.t==='o') p.term.write(d.d);
|
|
314
|
+
else if (d.t==='err') p.term.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
|
|
315
|
+
else if (d.t==='exit') p.term.write('\\r\\n\\x1b[90m[session ended — 재연결 시 소생]\\x1b[0m\\r\\n');
|
|
316
|
+
}catch(e){}
|
|
317
|
+
};
|
|
318
|
+
sock.onclose = function(){
|
|
319
|
+
if (sock!==p.ws || p.closing) return;
|
|
320
|
+
var delay = Math.min(8000, 800 * Math.pow(2, p.retry++));
|
|
321
|
+
setTimeout(function(){ connectPane(p); }, delay);
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function closePane(id){
|
|
325
|
+
var i = panes.findIndex(function(p){return p.id===id;});
|
|
326
|
+
if (i<0) return;
|
|
327
|
+
var p = panes[i]; p.closing = true;
|
|
328
|
+
try{ if (p.ws) p.ws.close(); }catch(e){}
|
|
329
|
+
try{ if (p.ro) p.ro.disconnect(); }catch(e){}
|
|
330
|
+
try{ if (p.term) p.term.dispose(); }catch(e){}
|
|
331
|
+
try{ p.el.remove(); }catch(e){}
|
|
332
|
+
delete paneByRun[p.runId];
|
|
333
|
+
panes.splice(i,1);
|
|
334
|
+
if (focusId===id) focusId = panes.length ? panes[panes.length-1].id : null;
|
|
335
|
+
layoutPanes(); if (focusId) setFocus(focusId);
|
|
336
|
+
renderTree();
|
|
337
|
+
}
|
|
338
|
+
// 페인 상태/타이틀을 fleet 갱신에 맞춰 갱신 + 사라진 run 페인 정리
|
|
339
|
+
function syncPanes(){
|
|
340
|
+
panes.forEach(function(p){
|
|
341
|
+
var r = runById[p.runId];
|
|
342
|
+
var chip = p.el.querySelector('[data-role=chip]'); var title = p.el.querySelector('[data-role=title]');
|
|
343
|
+
if (r){ if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; } if(title) title.textContent = r.tmuxWindow||'terminal'; }
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
$('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
|
|
347
|
+
|
|
348
|
+
// ── /ws 라이브 구독 → 트리·페인 갱신 ──
|
|
349
|
+
function wsConnect(){
|
|
350
|
+
var proto = location.protocol==='https:'?'wss':'ws';
|
|
351
|
+
var ws = new WebSocket(proto+'://'+location.host+'/ws');
|
|
352
|
+
ws.onopen = function(){ $('ws').classList.add('on'); $('wstext').textContent='live'; };
|
|
353
|
+
ws.onclose = function(){ $('ws').classList.remove('on'); $('wstext').textContent='reconnecting'; setTimeout(wsConnect, 1500); };
|
|
354
|
+
ws.onmessage = function(){ /* 델타는 종류가 많아 단순히 전체 리하이드레이트(디바운스) */ scheduleHydrate(); };
|
|
355
|
+
}
|
|
356
|
+
var hydT=null;
|
|
357
|
+
function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
|
|
358
|
+
|
|
359
|
+
hydrate();
|
|
360
|
+
wsConnect();
|
|
361
|
+
window.addEventListener('resize', function(){ panes.forEach(fitPane); });
|
|
123
362
|
</script>
|
|
124
363
|
</body>
|
|
125
364
|
</html>`;
|