claude-controller 0.2.0 → 0.3.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 +2 -2
- package/bin/autoloop.sh +382 -0
- package/bin/ctl +327 -5
- package/bin/native-app.py +5 -2
- package/bin/watchdog.sh +357 -0
- package/cognitive/__init__.py +14 -0
- package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
- package/cognitive/dispatcher.py +192 -0
- package/cognitive/evaluator.py +289 -0
- package/cognitive/goal_engine.py +232 -0
- package/cognitive/learning.py +189 -0
- package/cognitive/orchestrator.py +303 -0
- package/cognitive/planner.py +207 -0
- package/cognitive/prompts/analyst.md +31 -0
- package/cognitive/prompts/coder.md +22 -0
- package/cognitive/prompts/reviewer.md +33 -0
- package/cognitive/prompts/tester.md +21 -0
- package/cognitive/prompts/writer.md +25 -0
- package/config.sh +6 -1
- package/dag/__init__.py +5 -0
- package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
- package/dag/__pycache__/graph.cpython-314.pyc +0 -0
- package/dag/graph.py +222 -0
- package/lib/jobs.sh +12 -1
- package/package.json +5 -1
- package/postinstall.sh +1 -1
- package/service/controller.sh +43 -11
- package/web/audit.py +122 -0
- package/web/checkpoint.py +80 -0
- package/web/config.py +2 -5
- package/web/handler.py +464 -26
- package/web/handler_fs.py +15 -14
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +165 -42
- package/web/handler_memory.py +203 -0
- package/web/jobs.py +576 -12
- package/web/personas.py +419 -0
- package/web/pipeline.py +682 -50
- package/web/presets.py +506 -0
- package/web/projects.py +58 -4
- package/web/static/api.js +90 -3
- package/web/static/app.js +8 -0
- package/web/static/base.css +51 -12
- package/web/static/context.js +14 -4
- package/web/static/form.css +3 -2
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +288 -0
- package/web/static/index.html +142 -6
- package/web/static/jobs.css +951 -4
- package/web/static/jobs.js +890 -54
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +308 -1
- package/web/static/pipelines.js +249 -14
- package/web/static/presets.js +244 -0
- package/web/static/send.js +26 -4
- package/web/static/settings-style.css +34 -3
- package/web/static/settings.js +37 -1
- package/web/static/stream.js +242 -19
- package/web/static/utils.js +54 -2
- package/web/webhook.py +210 -0
package/web/static/jobs.js
CHANGED
|
@@ -4,32 +4,649 @@
|
|
|
4
4
|
|
|
5
5
|
let expandedJobId = null;
|
|
6
6
|
let jobPollTimer = null;
|
|
7
|
+
let _jobFilterStatus = 'all';
|
|
8
|
+
let _jobFilterProject = 'all';
|
|
9
|
+
let _jobSearchQuery = '';
|
|
10
|
+
let _allJobs = [];
|
|
11
|
+
let _registeredProjects = [];
|
|
12
|
+
let _jobListCollapsed = localStorage.getItem('jobListCollapsed') === '1';
|
|
13
|
+
let _jobViewMode = localStorage.getItem('jobViewMode') || 'flat';
|
|
14
|
+
let _jobViewModeManual = localStorage.getItem('jobViewModeManual') === '1'; // 사용자가 수동 전환한 적 있는지
|
|
15
|
+
let _collapsedGroups = JSON.parse(localStorage.getItem('collapsedGroups') || '{}');
|
|
16
|
+
let _statsPeriod = 'all';
|
|
17
|
+
let _selectedProject = null;
|
|
18
|
+
let _selectedProjectInfo = null;
|
|
19
|
+
let _jobPage = 1;
|
|
20
|
+
let _jobLimit = 10;
|
|
21
|
+
let _jobPages = 1;
|
|
22
|
+
let _jobTotal = 0;
|
|
7
23
|
|
|
8
|
-
|
|
24
|
+
/* ── Stats ── */
|
|
25
|
+
|
|
26
|
+
function setStatsPeriod(period) {
|
|
27
|
+
_statsPeriod = period;
|
|
28
|
+
document.querySelectorAll('.stats-period-btn').forEach(btn => {
|
|
29
|
+
btn.classList.toggle('active', btn.dataset.period === period);
|
|
30
|
+
});
|
|
31
|
+
fetchStats();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function fetchStats() {
|
|
35
|
+
try {
|
|
36
|
+
const data = await apiFetch(`/api/stats?period=${_statsPeriod}`);
|
|
37
|
+
renderStats(data);
|
|
38
|
+
} catch { /* 통계 실패는 무시 */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function fetchRegisteredProjects() {
|
|
42
|
+
try {
|
|
43
|
+
const data = await apiFetch('/api/projects');
|
|
44
|
+
_registeredProjects = Array.isArray(data) ? data : [];
|
|
45
|
+
if (_allJobs.length > 0) _renderProjectStrip(_allJobs);
|
|
46
|
+
} catch { _registeredProjects = []; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderStats(data) {
|
|
50
|
+
const jobs = data.jobs || {};
|
|
51
|
+
const total = jobs.total || 0;
|
|
52
|
+
const done = jobs.done || 0;
|
|
53
|
+
const failed = jobs.failed || 0;
|
|
54
|
+
const running = jobs.running || 0;
|
|
55
|
+
|
|
56
|
+
document.getElementById('statTotal').textContent =
|
|
57
|
+
t('stat_jobs_summary').replace('{total}', total).replace('{running}', running);
|
|
58
|
+
|
|
59
|
+
const rate = data.success_rate;
|
|
60
|
+
const el = document.getElementById('statSuccess');
|
|
61
|
+
if (rate != null) {
|
|
62
|
+
const pct = (rate * 100).toFixed(1);
|
|
63
|
+
el.textContent = t('stat_success').replace('{pct}', pct);
|
|
64
|
+
el.style.color = rate >= 0.8 ? '' : 'var(--danger, #ef4444)';
|
|
65
|
+
} else {
|
|
66
|
+
el.textContent = '-';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const avg = data.duration?.avg_ms;
|
|
70
|
+
if (avg != null) {
|
|
71
|
+
const sec = avg / 1000;
|
|
72
|
+
document.getElementById('statDuration').textContent =
|
|
73
|
+
sec >= 60 ? `avg ${(sec / 60).toFixed(1)}m` : `avg ${sec.toFixed(1)}s`;
|
|
74
|
+
} else {
|
|
75
|
+
document.getElementById('statDuration').textContent = '-';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toggleJobListCollapse() {
|
|
80
|
+
_jobListCollapsed = !_jobListCollapsed;
|
|
81
|
+
localStorage.setItem('jobListCollapsed', _jobListCollapsed ? '1' : '0');
|
|
82
|
+
_applyJobListCollapse();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _applyJobListCollapse() {
|
|
86
|
+
const wrap = document.getElementById('jobTableWrap');
|
|
87
|
+
const filterBar = document.getElementById('jobFilterBar');
|
|
88
|
+
const strip = document.getElementById('projectStrip');
|
|
89
|
+
const statsBar = document.getElementById('statsBar');
|
|
90
|
+
const detail = document.getElementById('projectDetail');
|
|
91
|
+
const btn = document.getElementById('btnCollapseJobs');
|
|
92
|
+
if (wrap) wrap.style.display = _jobListCollapsed ? 'none' : '';
|
|
93
|
+
if (filterBar) filterBar.style.display = _jobListCollapsed ? 'none' : '';
|
|
94
|
+
if (strip) strip.style.display = _jobListCollapsed ? 'none' : '';
|
|
95
|
+
if (statsBar) statsBar.style.display = _jobListCollapsed ? 'none' : '';
|
|
96
|
+
if (detail) detail.style.display = _jobListCollapsed ? 'none' : (_selectedProject ? '' : 'none');
|
|
97
|
+
if (btn) btn.classList.toggle('collapsed', _jobListCollapsed);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function setJobFilter(status) {
|
|
101
|
+
_jobFilterStatus = status;
|
|
102
|
+
document.querySelectorAll('.job-filter-btn').forEach(btn => {
|
|
103
|
+
btn.classList.toggle('active', btn.dataset.filter === status);
|
|
104
|
+
});
|
|
105
|
+
applyJobFilters();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyJobFilters() {
|
|
109
|
+
_jobSearchQuery = (document.getElementById('jobSearchInput')?.value || '').toLowerCase().trim();
|
|
110
|
+
renderJobs(_allJobs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function setJobProjectFilter(project) {
|
|
114
|
+
_jobFilterProject = project;
|
|
115
|
+
const sel = document.getElementById('jobProjectSelect');
|
|
116
|
+
if (sel) sel.value = project;
|
|
117
|
+
|
|
118
|
+
if (project === 'all') {
|
|
119
|
+
_selectedProject = null;
|
|
120
|
+
_selectedProjectInfo = null;
|
|
121
|
+
_hideProjectDetail();
|
|
122
|
+
} else {
|
|
123
|
+
const projects = _extractProjects(_allJobs);
|
|
124
|
+
_selectedProject = projects.find(p => p.name === project) || null;
|
|
125
|
+
_selectedProjectInfo = null;
|
|
126
|
+
_showProjectDetail();
|
|
127
|
+
if (_selectedProject?.registered && _selectedProject.projectId) {
|
|
128
|
+
_fetchProjectInfo(_selectedProject.projectId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
applyJobFilters();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _extractProjects(jobs) {
|
|
136
|
+
const map = {};
|
|
137
|
+
|
|
138
|
+
// 1) 등록된 프로젝트를 먼저 삽입 (이름·경로 우��� 사용)
|
|
139
|
+
for (const rp of _registeredProjects) {
|
|
140
|
+
const name = rp.name || formatCwd(rp.path);
|
|
141
|
+
const js = rp.job_stats || {};
|
|
142
|
+
map[name] = {
|
|
143
|
+
name,
|
|
144
|
+
cwd: rp.path,
|
|
145
|
+
projectId: rp.id,
|
|
146
|
+
registered: true,
|
|
147
|
+
count: js.total || 0,
|
|
148
|
+
running: js.running || 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 2) job cwd에서 추출한 프로젝트 병합 — 등록 프로젝트와 경로가 같으면 통합
|
|
153
|
+
for (const job of jobs) {
|
|
154
|
+
const cwdName = formatCwd(job.cwd);
|
|
155
|
+
if (!cwdName || cwdName === '-') continue;
|
|
156
|
+
|
|
157
|
+
// 등록 프로젝트 중 같은 경로인지 확인
|
|
158
|
+
const matchKey = Object.keys(map).find(k => {
|
|
159
|
+
const mp = map[k];
|
|
160
|
+
return mp.cwd && job.cwd && _normPath(mp.cwd) === _normPath(job.cwd);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (matchKey) {
|
|
164
|
+
// 등록 프로젝트와 매칭 — job 단위 카운트는 이미 job_stats에 있으므로 skip
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 등록되지 않은 ad-hoc 프로젝트
|
|
169
|
+
if (!map[cwdName]) map[cwdName] = { name: cwdName, cwd: job.cwd, count: 0, running: 0, registered: false };
|
|
170
|
+
map[cwdName].count++;
|
|
171
|
+
if (job.status === 'running') map[cwdName].running++;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 등록 프로젝트 우선, 그 안에서 count 역순
|
|
175
|
+
return Object.values(map).sort((a, b) => {
|
|
176
|
+
if (a.registered !== b.registered) return a.registered ? -1 : 1;
|
|
177
|
+
return b.count - a.count;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _normPath(p) {
|
|
182
|
+
if (!p) return '';
|
|
183
|
+
return p.replace(/\/+$/, '');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _updateProjectDropdown(jobs) {
|
|
187
|
+
const sel = document.getElementById('jobProjectSelect');
|
|
188
|
+
if (!sel) return;
|
|
189
|
+
const projects = _extractProjects(jobs);
|
|
190
|
+
const prev = sel.value;
|
|
191
|
+
sel.innerHTML = `<option value="all">${t('all_projects')} (${jobs.length})</option>`;
|
|
192
|
+
for (const p of projects) {
|
|
193
|
+
const label = p.name + ` (${p.count})`;
|
|
194
|
+
sel.innerHTML += `<option value="${escapeHtml(p.name)}">${escapeHtml(label)}</option>`;
|
|
195
|
+
}
|
|
196
|
+
sel.value = (prev && projects.some(p => p.name === prev)) ? prev : 'all';
|
|
197
|
+
_jobFilterProject = sel.value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _renderProjectStrip(jobs) {
|
|
201
|
+
const container = document.getElementById('projectStrip');
|
|
202
|
+
if (!container) return;
|
|
203
|
+
if (_jobListCollapsed) { container.style.display = 'none'; return; }
|
|
204
|
+
|
|
205
|
+
const projects = _extractProjects(jobs);
|
|
206
|
+
const dropdown = document.querySelector('.job-project-filter');
|
|
207
|
+
|
|
208
|
+
// 등록 프로젝트가 있으면 항상 스트립 표시
|
|
209
|
+
const hasRegistered = _registeredProjects.length > 0;
|
|
210
|
+
if (projects.length <= 1 && !hasRegistered) {
|
|
211
|
+
container.style.display = 'none';
|
|
212
|
+
if (dropdown) dropdown.style.display = projects.length > 0 ? 'flex' : 'none';
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
container.style.display = '';
|
|
216
|
+
if (dropdown) dropdown.style.display = 'none';
|
|
217
|
+
|
|
218
|
+
// 프로젝트별 상태 카운트 (job 데이터에서 실시간 집계)
|
|
219
|
+
const sc = {};
|
|
220
|
+
for (const job of jobs) {
|
|
221
|
+
const name = _resolveProjectName(job.cwd);
|
|
222
|
+
if (!sc[name]) sc[name] = { running: 0, done: 0, failed: 0 };
|
|
223
|
+
const s = job.status;
|
|
224
|
+
if (s === 'running') sc[name].running++;
|
|
225
|
+
else if (s === 'done') sc[name].done++;
|
|
226
|
+
else if (s === 'failed') sc[name].failed++;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let html = `<button class="project-chip${_jobFilterProject === 'all' ? ' active' : ''}" onclick="setJobProjectFilter('all')">
|
|
230
|
+
<svg class="pchip-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
231
|
+
<span class="pchip-name">${t('all_projects')}</span>
|
|
232
|
+
<span class="pchip-count">${jobs.length}</span>
|
|
233
|
+
</button>`;
|
|
234
|
+
|
|
235
|
+
for (const p of projects) {
|
|
236
|
+
const active = _jobFilterProject === p.name ? ' active' : '';
|
|
237
|
+
const dot = p.running > 0 ? '<span class="pchip-dot"></span>' : '';
|
|
238
|
+
const s = sc[p.name] || {};
|
|
239
|
+
let stats = '';
|
|
240
|
+
if (s.running > 0) stats += `<span class="pchip-stat pchip-stat-running">${s.running}</span>`;
|
|
241
|
+
if (s.done > 0) stats += `<span class="pchip-stat pchip-stat-done">${s.done}</span>`;
|
|
242
|
+
if (s.failed > 0) stats += `<span class="pchip-stat pchip-stat-failed">${s.failed}</span>`;
|
|
243
|
+
|
|
244
|
+
const regBadge = p.registered ? '<span class="pchip-reg"></span>' : '';
|
|
245
|
+
|
|
246
|
+
html += `<button class="project-chip${active}${p.registered ? ' registered' : ''}" onclick="setJobProjectFilter('${escapeHtml(escapeJsStr(p.name))}')" title="${escapeHtml(p.cwd)}">
|
|
247
|
+
${dot}${regBadge}
|
|
248
|
+
<svg class="pchip-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
249
|
+
<span class="pchip-name">${escapeHtml(p.name)}</span>
|
|
250
|
+
<span class="pchip-count">${p.count}</span>
|
|
251
|
+
${stats}
|
|
252
|
+
</button>`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
container.innerHTML = html;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** job의 cwd를 등록 프로젝트 이름으로 resolve한다. 매칭 실패 시 formatCwd 사용. */
|
|
259
|
+
function _resolveProjectName(cwd) {
|
|
260
|
+
if (!cwd) return '-';
|
|
261
|
+
const norm = _normPath(cwd);
|
|
262
|
+
for (const rp of _registeredProjects) {
|
|
263
|
+
if (_normPath(rp.path) === norm) return rp.name || formatCwd(rp.path);
|
|
264
|
+
}
|
|
265
|
+
return formatCwd(cwd);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ── Project Detail Panel ── */
|
|
269
|
+
|
|
270
|
+
async function _fetchProjectInfo(projectId) {
|
|
271
|
+
try {
|
|
272
|
+
const data = await apiFetch(`/api/projects/${projectId}`);
|
|
273
|
+
_selectedProjectInfo = data;
|
|
274
|
+
_showProjectDetail();
|
|
275
|
+
} catch { /* silent */ }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function _showProjectDetail() {
|
|
279
|
+
const container = document.getElementById('projectDetail');
|
|
280
|
+
if (!container || !_selectedProject) { _hideProjectDetail(); return; }
|
|
281
|
+
|
|
282
|
+
const projectJobs = _allJobs.filter(j => _resolveProjectName(j.cwd) === _selectedProject.name);
|
|
283
|
+
|
|
284
|
+
let running = 0, done = 0, failed = 0, totalDuration = 0, durCount = 0;
|
|
285
|
+
for (const j of projectJobs) {
|
|
286
|
+
if (j.status === 'running') running++;
|
|
287
|
+
else if (j.status === 'done') done++;
|
|
288
|
+
else if (j.status === 'failed') failed++;
|
|
289
|
+
if (j.duration_ms != null) { totalDuration += j.duration_ms; durCount++; }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const completed = done + failed;
|
|
293
|
+
const successRate = completed > 0 ? Math.round((done / completed) * 100) : null;
|
|
294
|
+
const avgDuration = durCount > 0 ? totalDuration / durCount / 1000 : null;
|
|
295
|
+
|
|
296
|
+
const info = _selectedProjectInfo;
|
|
297
|
+
const metaItems = [];
|
|
298
|
+
if (_selectedProject.cwd) metaItems.push(`<span class="pd-path-text">${escapeHtml(_selectedProject.cwd)}</span>`);
|
|
299
|
+
if (info?.branch) metaItems.push(`<span class="pd-branch">${escapeHtml(info.branch)}</span>`);
|
|
300
|
+
if (info?.remote) {
|
|
301
|
+
const remote = info.remote.replace(/\.git$/, '').replace(/^https?:\/\//, '').replace(/^git@([^:]+):/, '$1/');
|
|
302
|
+
metaItems.push(`<span class="pd-remote" title="${escapeHtml(info.remote)}">${escapeHtml(remote)}</span>`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let statsHtml = `<div class="pd-stat"><div class="pd-stat-val">${projectJobs.length}</div><div class="pd-stat-label">${escapeHtml(t('pd_total'))}</div></div>`;
|
|
306
|
+
if (running > 0) statsHtml += `<div class="pd-stat pd-running"><div class="pd-stat-val">${running}</div><div class="pd-stat-label">${escapeHtml(t('pd_running'))}</div></div>`;
|
|
307
|
+
statsHtml += `<div class="pd-stat pd-done"><div class="pd-stat-val">${done}</div><div class="pd-stat-label">${escapeHtml(t('pd_done'))}</div></div>`;
|
|
308
|
+
if (failed > 0) statsHtml += `<div class="pd-stat pd-failed"><div class="pd-stat-val">${failed}</div><div class="pd-stat-label">${escapeHtml(t('pd_failed'))}</div></div>`;
|
|
309
|
+
if (avgDuration != null) {
|
|
310
|
+
const durStr = avgDuration < 60 ? `${avgDuration.toFixed(1)}s` : `${(avgDuration / 60).toFixed(1)}m`;
|
|
311
|
+
statsHtml += `<div class="pd-stat"><div class="pd-stat-val">${durStr}</div><div class="pd-stat-label">${escapeHtml(t('pd_avg'))}</div></div>`;
|
|
312
|
+
}
|
|
313
|
+
if (successRate != null) {
|
|
314
|
+
const rateClass = successRate >= 80 ? 'pd-rate-ok' : 'pd-rate-warn';
|
|
315
|
+
statsHtml += `<div class="pd-stat ${rateClass}"><div class="pd-stat-val">${successRate}%</div><div class="pd-stat-label">${escapeHtml(t('pd_success_rate'))}</div></div>`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const regBadge = _selectedProject.registered ? `<span class="pd-badge">${escapeHtml(t('pd_registered'))}</span>` : '';
|
|
319
|
+
const cwdAttr = escapeHtml(escapeJsStr(_selectedProject.cwd || ''));
|
|
320
|
+
|
|
321
|
+
container.innerHTML = `
|
|
322
|
+
<div class="pd-header">
|
|
323
|
+
<div class="pd-title-row">
|
|
324
|
+
<svg class="pd-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
325
|
+
<span class="pd-name">${escapeHtml(_selectedProject.name)}</span>
|
|
326
|
+
${regBadge}
|
|
327
|
+
<div class="pd-actions">
|
|
328
|
+
<button class="btn btn-sm btn-primary" onclick="sendTaskToProject('${cwdAttr}')" title="${escapeHtml(t('pd_send_task_title'))}">
|
|
329
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
330
|
+
${escapeHtml(t('pd_send_task'))}
|
|
331
|
+
</button>
|
|
332
|
+
<button class="pd-close" onclick="setJobProjectFilter('all')" title="${escapeHtml(t('pd_show_all_title'))}">
|
|
333
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
334
|
+
</button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
${metaItems.length ? `<div class="pd-meta">${metaItems.join(' <span class="pd-sep">\u00b7</span> ')}</div>` : ''}
|
|
338
|
+
</div>
|
|
339
|
+
<div class="pd-stats">${statsHtml}</div>
|
|
340
|
+
`;
|
|
341
|
+
container.style.display = '';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function _hideProjectDetail() {
|
|
345
|
+
const container = document.getElementById('projectDetail');
|
|
346
|
+
if (container) { container.style.display = 'none'; container.innerHTML = ''; }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function sendTaskToProject(cwd) {
|
|
350
|
+
if (cwd) {
|
|
351
|
+
addRecentDir(cwd);
|
|
352
|
+
selectRecentDir(cwd, true);
|
|
353
|
+
}
|
|
354
|
+
document.getElementById('promptInput')?.focus();
|
|
355
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* ── Grouped View ── */
|
|
359
|
+
|
|
360
|
+
function toggleJobViewMode() {
|
|
361
|
+
_jobViewMode = _jobViewMode === 'flat' ? 'grouped' : 'flat';
|
|
362
|
+
_jobViewModeManual = true;
|
|
363
|
+
localStorage.setItem('jobViewMode', _jobViewMode);
|
|
364
|
+
localStorage.setItem('jobViewModeManual', '1');
|
|
365
|
+
renderJobs(_allJobs);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 프로젝트별 통계 계산 — duration, success rate */
|
|
369
|
+
function _calcProjectStats(jobs) {
|
|
370
|
+
const stats = {};
|
|
371
|
+
for (const job of jobs) {
|
|
372
|
+
const name = _resolveProjectName(job.cwd);
|
|
373
|
+
const key = (name && name !== '-') ? name : t('pd_other');
|
|
374
|
+
if (!stats[key]) stats[key] = { duration: 0, done: 0, failed: 0, durCount: 0 };
|
|
375
|
+
const s = stats[key];
|
|
376
|
+
if (job.duration_ms != null) { s.duration += job.duration_ms; s.durCount++; }
|
|
377
|
+
if (job.status === 'done') s.done++;
|
|
378
|
+
if (job.status === 'failed') s.failed++;
|
|
379
|
+
}
|
|
380
|
+
return stats;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function _updateViewModeUI() {
|
|
384
|
+
const btn = document.getElementById('btnViewMode');
|
|
385
|
+
if (btn) btn.classList.toggle('active', _jobViewMode === 'grouped');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function toggleGroupCollapse(groupName) {
|
|
389
|
+
_collapsedGroups[groupName] = !_collapsedGroups[groupName];
|
|
390
|
+
localStorage.setItem('collapsedGroups', JSON.stringify(_collapsedGroups));
|
|
391
|
+
renderJobs(_allJobs);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function _renderGroupedView(jobs, tbody) {
|
|
395
|
+
jobs.sort((a, b) => {
|
|
396
|
+
const aR = a.status === 'running' ? 0 : 1;
|
|
397
|
+
const bR = b.status === 'running' ? 0 : 1;
|
|
398
|
+
if (aR !== bR) return aR - bR;
|
|
399
|
+
return (parseInt(b.id || b.job_id || 0)) - (parseInt(a.id || a.job_id || 0));
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const groups = new Map();
|
|
403
|
+
for (const job of jobs) {
|
|
404
|
+
const name = _resolveProjectName(job.cwd);
|
|
405
|
+
const key = (name && name !== '-') ? name : t('pd_other');
|
|
406
|
+
if (!groups.has(key)) groups.set(key, { name: key, cwd: job.cwd, jobs: [] });
|
|
407
|
+
groups.get(key).jobs.push(job);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const sorted = [...groups.values()].sort((a, b) => {
|
|
411
|
+
const aR = a.jobs.some(j => j.status === 'running') ? 0 : 1;
|
|
412
|
+
const bR = b.jobs.some(j => j.status === 'running') ? 0 : 1;
|
|
413
|
+
if (aR !== bR) return aR - bR;
|
|
414
|
+
return b.jobs.length - a.jobs.length;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// 프로젝트별 통계
|
|
418
|
+
const pStats = _calcProjectStats(jobs);
|
|
419
|
+
|
|
420
|
+
// 포커스·expand row 보존
|
|
421
|
+
const activeEl = document.activeElement;
|
|
422
|
+
const focusId = activeEl?.classList.contains('followup-input') ? activeEl.id : null;
|
|
423
|
+
const focusVal = focusId ? activeEl.value : null;
|
|
424
|
+
const focusCur = focusId ? activeEl.selectionStart : null;
|
|
425
|
+
|
|
426
|
+
const savedExpands = new Map();
|
|
427
|
+
for (const tr of [...tbody.querySelectorAll('tr.expand-row')]) {
|
|
428
|
+
savedExpands.set(tr.dataset.jobId, tr);
|
|
429
|
+
tr.remove();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
tbody.innerHTML = '';
|
|
433
|
+
|
|
434
|
+
if (sorted.length === 0) {
|
|
435
|
+
tbody.innerHTML = `<tr data-job-id="__empty__"><td colspan="6" class="empty-state">
|
|
436
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:40px;height:40px;margin-bottom:12px;opacity:0.3;"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
|
|
437
|
+
<div>${t('no_jobs')}</div></td></tr>`;
|
|
438
|
+
document.getElementById('btnDeleteCompleted').style.display = 'none';
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let hasCompleted = false;
|
|
443
|
+
|
|
444
|
+
for (const group of sorted) {
|
|
445
|
+
const collapsed = !!_collapsedGroups[group.name];
|
|
446
|
+
const running = group.jobs.filter(j => j.status === 'running').length;
|
|
447
|
+
const done = group.jobs.filter(j => j.status === 'done').length;
|
|
448
|
+
const failed = group.jobs.filter(j => j.status === 'failed').length;
|
|
449
|
+
if (done > 0 || failed > 0) hasCompleted = true;
|
|
450
|
+
|
|
451
|
+
let statsHtml = '';
|
|
452
|
+
if (running > 0) statsHtml += `<span class="grp-stat grp-stat-running"><span class="grp-dot"></span>${running}</span>`;
|
|
453
|
+
if (done > 0) statsHtml += `<span class="grp-stat grp-stat-done">${done}</span>`;
|
|
454
|
+
if (failed > 0) statsHtml += `<span class="grp-stat grp-stat-failed">${failed}</span>`;
|
|
455
|
+
|
|
456
|
+
// 프로젝트별 메트릭 (소요시간, 성공률)
|
|
457
|
+
const ps = pStats[group.name] || {};
|
|
458
|
+
let metaHtml = '';
|
|
459
|
+
if (ps.durCount > 0) {
|
|
460
|
+
const avg = ps.duration / ps.durCount / 1000;
|
|
461
|
+
metaHtml += `<span class="grp-meta grp-meta-dur">avg ${avg < 60 ? avg.toFixed(1) + 's' : (avg / 60).toFixed(1) + 'm'}</span>`;
|
|
462
|
+
}
|
|
463
|
+
const completed = ps.done + ps.failed;
|
|
464
|
+
if (completed > 0) {
|
|
465
|
+
const rate = Math.round((ps.done / completed) * 100);
|
|
466
|
+
const rateClass = rate >= 80 ? 'grp-meta-rate-ok' : 'grp-meta-rate-warn';
|
|
467
|
+
metaHtml += `<span class="grp-meta ${rateClass}">${rate}%</span>`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const hdr = document.createElement('tr');
|
|
471
|
+
hdr.className = 'job-group-row';
|
|
472
|
+
hdr.dataset.jobId = `__group__${group.name}`;
|
|
473
|
+
hdr.setAttribute('onclick', `toggleGroupCollapse('${escapeJsStr(group.name)}')`);
|
|
474
|
+
hdr.innerHTML = `<td colspan="6" class="job-group-cell"><div class="job-group-content">
|
|
475
|
+
<svg class="job-group-chevron${collapsed ? ' collapsed' : ''}" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
476
|
+
<svg class="job-group-folder" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
477
|
+
<span class="job-group-name">${escapeHtml(group.name)}</span>
|
|
478
|
+
<span class="job-group-count">${group.jobs.length}</span>
|
|
479
|
+
<div class="job-group-stats">${statsHtml}</div>
|
|
480
|
+
${metaHtml ? `<div class="job-group-meta">${metaHtml}</div>` : ''}
|
|
481
|
+
</div></td>`;
|
|
482
|
+
tbody.appendChild(hdr);
|
|
483
|
+
|
|
484
|
+
if (collapsed) continue;
|
|
485
|
+
|
|
486
|
+
for (const job of group.jobs) {
|
|
487
|
+
const id = job.id || job.job_id || '-';
|
|
488
|
+
const isExpanded = expandedJobId === id;
|
|
489
|
+
if (streamState[id]) streamState[id].jobData = job;
|
|
490
|
+
|
|
491
|
+
const tr = document.createElement('tr');
|
|
492
|
+
tr.dataset.jobId = id;
|
|
493
|
+
tr.className = `job-group-item${isExpanded ? ' expanded' : ''}`;
|
|
494
|
+
tr.setAttribute('onclick', `toggleJobExpand('${escapeHtml(id)}')`);
|
|
495
|
+
tr.innerHTML = _buildJobRowCells(id, job);
|
|
496
|
+
tbody.appendChild(tr);
|
|
497
|
+
|
|
498
|
+
if (isExpanded) {
|
|
499
|
+
const eKey = id + '__expand';
|
|
500
|
+
if (savedExpands.has(eKey)) {
|
|
501
|
+
tbody.appendChild(savedExpands.get(eKey));
|
|
502
|
+
savedExpands.delete(eKey);
|
|
503
|
+
if (job.session_id) {
|
|
504
|
+
const panel = document.getElementById(`streamPanel-${id}`);
|
|
505
|
+
if (panel) panel.dataset.sessionId = job.session_id;
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
const eTr = document.createElement('tr');
|
|
509
|
+
eTr.className = 'expand-row';
|
|
510
|
+
eTr.dataset.jobId = eKey;
|
|
511
|
+
eTr.innerHTML = _buildExpandRowHtml(id, job);
|
|
512
|
+
tbody.appendChild(eTr);
|
|
513
|
+
initStream(id, job);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (job.status === 'running' && !isExpanded) {
|
|
518
|
+
if (!streamState[id]) {
|
|
519
|
+
streamState[id] = { offset: 0, timer: null, done: false, jobData: job, events: [], renderedCount: 0, _initTime: Date.now(), _lastEventTime: Date.now() };
|
|
520
|
+
}
|
|
521
|
+
if (!streamState[id].timer) initStream(id, job);
|
|
522
|
+
const pvTr = document.createElement('tr');
|
|
523
|
+
pvTr.className = 'preview-row';
|
|
524
|
+
pvTr.dataset.jobId = id + '__preview';
|
|
525
|
+
pvTr.innerHTML = `<td colspan="6"><div class="job-preview" id="jobPreview-${escapeHtml(id)}"><span class="preview-text">${escapeHtml(t('stream_preview_wait'))}</span></div></td>`;
|
|
526
|
+
tbody.appendChild(pvTr);
|
|
527
|
+
updateJobPreview(id);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
document.getElementById('btnDeleteCompleted').style.display = hasCompleted ? 'inline-flex' : 'none';
|
|
533
|
+
|
|
534
|
+
// 포커스 복원
|
|
535
|
+
if (focusId) {
|
|
536
|
+
const el = document.getElementById(focusId);
|
|
537
|
+
if (el) { el.value = focusVal || ''; el.focus(); if (focusCur !== null) el.setSelectionRange(focusCur, focusCur); }
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function filterJobs(jobs) {
|
|
542
|
+
return jobs.filter(job => {
|
|
543
|
+
if (_jobFilterStatus !== 'all' && job.status !== _jobFilterStatus) return false;
|
|
544
|
+
if (_jobFilterProject !== 'all' && _resolveProjectName(job.cwd) !== _jobFilterProject) return false;
|
|
545
|
+
if (_jobSearchQuery && !(job.prompt || '').toLowerCase().includes(_jobSearchQuery)) return false;
|
|
546
|
+
return true;
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const ZOMBIE_THRESHOLD_MS = 5 * 60 * 1000; // 5분간 스트림 데이터 없으면 좀비 의심
|
|
551
|
+
|
|
552
|
+
function statusBadgeHtml(status, jobId, job) {
|
|
9
553
|
const s = (status || 'unknown').toLowerCase();
|
|
10
554
|
const labels = { running: t('status_running'), done: t('status_done'), failed: t('status_failed'), pending: t('status_pending') };
|
|
11
555
|
const cls = { running: 'badge-running', done: 'badge-done', failed: 'badge-failed', pending: 'badge-pending' };
|
|
12
|
-
|
|
556
|
+
let badge = `<span class="badge ${cls[s] || 'badge-pending'}">${labels[s] || s}</span>`;
|
|
557
|
+
if (s === 'running' && jobId && isZombieJob(jobId)) {
|
|
558
|
+
badge += ` <span class="badge badge-zombie" title="${escapeHtml(t('job_zombie_title'))}">${escapeHtml(t('job_zombie'))}</span>`;
|
|
559
|
+
}
|
|
560
|
+
if (job && (s === 'done' || s === 'failed') && job.duration_ms != null) {
|
|
561
|
+
const info = formatDuration(job.duration_ms);
|
|
562
|
+
if (info) badge += `<span class="job-meta-info">${escapeHtml(info)}</span>`;
|
|
563
|
+
}
|
|
564
|
+
// 의존성 뱃지
|
|
565
|
+
if (job && job.depends_on && job.depends_on.length > 0) {
|
|
566
|
+
const depIds = job.depends_on.map(d => '#' + d).join(', ');
|
|
567
|
+
badge += ` <span class="badge badge-dep" title="${escapeHtml(t('job_dep_title').replace('{deps}', depIds))}">⛓ ${escapeHtml(depIds)}</span>`;
|
|
568
|
+
}
|
|
569
|
+
return badge;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function isZombieJob(jobId) {
|
|
573
|
+
const state = streamState[jobId];
|
|
574
|
+
if (!state) return false;
|
|
575
|
+
const lastEventTime = state._lastEventTime || state._initTime;
|
|
576
|
+
if (!lastEventTime) return false;
|
|
577
|
+
return (Date.now() - lastEventTime) > ZOMBIE_THRESHOLD_MS;
|
|
13
578
|
}
|
|
14
579
|
|
|
15
580
|
function jobActionsHtml(id, status, sessionId, cwd) {
|
|
16
581
|
const isRunning = status === 'running';
|
|
17
|
-
const escapedCwd = escapeHtml(cwd || '');
|
|
582
|
+
const escapedCwd = escapeHtml(escapeJsStr(cwd || ''));
|
|
18
583
|
let btns = '';
|
|
19
584
|
if (!isRunning) {
|
|
20
|
-
btns += `<button class="btn-retry-job" onclick="event.stopPropagation(); retryJob('${escapeHtml(id)}')" title="
|
|
585
|
+
btns += `<button class="btn-retry-job" onclick="event.stopPropagation(); retryJob('${escapeHtml(id)}')" title="${escapeHtml(t('job_retry_title'))}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>`;
|
|
21
586
|
}
|
|
22
587
|
if (sessionId) {
|
|
23
|
-
btns += `<button class="btn-continue-job" onclick="event.stopPropagation(); openFollowUp('${escapeHtml(id)}')" title="
|
|
24
|
-
btns += `<button class="btn-fork-job" onclick="event.stopPropagation(); quickForkSession('${escapeHtml(sessionId)}', '${escapedCwd}')" title="
|
|
588
|
+
btns += `<button class="btn-continue-job" onclick="event.stopPropagation(); openFollowUp('${escapeHtml(id)}')" title="${escapeHtml(t('job_resume_title'))}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg></button>`;
|
|
589
|
+
btns += `<button class="btn-fork-job" onclick="event.stopPropagation(); quickForkSession('${escapeHtml(sessionId)}', '${escapedCwd}')" title="${escapeHtml(t('job_fork_title'))}" style="color:var(--yellow);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M6 9v3c0 3.3 2.7 6 6 6h3"/></svg></button>`;
|
|
25
590
|
}
|
|
26
591
|
if (!isRunning) {
|
|
27
|
-
btns += `<button class="btn-delete-job" onclick="event.stopPropagation(); deleteJob('${escapeHtml(id)}')" title="
|
|
592
|
+
btns += `<button class="btn-delete-job" onclick="event.stopPropagation(); deleteJob('${escapeHtml(id)}')" title="${escapeHtml(t('job_delete_title'))}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>`;
|
|
28
593
|
}
|
|
29
594
|
if (!btns) return '';
|
|
30
595
|
return `<div style="display:flex; align-items:center; gap:4px;">${btns}</div>`;
|
|
31
596
|
}
|
|
32
597
|
|
|
598
|
+
/** job row의 6개 셀 HTML을 생성한다 — flat/grouped 뷰 공용. */
|
|
599
|
+
function _buildJobRowCells(id, job) {
|
|
600
|
+
return `
|
|
601
|
+
<td class="job-id">${escapeHtml(String(id).slice(0, 8))}</td>
|
|
602
|
+
<td>${statusBadgeHtml(job.status, id, job)}</td>
|
|
603
|
+
<td class="prompt-cell" title="${escapeHtml(job.prompt)}">${renderPromptHtml(job.prompt)}</td>
|
|
604
|
+
<td class="job-cwd" title="${escapeHtml(job.cwd || '')}">${escapeHtml(formatCwd(job.cwd))}</td>
|
|
605
|
+
<td class="job-session${job.session_id ? ' clickable' : ''}" title="${escapeHtml(job.session_id || '')}" ${job.session_id ? `onclick="event.stopPropagation(); resumeFromJob('${escapeHtml(escapeJsStr(job.session_id))}', '${escapeHtml(escapeJsStr(truncate(job.prompt, 40)))}', '${escapeHtml(escapeJsStr(job.cwd || ''))}')"` : ''}>${job.session_id ? escapeHtml(job.session_id.slice(0, 8)) : (job.status === 'running' ? '<span style="color:var(--text-muted);font-size:0.7rem;">—</span>' : '-')}</td>
|
|
606
|
+
<td>${jobActionsHtml(id, job.status, job.session_id, job.cwd)}</td>`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** expand row의 inner HTML을 생성한다 — flat/grouped 뷰 공용. */
|
|
610
|
+
function _buildExpandRowHtml(id, job) {
|
|
611
|
+
const sessionId = job.session_id || '';
|
|
612
|
+
const jobCwd = job.cwd || '';
|
|
613
|
+
const eid = escapeHtml(id);
|
|
614
|
+
|
|
615
|
+
let actionsHtml = '';
|
|
616
|
+
if (job.status !== 'running') {
|
|
617
|
+
actionsHtml = `<div class="stream-actions">
|
|
618
|
+
<button class="btn btn-sm" onclick="event.stopPropagation(); retryJob('${eid}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg> ${escapeHtml(t('job_retry'))}</button>
|
|
619
|
+
<button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${eid}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> ${escapeHtml(t('stream_copy_all'))}</button>
|
|
620
|
+
<button class="btn btn-sm" onclick="event.stopPropagation(); toggleCheckpointPanel('${eid}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg> ${escapeHtml(t('checkpoints'))}</button>
|
|
621
|
+
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${eid}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> ${escapeHtml(t('stream_delete_job'))}</button>
|
|
622
|
+
</div>`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
let followupHtml = '';
|
|
626
|
+
if (job.status !== 'running' && sessionId) {
|
|
627
|
+
followupHtml = `<div class="stream-followup">
|
|
628
|
+
<span class="stream-followup-label">${escapeHtml(t('stream_followup_label'))}</span>
|
|
629
|
+
<div class="followup-input-wrap">
|
|
630
|
+
<input type="text" class="followup-input" id="followupInput-${eid}" placeholder="${escapeHtml(t('stream_followup_placeholder'))}" onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${eid}')}" onclick="event.stopPropagation()">
|
|
631
|
+
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${eid}')" style="white-space:nowrap;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> ${escapeHtml(t('send'))}</button>
|
|
632
|
+
</div>
|
|
633
|
+
</div>`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return `<td colspan="6">
|
|
637
|
+
<div class="stream-panel" id="streamPanel-${eid}" data-session-id="${escapeHtml(sessionId)}" data-cwd="${escapeHtml(jobCwd)}">
|
|
638
|
+
<div class="stream-content" id="streamContent-${eid}">
|
|
639
|
+
<div class="stream-empty">${escapeHtml(t('stream_loading'))}</div>
|
|
640
|
+
</div>
|
|
641
|
+
${job.status === 'done' ? `<div class="stream-done-banner">✓ ${escapeHtml(t('stream_job_done'))}</div>` : ''}
|
|
642
|
+
${job.status === 'failed' ? `<div class="stream-done-banner failed">✗ ${escapeHtml(t('stream_job_failed'))}</div>` : ''}
|
|
643
|
+
${actionsHtml}
|
|
644
|
+
${followupHtml}
|
|
645
|
+
<div id="ckptPanel-${eid}" class="ckpt-panel" style="display:none"></div>
|
|
646
|
+
</div>
|
|
647
|
+
</td>`;
|
|
648
|
+
}
|
|
649
|
+
|
|
33
650
|
function quickForkSession(sessionId, cwd) {
|
|
34
651
|
_contextMode = 'fork';
|
|
35
652
|
_contextSessionId = sessionId;
|
|
@@ -53,7 +670,7 @@ function resumeFromJob(sessionId, promptHint, cwd) {
|
|
|
53
670
|
addRecentDir(cwd);
|
|
54
671
|
selectRecentDir(cwd, true);
|
|
55
672
|
}
|
|
56
|
-
showToast('
|
|
673
|
+
showToast(t('msg_resume_mode').replace('{sid}', sessionId.slice(0, 8) + '...'));
|
|
57
674
|
document.getElementById('promptInput').focus();
|
|
58
675
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
59
676
|
}
|
|
@@ -153,17 +770,15 @@ async function sendFollowUp(jobId) {
|
|
|
153
770
|
|
|
154
771
|
async function retryJob(jobId) {
|
|
155
772
|
if (_sendLock) return;
|
|
156
|
-
_sendLock = true;
|
|
157
773
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
showToast(t('msg_no_original_prompt'), 'error');
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
774
|
+
const job = _allJobs.find(j => String(j.id || j.job_id) === String(jobId));
|
|
775
|
+
if (!job || !job.prompt) {
|
|
776
|
+
showToast(t('msg_no_original_prompt'), 'error');
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
166
779
|
|
|
780
|
+
_sendLock = true;
|
|
781
|
+
try {
|
|
167
782
|
const body = { prompt: job.prompt };
|
|
168
783
|
if (job.cwd) body.cwd = job.cwd;
|
|
169
784
|
|
|
@@ -177,23 +792,123 @@ async function retryJob(jobId) {
|
|
|
177
792
|
}
|
|
178
793
|
}
|
|
179
794
|
|
|
180
|
-
|
|
795
|
+
let _fetchJobsTimer = null;
|
|
796
|
+
let _fetchJobsInFlight = false;
|
|
797
|
+
|
|
798
|
+
async function _fetchJobsCore() {
|
|
799
|
+
if (_fetchJobsInFlight) return;
|
|
800
|
+
_fetchJobsInFlight = true;
|
|
181
801
|
try {
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
802
|
+
const params = new URLSearchParams({ page: _jobPage, limit: _jobLimit });
|
|
803
|
+
const data = await apiFetch(`/api/jobs?${params}`);
|
|
804
|
+
if (Array.isArray(data)) {
|
|
805
|
+
_jobTotal = data.length;
|
|
806
|
+
_jobPages = 1;
|
|
807
|
+
_jobPage = 1;
|
|
808
|
+
renderJobs(data);
|
|
809
|
+
} else {
|
|
810
|
+
_jobTotal = data.total || 0;
|
|
811
|
+
_jobPages = data.pages || 1;
|
|
812
|
+
_jobPage = data.page || 1;
|
|
813
|
+
renderJobs(data.jobs || []);
|
|
814
|
+
}
|
|
815
|
+
_renderJobPagination();
|
|
185
816
|
} catch {
|
|
186
817
|
// silent fail for polling
|
|
818
|
+
} finally {
|
|
819
|
+
_fetchJobsInFlight = false;
|
|
187
820
|
}
|
|
188
821
|
}
|
|
189
822
|
|
|
823
|
+
function fetchJobs() {
|
|
824
|
+
if (_fetchJobsTimer) clearTimeout(_fetchJobsTimer);
|
|
825
|
+
_fetchJobsTimer = setTimeout(_fetchJobsCore, 300);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function goJobPage(page) {
|
|
829
|
+
_jobPage = Math.max(1, Math.min(page, _jobPages));
|
|
830
|
+
fetchJobs();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function _renderJobPagination() {
|
|
834
|
+
let container = document.getElementById('jobPagination');
|
|
835
|
+
if (!container) return;
|
|
836
|
+
if (_jobPages <= 1) {
|
|
837
|
+
container.innerHTML = '';
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const prev = _jobPage > 1;
|
|
841
|
+
const next = _jobPage < _jobPages;
|
|
842
|
+
let html = '<div class="job-pagination">';
|
|
843
|
+
html += `<button class="pg-btn" ${prev ? '' : 'disabled'} onclick="goJobPage(${_jobPage - 1})">«</button>`;
|
|
844
|
+
|
|
845
|
+
const range = _buildPageRange(_jobPage, _jobPages);
|
|
846
|
+
for (const p of range) {
|
|
847
|
+
if (p === '...') {
|
|
848
|
+
html += '<span class="pg-ellipsis">…</span>';
|
|
849
|
+
} else {
|
|
850
|
+
html += `<button class="pg-btn${p === _jobPage ? ' active' : ''}" onclick="goJobPage(${p})">${p}</button>`;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
html += `<button class="pg-btn" ${next ? '' : 'disabled'} onclick="goJobPage(${_jobPage + 1})">»</button>`;
|
|
855
|
+
html += `<span class="pg-info">${_jobPage}/${_jobPages} (${_jobTotal})</span>`;
|
|
856
|
+
html += '</div>';
|
|
857
|
+
container.innerHTML = html;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function _buildPageRange(current, total) {
|
|
861
|
+
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
|
862
|
+
const pages = [];
|
|
863
|
+
pages.push(1);
|
|
864
|
+
if (current > 3) pages.push('...');
|
|
865
|
+
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
|
|
866
|
+
pages.push(i);
|
|
867
|
+
}
|
|
868
|
+
if (current < total - 2) pages.push('...');
|
|
869
|
+
pages.push(total);
|
|
870
|
+
return pages;
|
|
871
|
+
}
|
|
872
|
+
|
|
190
873
|
function renderJobs(jobs) {
|
|
874
|
+
_allJobs = jobs;
|
|
875
|
+
_updateProjectDropdown(jobs);
|
|
876
|
+
_renderProjectStrip(jobs);
|
|
877
|
+
if (_selectedProject) _showProjectDetail();
|
|
878
|
+
const filtered = filterJobs(jobs);
|
|
191
879
|
const tbody = document.getElementById('jobTableBody');
|
|
192
880
|
const countEl = document.getElementById('jobCount');
|
|
193
|
-
|
|
881
|
+
const totalCount = _jobTotal || jobs.length;
|
|
882
|
+
const isFiltered = _jobFilterStatus !== 'all' || _jobFilterProject !== 'all' || _jobSearchQuery;
|
|
883
|
+
countEl.textContent = totalCount > 0
|
|
884
|
+
? isFiltered ? `(${t('job_count_filtered').replace('{filtered}', filtered.length).replace('{total}', totalCount)})` : `(${t('job_count').replace('{n}', totalCount)})`
|
|
885
|
+
: '';
|
|
886
|
+
|
|
887
|
+
// 프로젝트 2개 이상 + 사용자가 수동 전환한 적 없으면 자동 grouped
|
|
888
|
+
if (!_jobViewModeManual) {
|
|
889
|
+
const projectCount = _extractProjects(jobs).length;
|
|
890
|
+
if (projectCount >= 2 && _jobViewMode !== 'grouped') {
|
|
891
|
+
_jobViewMode = 'grouped';
|
|
892
|
+
localStorage.setItem('jobViewMode', 'grouped');
|
|
893
|
+
} else if (projectCount < 2 && _jobViewMode === 'grouped') {
|
|
894
|
+
_jobViewMode = 'flat';
|
|
895
|
+
localStorage.setItem('jobViewMode', 'flat');
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
_updateViewModeUI();
|
|
900
|
+
const thead = tbody.closest('table')?.querySelector('thead');
|
|
901
|
+
if (_jobViewMode === 'grouped') {
|
|
902
|
+
if (thead) thead.style.display = 'none';
|
|
903
|
+
_renderGroupedView(filtered, tbody);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (thead) thead.style.display = '';
|
|
907
|
+
|
|
908
|
+
jobs = filtered;
|
|
194
909
|
|
|
195
910
|
if (jobs.length === 0) {
|
|
196
|
-
tbody.innerHTML = `<tr data-job-id="__empty__"><td colspan="
|
|
911
|
+
tbody.innerHTML = `<tr data-job-id="__empty__"><td colspan="6" class="empty-state">
|
|
197
912
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:40px;height:40px;margin-bottom:12px;opacity:0.3;"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
|
|
198
913
|
<div>${t('no_jobs')}</div>
|
|
199
914
|
</td></tr>`;
|
|
@@ -236,8 +951,8 @@ function renderJobs(jobs) {
|
|
|
236
951
|
|
|
237
952
|
if (existing && !existing.classList.contains('expand-row')) {
|
|
238
953
|
const cells = existing.querySelectorAll('td');
|
|
239
|
-
if (cells.length >=
|
|
240
|
-
const newStatus = statusBadgeHtml(job.status);
|
|
954
|
+
if (cells.length >= 6) {
|
|
955
|
+
const newStatus = statusBadgeHtml(job.status, id, job);
|
|
241
956
|
if (cells[1].innerHTML !== newStatus) cells[1].innerHTML = newStatus;
|
|
242
957
|
const newCwd = escapeHtml(formatCwd(job.cwd));
|
|
243
958
|
if (cells[3].innerHTML !== newCwd) {
|
|
@@ -250,12 +965,12 @@ function renderJobs(jobs) {
|
|
|
250
965
|
if (job.session_id) {
|
|
251
966
|
cells[4].className = 'job-session clickable';
|
|
252
967
|
cells[4].title = job.session_id;
|
|
253
|
-
cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${
|
|
968
|
+
cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeJsStr(job.session_id)}', '${escapeJsStr(truncate(job.prompt, 40))}', '${escapeJsStr(job.cwd || '')}')`);
|
|
254
969
|
}
|
|
255
970
|
}
|
|
256
971
|
const newActions = jobActionsHtml(id, job.status, job.session_id, job.cwd);
|
|
257
|
-
if (cells[
|
|
258
|
-
cells[
|
|
972
|
+
if (cells[5].innerHTML !== newActions) {
|
|
973
|
+
cells[5].innerHTML = newActions;
|
|
259
974
|
}
|
|
260
975
|
}
|
|
261
976
|
existing.className = isExpanded ? 'expanded' : '';
|
|
@@ -265,14 +980,7 @@ function renderJobs(jobs) {
|
|
|
265
980
|
tr.dataset.jobId = id;
|
|
266
981
|
tr.className = isExpanded ? 'expanded' : '';
|
|
267
982
|
tr.setAttribute('onclick', `toggleJobExpand('${escapeHtml(id)}')`);
|
|
268
|
-
tr.innerHTML =
|
|
269
|
-
<td class="job-id">${escapeHtml(String(id).slice(0, 8))}</td>
|
|
270
|
-
<td>${statusBadgeHtml(job.status)}</td>
|
|
271
|
-
<td class="prompt-cell" title="${escapeHtml(job.prompt)}">${renderPromptHtml(job.prompt)}</td>
|
|
272
|
-
<td class="job-cwd" title="${escapeHtml(job.cwd || '')}">${escapeHtml(formatCwd(job.cwd))}</td>
|
|
273
|
-
<td class="job-session${job.session_id ? ' clickable' : ''}" title="${escapeHtml(job.session_id || '')}" ${job.session_id ? `onclick="event.stopPropagation(); resumeFromJob('${escapeHtml(job.session_id)}', '${escapeHtml(truncate(job.prompt, 40))}', '${escapeHtml(job.cwd || '')}')"` : ''}>${job.session_id ? escapeHtml(job.session_id.slice(0, 8)) : (job.status === 'running' ? '<span style="color:var(--text-muted);font-size:0.7rem;">—</span>' : '-')}</td>
|
|
274
|
-
<td class="job-time">${formatTime(job.created || job.created_at)}</td>
|
|
275
|
-
<td>${jobActionsHtml(id, job.status, job.session_id, job.cwd)}</td>`;
|
|
983
|
+
tr.innerHTML = _buildJobRowCells(id, job);
|
|
276
984
|
tbody.appendChild(tr);
|
|
277
985
|
} else {
|
|
278
986
|
delete existingRows[id];
|
|
@@ -286,26 +994,14 @@ function renderJobs(jobs) {
|
|
|
286
994
|
const expandTr = document.createElement('tr');
|
|
287
995
|
expandTr.className = 'expand-row';
|
|
288
996
|
expandTr.dataset.jobId = expandKey;
|
|
289
|
-
|
|
290
|
-
const jobCwd = job.cwd || '';
|
|
291
|
-
expandTr.innerHTML = `<td colspan="7">
|
|
292
|
-
<div class="stream-panel" id="streamPanel-${escapeHtml(id)}" data-session-id="${escapeHtml(sessionId)}" data-cwd="${escapeHtml(jobCwd)}">
|
|
293
|
-
<div class="stream-content" id="streamContent-${escapeHtml(id)}">
|
|
294
|
-
<div class="stream-empty">스트림 데이터를 불러오는 중...</div>
|
|
295
|
-
</div>
|
|
296
|
-
${job.status === 'done' ? '<div class="stream-done-banner">✓ 작업 완료</div>' : ''}
|
|
297
|
-
${job.status === 'failed' ? '<div class="stream-done-banner failed">✗ 작업 실패</div>' : ''}
|
|
298
|
-
${job.status !== 'running' ? `<div class="stream-actions"><button class="btn btn-sm" onclick="event.stopPropagation(); retryJob('${escapeHtml(id)}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg> 다시 실행</button><button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${escapeHtml(id)}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> 전체 복사</button><button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${escapeHtml(id)}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> 작업 제거</button></div>` : ''}
|
|
299
|
-
${(job.status !== 'running' && sessionId) ? `<div class="stream-followup"><span class="stream-followup-label">이어서</span><div class="followup-input-wrap"><input type="text" class="followup-input" id="followupInput-${escapeHtml(id)}" placeholder="이 세션에 이어서 실행할 명령..." onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${escapeHtml(id)}')}" onclick="event.stopPropagation()"><button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${escapeHtml(id)}')" style="white-space:nowrap;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> 전송</button></div></div>` : ''}
|
|
300
|
-
</div>
|
|
301
|
-
</td>`;
|
|
997
|
+
expandTr.innerHTML = _buildExpandRowHtml(id, job);
|
|
302
998
|
const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
|
|
303
999
|
if (jobRow && jobRow.nextSibling) {
|
|
304
1000
|
tbody.insertBefore(expandTr, jobRow.nextSibling);
|
|
305
1001
|
} else {
|
|
306
1002
|
tbody.appendChild(expandTr);
|
|
307
1003
|
}
|
|
308
|
-
initStream(id);
|
|
1004
|
+
initStream(id, job);
|
|
309
1005
|
} else {
|
|
310
1006
|
delete existingRows[expandKey];
|
|
311
1007
|
}
|
|
@@ -323,13 +1019,13 @@ function renderJobs(jobs) {
|
|
|
323
1019
|
streamState[id] = { offset: 0, timer: null, done: false, jobData: job, events: [], renderedCount: 0 };
|
|
324
1020
|
}
|
|
325
1021
|
if (!streamState[id].timer) {
|
|
326
|
-
initStream(id);
|
|
1022
|
+
initStream(id, job);
|
|
327
1023
|
}
|
|
328
1024
|
if (!existingPreview) {
|
|
329
1025
|
const pvTr = document.createElement('tr');
|
|
330
1026
|
pvTr.className = 'preview-row';
|
|
331
1027
|
pvTr.dataset.jobId = previewKey;
|
|
332
|
-
pvTr.innerHTML = `<td colspan="
|
|
1028
|
+
pvTr.innerHTML = `<td colspan="6"><div class="job-preview" id="jobPreview-${escapeHtml(id)}"><span class="preview-text">${escapeHtml(t('stream_preview_wait'))}</span></div></td>`;
|
|
333
1029
|
const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
|
|
334
1030
|
if (jobRow && jobRow.nextSibling) {
|
|
335
1031
|
tbody.insertBefore(pvTr, jobRow.nextSibling);
|
|
@@ -373,7 +1069,8 @@ function updateJobRowStatus(jobId, status) {
|
|
|
373
1069
|
if (!row || row.classList.contains('expand-row')) return;
|
|
374
1070
|
const cells = row.querySelectorAll('td');
|
|
375
1071
|
if (cells.length >= 2) {
|
|
376
|
-
const
|
|
1072
|
+
const job = _allJobs.find(j => String(j.id || j.job_id) === String(jobId));
|
|
1073
|
+
const newBadge = statusBadgeHtml(status, jobId, job);
|
|
377
1074
|
if (cells[1].innerHTML !== newBadge) cells[1].innerHTML = newBadge;
|
|
378
1075
|
}
|
|
379
1076
|
}
|
|
@@ -396,11 +1093,12 @@ function toggleJobExpand(id) {
|
|
|
396
1093
|
if (prevRow) prevRow.className = '';
|
|
397
1094
|
}
|
|
398
1095
|
expandedJobId = id;
|
|
399
|
-
|
|
1096
|
+
renderJobs(_allJobs);
|
|
400
1097
|
}
|
|
401
1098
|
}
|
|
402
1099
|
|
|
403
1100
|
async function deleteJob(jobId) {
|
|
1101
|
+
if (!confirm(t('confirm_delete_job'))) return;
|
|
404
1102
|
try {
|
|
405
1103
|
await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' });
|
|
406
1104
|
if (streamState[jobId]) {
|
|
@@ -416,6 +1114,7 @@ async function deleteJob(jobId) {
|
|
|
416
1114
|
}
|
|
417
1115
|
|
|
418
1116
|
async function deleteCompletedJobs() {
|
|
1117
|
+
if (!confirm(t('confirm_delete_completed'))) return;
|
|
419
1118
|
try {
|
|
420
1119
|
const data = await apiFetch('/api/jobs', { method: 'DELETE' });
|
|
421
1120
|
const count = data.count || 0;
|
|
@@ -432,3 +1131,140 @@ async function deleteCompletedJobs() {
|
|
|
432
1131
|
showToast(`${t('msg_batch_delete_failed')}: ${err.message}`, 'error');
|
|
433
1132
|
}
|
|
434
1133
|
}
|
|
1134
|
+
|
|
1135
|
+
/* ═══════════════════════════════════════════════
|
|
1136
|
+
Checkpoint Diff Viewer
|
|
1137
|
+
═══════════════════════════════════════════════ */
|
|
1138
|
+
|
|
1139
|
+
let _ckptCache = {}; // jobId → checkpoints array
|
|
1140
|
+
|
|
1141
|
+
async function toggleCheckpointPanel(jobId) {
|
|
1142
|
+
const panel = document.getElementById(`ckptPanel-${jobId}`);
|
|
1143
|
+
if (!panel) return;
|
|
1144
|
+
|
|
1145
|
+
if (panel.style.display !== 'none') {
|
|
1146
|
+
panel.style.display = 'none';
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
panel.style.display = '';
|
|
1150
|
+
panel.innerHTML = `<div class="ckpt-loading">${t('diff_loading')}</div>`;
|
|
1151
|
+
|
|
1152
|
+
try {
|
|
1153
|
+
const checkpoints = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/checkpoints`);
|
|
1154
|
+
_ckptCache[jobId] = checkpoints;
|
|
1155
|
+
renderCheckpointSelector(jobId, checkpoints);
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
panel.innerHTML = `<div class="ckpt-empty">${t('diff_no_checkpoints')}</div>`;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function renderCheckpointSelector(jobId, checkpoints) {
|
|
1162
|
+
const panel = document.getElementById(`ckptPanel-${jobId}`);
|
|
1163
|
+
if (!panel) return;
|
|
1164
|
+
|
|
1165
|
+
if (!checkpoints || checkpoints.length === 0) {
|
|
1166
|
+
panel.innerHTML = `<div class="ckpt-empty">${t('diff_no_checkpoints')}</div>`;
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const optionsHtml = checkpoints.map((c, i) => {
|
|
1171
|
+
const label = `#${c.turn} — ${c.hash.slice(0, 7)} (${c.files_changed} ${t('diff_files')})`;
|
|
1172
|
+
return `<option value="${escapeHtml(c.hash)}"${i === 0 ? ' selected' : ''}>${escapeHtml(label)}</option>`;
|
|
1173
|
+
}).join('');
|
|
1174
|
+
|
|
1175
|
+
const prevDefault = checkpoints.length > 1 ? checkpoints[1].hash : '';
|
|
1176
|
+
const prevOptions = checkpoints.map((c, i) => {
|
|
1177
|
+
const label = `#${c.turn} — ${c.hash.slice(0, 7)}`;
|
|
1178
|
+
return `<option value="${escapeHtml(c.hash)}"${i === 1 ? ' selected' : ''}>${escapeHtml(label)}</option>`;
|
|
1179
|
+
}).join('');
|
|
1180
|
+
|
|
1181
|
+
panel.innerHTML = `
|
|
1182
|
+
<div class="ckpt-selector">
|
|
1183
|
+
<div class="ckpt-select-group">
|
|
1184
|
+
<label>From</label>
|
|
1185
|
+
<select id="ckptFrom-${escapeHtml(jobId)}" onclick="event.stopPropagation()">${prevOptions}</select>
|
|
1186
|
+
</div>
|
|
1187
|
+
<span class="ckpt-arrow">→</span>
|
|
1188
|
+
<div class="ckpt-select-group">
|
|
1189
|
+
<label>To</label>
|
|
1190
|
+
<select id="ckptTo-${escapeHtml(jobId)}" onclick="event.stopPropagation()">${optionsHtml}</select>
|
|
1191
|
+
</div>
|
|
1192
|
+
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); loadDiff('${escapeHtml(jobId)}')">${t('diff_compare')}</button>
|
|
1193
|
+
</div>
|
|
1194
|
+
<div class="ckpt-hint">${t('diff_select_hint')}</div>
|
|
1195
|
+
<div id="diffResult-${escapeHtml(jobId)}" class="diff-result"></div>`;
|
|
1196
|
+
|
|
1197
|
+
// 자동으로 첫 체크포인트의 단독 diff 로드
|
|
1198
|
+
if (checkpoints.length >= 1) {
|
|
1199
|
+
loadSingleDiff(jobId, checkpoints[0].hash);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
async function loadSingleDiff(jobId, hash) {
|
|
1204
|
+
const container = document.getElementById(`diffResult-${jobId}`);
|
|
1205
|
+
if (!container) return;
|
|
1206
|
+
container.innerHTML = `<div class="ckpt-loading">${t('diff_loading')}</div>`;
|
|
1207
|
+
|
|
1208
|
+
try {
|
|
1209
|
+
const data = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/diff?from=${encodeURIComponent(hash)}`);
|
|
1210
|
+
renderDiffResult(container, data);
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
container.innerHTML = `<div class="ckpt-empty">${err.message || t('diff_no_changes')}</div>`;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async function loadDiff(jobId) {
|
|
1217
|
+
const fromEl = document.getElementById(`ckptFrom-${jobId}`);
|
|
1218
|
+
const toEl = document.getElementById(`ckptTo-${jobId}`);
|
|
1219
|
+
if (!fromEl || !toEl) return;
|
|
1220
|
+
|
|
1221
|
+
const container = document.getElementById(`diffResult-${jobId}`);
|
|
1222
|
+
if (!container) return;
|
|
1223
|
+
container.innerHTML = `<div class="ckpt-loading">${t('diff_loading')}</div>`;
|
|
1224
|
+
|
|
1225
|
+
try {
|
|
1226
|
+
const data = await apiFetch(
|
|
1227
|
+
`/api/jobs/${encodeURIComponent(jobId)}/diff?from=${encodeURIComponent(fromEl.value)}&to=${encodeURIComponent(toEl.value)}`
|
|
1228
|
+
);
|
|
1229
|
+
renderDiffResult(container, data);
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
container.innerHTML = `<div class="ckpt-empty">${err.message || t('diff_no_changes')}</div>`;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function renderDiffResult(container, data) {
|
|
1236
|
+
if (!data.files || data.files.length === 0) {
|
|
1237
|
+
container.innerHTML = `<div class="ckpt-empty">${t('diff_no_changes')}</div>`;
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const summary = `<div class="diff-summary">
|
|
1242
|
+
<span class="diff-stat-files">${data.total_files} ${t('diff_files')}</span>
|
|
1243
|
+
<span class="diff-stat-add">+${data.total_additions} ${t('diff_additions')}</span>
|
|
1244
|
+
<span class="diff-stat-del">-${data.total_deletions} ${t('diff_deletions')}</span>
|
|
1245
|
+
</div>`;
|
|
1246
|
+
|
|
1247
|
+
const filesHtml = data.files.map((f, idx) => {
|
|
1248
|
+
const lines = f.chunks.map(line => {
|
|
1249
|
+
const escaped = escapeHtml(line);
|
|
1250
|
+
if (line.startsWith('@@')) return `<div class="diff-line diff-hunk">${escaped}</div>`;
|
|
1251
|
+
if (line.startsWith('+')) return `<div class="diff-line diff-add">${escaped}</div>`;
|
|
1252
|
+
if (line.startsWith('-')) return `<div class="diff-line diff-del">${escaped}</div>`;
|
|
1253
|
+
if (line.startsWith('\\')) return `<div class="diff-line diff-meta">${escaped}</div>`;
|
|
1254
|
+
return `<div class="diff-line">${escaped}</div>`;
|
|
1255
|
+
}).join('');
|
|
1256
|
+
|
|
1257
|
+
return `<div class="diff-file">
|
|
1258
|
+
<div class="diff-file-header" onclick="event.stopPropagation(); this.parentElement.classList.toggle('collapsed')">
|
|
1259
|
+
<span class="diff-file-name">${escapeHtml(f.file)}</span>
|
|
1260
|
+
<span class="diff-file-stats">
|
|
1261
|
+
<span class="diff-stat-add">+${f.additions}</span>
|
|
1262
|
+
<span class="diff-stat-del">-${f.deletions}</span>
|
|
1263
|
+
</span>
|
|
1264
|
+
</div>
|
|
1265
|
+
<div class="diff-file-body"><pre class="diff-code">${lines}</pre></div>
|
|
1266
|
+
</div>`;
|
|
1267
|
+
}).join('');
|
|
1268
|
+
|
|
1269
|
+
container.innerHTML = summary + filesHtml;
|
|
1270
|
+
}
|