@clazic/urban 0.2.5 → 0.2.7

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.
@@ -0,0 +1,503 @@
1
+ import { getJson, postJson } from "../api.js";
2
+ import { qs, escapeHtml } from "../dom.js";
3
+ import { renderPipelineBar } from "../components/pipeline-strip.js";
4
+ import { subscribe } from "../sse.js";
5
+
6
+ let unsubscribeItemStage = null;
7
+ let unsubscribeKordoc = null;
8
+ let unsubscribeKordocInline = null;
9
+ let trackedDocid = null;
10
+ let reloadTimer = null;
11
+ let currentStageLog = null;
12
+
13
+ // kordoc 이벤트 로그 버퍼
14
+ const logBuffer = [];
15
+ const LOG_MAX = 100;
16
+
17
+ // rAF 배칭 상태
18
+ let pendingKordoc = [];
19
+ let rafHandle = null;
20
+
21
+ let unsubscribePipelineStats = null;
22
+
23
+ /**
24
+ * 실패 항목 재시도. block_cache 히트 시 사용자에게 해제 여부 확인 후 force=true 로 재호출.
25
+ */
26
+ async function retryPendingItem(docid, { force = false } = {}) {
27
+ try {
28
+ const res = await fetch(`/api/pending/${encodeURIComponent(docid)}/retry`, {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify({ force }),
32
+ });
33
+ const body = await res.json().catch(() => ({}));
34
+
35
+ if (res.status === 409 && body?.reason === "blocked_by_cache") {
36
+ const msg = [
37
+ `이 항목은 과거 수집에서 차단 학습되었습니다.`,
38
+ ``,
39
+ ` 상태: ${body.status}`,
40
+ body.detail ? ` 사유: ${body.detail}` : null,
41
+ ``,
42
+ `차단 캐시를 해제하고 재시도하시겠습니까?`,
43
+ ].filter(Boolean).join("\n");
44
+ if (window.confirm(msg)) {
45
+ return retryPendingItem(docid, { force: true });
46
+ }
47
+ return { ok: false, cancelled: true };
48
+ }
49
+ if (!res.ok) {
50
+ throw new Error(body?.error || `재시도 실패 (HTTP ${res.status})`);
51
+ }
52
+ return body;
53
+ } catch (error) {
54
+ console.error("retryPendingItem failed", error);
55
+ alert(`재시도 실패: ${error.message}`);
56
+ return { ok: false, error: error.message };
57
+ }
58
+ }
59
+
60
+ function schedulePendingReload() {
61
+ clearTimeout(reloadTimer);
62
+ reloadTimer = setTimeout(() => {
63
+ loadPending().catch((error) => console.error("pending reload failed", error));
64
+ if (trackedDocid) showStageTrack(trackedDocid).catch(() => {});
65
+ }, 180);
66
+ }
67
+
68
+ async function loadPending() {
69
+ const data = await getJson("/api/pending");
70
+ const items = data.items || [];
71
+ const pipeline = qs("#pending-pipeline");
72
+ const list = qs("#pending-list");
73
+ const counts = Object.fromEntries(["waiting", "queued", "downloading", "converting", "extracting", "graph", "wiki", "done"].map((key) => [key, 0]));
74
+ let failedCount = 0;
75
+
76
+ items.forEach((item) => {
77
+ if (item.status in counts) counts[item.status] += 1;
78
+ if (item.status === "failed") failedCount += 1;
79
+ });
80
+
81
+ renderPipelineBar(pipeline, counts, failedCount);
82
+
83
+ if (list) {
84
+ list.innerHTML = items.filter((item) => ["waiting", "queued", "downloading", "converting", "extracting", "graph", "wiki", "failed"].includes(item.status)).map((item) => {
85
+ const isFailed = item.status === "failed";
86
+ const failedMeta = isFailed && item.failed_reason ? `<div class="card-sub" style="color:var(--danger); margin-top:2px" title="${escapeHtml(item.failed_reason)}">실패${item.failed_stage ? `·${escapeHtml(item.failed_stage)}` : ""}: ${escapeHtml(String(item.failed_reason).slice(0, 120))}</div>` : "";
87
+ return `
88
+ <div class="t-row pending-row" data-pending-row="${escapeHtml(item.docid)}">
89
+ <div><input type="checkbox" data-pending-select="${escapeHtml(item.docid)}" /></div>
90
+ <div>
91
+ <div>${escapeHtml(item.display_title || item.title || item.raw_filename || item.docid || "항목")}</div>
92
+ <div class="card-sub">${escapeHtml(item.source || "")}</div>
93
+ ${failedMeta}
94
+ ${item.status === "converting" ? (() => {
95
+ let inner = "", cls = "";
96
+ try {
97
+ const p = item.kordoc_progress ? JSON.parse(item.kordoc_progress) : null;
98
+ if (p?.stage) {
99
+ const label = KORDOC_STAGE_LABELS[p.stage] ?? p.stage;
100
+ const hasPages = typeof p.current === "number" && typeof p.total === "number";
101
+ const hasPct = typeof p.percent === "number";
102
+ const modelHint = (p.stage === "ocr" && p.model) ? ` · ${p.model}` : "";
103
+ if (hasPages || hasPct) {
104
+ const pct = hasPages ? (p.total > 0 ? Math.round((p.current / p.total) * 100) : 0) : p.percent;
105
+ const countLabel = hasPages ? `${p.current}/${p.total}` : `${pct}%`;
106
+ inner = `<div class="kordoc-progress-row"><span>${escapeHtml(label)} ${countLabel}${escapeHtml(modelHint)}</span><div class="kordoc-bar"><div class="kordoc-bar-fill" style="width:${pct}%"></div></div><span class="kordoc-pct">${pct}%</span></div>`;
107
+ cls = "is-progress";
108
+ } else {
109
+ // 진행 정보 없는 단계 → 로딩 바 (is-start는 바 없어 시각적 피드백 없음)
110
+ inner = `<span>▶ ${escapeHtml(label)}${escapeHtml(modelHint)}</span><div class="kordoc-bar"><div class="kordoc-bar-fill"></div></div>`;
111
+ cls = "is-loading";
112
+ }
113
+ } else {
114
+ // kordoc_progress 없음 = probe 이벤트 도착 전 초기 변환 단계
115
+ inner = `<span>변환 준비 중</span><div class="kordoc-bar"><div class="kordoc-bar-fill"></div></div>`;
116
+ cls = "is-loading";
117
+ }
118
+ } catch {}
119
+ const fileTag = item.raw_filename ? `<div class="card-sub" style="margin-bottom:2px;font-size:0.78em;opacity:0.65">${escapeHtml(item.raw_filename)}</div>` : "";
120
+ return `${fileTag}<div class="kordoc-inline-status${cls ? " " + cls : ""}" data-kordoc-docid="${escapeHtml(item.docid)}">${inner}</div>`;
121
+ })() : ""}
122
+ </div>
123
+ <div><span class="chip ${isFailed ? "chip-danger" : ""}">${escapeHtml(item.status || "")}</span></div>
124
+ <div>${item.found_at ? new Date(item.found_at).toLocaleDateString("ko-KR") : (item.created_at ? new Date(item.created_at).toLocaleDateString("ko-KR") : "")}</div>
125
+ <div class="pending-actions">
126
+ <button class="btn sm ghost" data-track-id="${escapeHtml(item.docid)}">상세</button>
127
+ </div>
128
+ <div class="pending-actions">
129
+ ${item.status === "waiting" ? `<button class="btn sm" data-approve-id="${escapeHtml(item.docid)}">승인</button><button class="btn sm danger" data-reject-id="${escapeHtml(item.docid)}">거절</button>` : ""}
130
+ ${isFailed ? `<button class="btn sm" data-retry-id="${escapeHtml(item.docid)}">재시도</button>` : ""}
131
+ </div>
132
+ </div>
133
+ `;}).join("") || '<div class="empty-state">대기 항목 없음</div>';
134
+
135
+ list.querySelectorAll("[data-approve-id]").forEach((button) => button.addEventListener("click", async () => {
136
+ await postJson(`/api/pending/${encodeURIComponent(button.dataset.approveId)}/approve`);
137
+ await loadPending();
138
+ }));
139
+
140
+ list.querySelectorAll("[data-reject-id]").forEach((button) => button.addEventListener("click", async () => {
141
+ await postJson(`/api/pending/${encodeURIComponent(button.dataset.rejectId)}/reject`);
142
+ await loadPending();
143
+ }));
144
+
145
+ list.querySelectorAll("[data-retry-id]").forEach((button) => button.addEventListener("click", async () => {
146
+ const docid = button.dataset.retryId;
147
+ await retryPendingItem(docid, { force: false });
148
+ await loadPending();
149
+ }));
150
+
151
+ list.querySelectorAll("[data-track-id]").forEach((button) => button.addEventListener("click", () => showStageTrack(button.dataset.trackId)));
152
+ }
153
+ }
154
+
155
+ function formatDuration(ms) {
156
+ if (ms == null) return "";
157
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
158
+ }
159
+
160
+ /** item_stage 이벤트: 해당 행의 status chip + kordoc 인디케이터 직접 업데이트 */
161
+ function updateRowStatus(docid, newStatus) {
162
+ const row = document.querySelector(`[data-pending-row="${CSS.escape(docid)}"]`);
163
+ if (!row) return false;
164
+
165
+ // status chip
166
+ const chip = row.querySelector(".chip");
167
+ if (chip) {
168
+ chip.textContent = newStatus;
169
+ chip.className = `chip${newStatus === "failed" ? " chip-danger" : ""}`;
170
+ }
171
+
172
+ // converting 진입 시 kordoc 인디케이터 초기화
173
+ if (newStatus === "converting") {
174
+ let kordocEl = row.querySelector("[data-kordoc-docid]");
175
+ if (!kordocEl) {
176
+ // 아직 div 없음 → 제목 column에 삽입
177
+ const infoCol = row.children[1];
178
+ if (infoCol) {
179
+ kordocEl = document.createElement("div");
180
+ kordocEl.dataset.kordocDocid = docid;
181
+ infoCol.appendChild(kordocEl);
182
+ }
183
+ }
184
+ if (kordocEl && !kordocEl.className.includes("is-progress") && !kordocEl.className.includes("is-done")) {
185
+ kordocEl.innerHTML = `<span>변환 준비 중</span><div class="kordoc-bar"><div class="kordoc-bar-fill"></div></div>`;
186
+ kordocEl.className = "kordoc-inline-status is-loading";
187
+ }
188
+ }
189
+
190
+ return true;
191
+ }
192
+
193
+ /** item_stage done/dismissed: 해당 행 즉시 제거 */
194
+ function removeRowByDocid(docid) {
195
+ document.querySelector(`[data-pending-row="${CSS.escape(docid)}"]`)?.remove();
196
+ }
197
+
198
+ const KORDOC_STAGE_LABELS = { convert: "변환", render: "렌더링", probe: "속도측정", ocr: "OCR", proofread: "교정", merge: "병합" };
199
+
200
+ function updateKordocInline(payload) {
201
+ const { docid, kordocStage, phase, current, total, error, from, to, message, model } = payload;
202
+ const el = document.querySelector(`[data-kordoc-docid="${CSS.escape(docid)}"]`);
203
+ if (!el) return;
204
+
205
+ if (kordocStage === "provider_fallback") {
206
+ el.textContent = `⇢ ${from ?? "?"} → ${to ?? "?"}`;
207
+ el.className = "kordoc-inline-status is-fallback";
208
+ return;
209
+ }
210
+
211
+ const label = KORDOC_STAGE_LABELS[kordocStage] ?? kordocStage;
212
+
213
+ // ocr·proofread는 페이지마다 stage_start가 반복 발생 — start는 무시, progress/done만 처리
214
+ const CYCLE_STAGES = new Set(["ocr", "proofread"]);
215
+ if (phase === "start" && CYCLE_STAGES.has(kordocStage)) {
216
+ // OCR 모델명을 첫 start 이벤트에서 캡처해 progress 표시에 활용
217
+ if (kordocStage === "ocr" && !el.dataset.ocrModel) {
218
+ const modelName = model || message?.match(/\((.+?)\)/)?.[1];
219
+ if (modelName) el.dataset.ocrModel = modelName;
220
+ }
221
+ return;
222
+ }
223
+
224
+ if (phase === "start") {
225
+ // 모든 단계 시작: 로딩 바 표시 (is-start는 바 없어 시각적 피드백 없음)
226
+ el.innerHTML = `<span>▶ ${escapeHtml(label)}</span><div class="kordoc-bar"><div class="kordoc-bar-fill"></div></div>`;
227
+ el.className = "kordoc-inline-status is-loading";
228
+ } else if (phase === "progress") {
229
+ const hasPages = typeof current === "number" && typeof total === "number";
230
+ const pct = payload.percent ?? (hasPages && total > 0 ? Math.round((current / total) * 100) : 0);
231
+ const countLabel = hasPages ? `${current}/${total}` : `${pct}%`;
232
+ // progress 이벤트에서도 model 캡처 (페이지 재로드 후 start 이벤트를 못 받은 경우 대비)
233
+ if (kordocStage === "ocr" && !el.dataset.ocrModel) {
234
+ const modelName = model || message?.match(/\((.+?)\)/)?.[1];
235
+ if (modelName) el.dataset.ocrModel = modelName;
236
+ }
237
+ // render 총 페이지 수 캐시 (done 상태 표시용)
238
+ if (kordocStage === "render" && hasPages) el.dataset.renderTotal = String(total);
239
+ const modelHint = kordocStage === "ocr" && el.dataset.ocrModel ? ` · ${el.dataset.ocrModel}` : "";
240
+ el.innerHTML = `<div class="kordoc-progress-row"><span>${escapeHtml(label)} ${countLabel}${escapeHtml(modelHint)}</span><div class="kordoc-bar"><div class="kordoc-bar-fill" style="width:${pct}%"></div></div><span class="kordoc-pct">${pct}%</span></div>`;
241
+ el.className = "kordoc-inline-status is-progress";
242
+ } else if (phase === "done") {
243
+ const ocrModel = kordocStage === "ocr" && el.dataset.ocrModel ? ` · ${el.dataset.ocrModel}` : "";
244
+ const renderPages = kordocStage === "render" && el.dataset.renderTotal ? ` · ${el.dataset.renderTotal}장` : "";
245
+ el.textContent = `✓ ${label}${ocrModel}${renderPages} ${formatDuration(payload.duration_ms)}`.trimEnd();
246
+ el.className = "kordoc-inline-status is-done";
247
+ } else if (phase === "error") {
248
+ el.textContent = `✗ ${label}: ${error ?? ""}`;
249
+ el.className = "kordoc-inline-status is-error";
250
+ }
251
+ }
252
+
253
+ function appendLogLine({ kind, text }) {
254
+ const ts = new Date().toTimeString().slice(0, 8);
255
+ logBuffer.push({ ts, kind, text });
256
+ if (logBuffer.length > LOG_MAX) logBuffer.shift();
257
+ renderLog();
258
+ }
259
+
260
+ function renderLog() {
261
+ const container = document.getElementById("kordoc-event-log");
262
+ const section = document.getElementById("kordoc-event-log-section");
263
+ if (!container) return;
264
+ if (section) section.hidden = false;
265
+ container.innerHTML = logBuffer.map((e) =>
266
+ `<div class="log-line is-${e.kind}">${escapeHtml(e.ts)} ${escapeHtml(e.text)}</div>`
267
+ ).join("");
268
+ container.scrollTop = container.scrollHeight;
269
+ }
270
+
271
+ function updateKordocPanel(payload) {
272
+ const { kordocStage, phase, current, total, duration_ms, error, from, to, reason, message, model } = payload;
273
+ const section = document.getElementById("kordoc-substages");
274
+ if (section) section.hidden = false;
275
+
276
+ if (kordocStage === "provider_fallback") {
277
+ appendLogLine({ kind: "fallback", text: `⇢ ${from ?? "?"} → ${to ?? "?"} (${reason ?? ""})` });
278
+ return;
279
+ }
280
+
281
+ const li = section?.querySelector(`li[data-sub="${kordocStage}"]`);
282
+ if (!li) return;
283
+ const icon = li.querySelector(".icon");
284
+ const meta = li.querySelector(".meta");
285
+ li.classList.remove("is-start", "is-done", "is-error");
286
+
287
+ if (phase === "start") {
288
+ li.classList.add("is-start");
289
+ if (icon) icon.textContent = "▶";
290
+ if (meta) {
291
+ if (kordocStage === "ocr") {
292
+ // 모델명은 start 첫 발생 시 캡처 (페이지마다 반복 발생하므로 기존 값 유지)
293
+ const modelName = model || message?.match(/\((.+?)\)/)?.[1];
294
+ if (modelName) li.dataset.ocrModel = modelName;
295
+ meta.textContent = li.dataset.ocrModel || "";
296
+ } else {
297
+ meta.textContent = "";
298
+ }
299
+ }
300
+ const modelSuffix = kordocStage === "ocr" && (model || message?.match(/\((.+?)\)/)?.[1])
301
+ ? ` (${model || message.match(/\((.+?)\)/)[1]})` : "";
302
+ appendLogLine({ kind: "start", text: `${kordocStage} start${modelSuffix}` });
303
+ } else if (phase === "progress") {
304
+ if (icon) icon.textContent = "…";
305
+ if (typeof current === "number" && typeof total === "number") {
306
+ if (meta) meta.textContent = `${current}/${total}`;
307
+ const fill = li.querySelector(".progress-fill");
308
+ if (fill) fill.style.width = `${Math.max(0, Math.min(100, (current / total) * 100))}%`;
309
+ // render 총 페이지 수 캐시
310
+ if (kordocStage === "render") li.dataset.renderTotal = String(total);
311
+ }
312
+ } else if (phase === "done") {
313
+ li.classList.add("is-done");
314
+ if (icon) icon.textContent = "✅";
315
+ if (meta) {
316
+ const ocrModel = kordocStage === "ocr" && li.dataset.ocrModel ? `${li.dataset.ocrModel} · ` : "";
317
+ const renderPages = kordocStage === "render" && li.dataset.renderTotal ? `${li.dataset.renderTotal}장 · ` : "";
318
+ meta.textContent = `${ocrModel}${renderPages}${formatDuration(duration_ms)}`;
319
+ }
320
+ appendLogLine({ kind: "done", text: `${kordocStage} done ${formatDuration(duration_ms)}` });
321
+ } else if (phase === "error") {
322
+ li.classList.add("is-error");
323
+ if (icon) icon.textContent = "✗";
324
+ if (meta) meta.textContent = error ?? "";
325
+ appendLogLine({ kind: "error", text: `${kordocStage} ERROR: ${error ?? "unknown"}` });
326
+ }
327
+ }
328
+
329
+ function scheduleKordocUpdate(payload) {
330
+ pendingKordoc.push(payload);
331
+ if (rafHandle) return;
332
+ rafHandle = requestAnimationFrame(() => {
333
+ const batch = pendingKordoc;
334
+ pendingKordoc = [];
335
+ rafHandle = null;
336
+ const latestByKey = new Map();
337
+ for (const p of batch) {
338
+ const key = `${p.kordocStage}:${p.phase === "progress" ? "progress" : p.phase}`;
339
+ latestByKey.set(key, p);
340
+ }
341
+ for (const p of latestByKey.values()) updateKordocPanel(p);
342
+ });
343
+ }
344
+
345
+ function resetKordocPanel() {
346
+ logBuffer.length = 0;
347
+ const substages = document.getElementById("kordoc-substages");
348
+ if (substages) {
349
+ substages.hidden = true;
350
+ substages.querySelectorAll("li").forEach((li) => {
351
+ li.classList.remove("is-start", "is-done", "is-error");
352
+ const icon = li.querySelector(".icon");
353
+ const meta = li.querySelector(".meta");
354
+ const fill = li.querySelector(".progress-fill");
355
+ if (icon) icon.textContent = "○";
356
+ if (meta) meta.textContent = "";
357
+ if (fill) fill.style.width = "0";
358
+ });
359
+ }
360
+ const logSection = document.getElementById("kordoc-event-log-section");
361
+ if (logSection) logSection.hidden = true;
362
+ const logEl = document.getElementById("kordoc-event-log");
363
+ if (logEl) { logEl.innerHTML = ""; logEl.classList.remove("is-collapsed"); }
364
+ }
365
+
366
+ async function showStageTrack(docid) {
367
+ trackedDocid = docid;
368
+ resetKordocPanel();
369
+ unsubscribeKordoc?.();
370
+ unsubscribeKordoc = subscribe("kordoc_stage", (payload) => {
371
+ if (payload?.docid !== trackedDocid) return;
372
+ scheduleKordocUpdate(payload);
373
+ });
374
+
375
+ document.getElementById("drawer-overlay")?.classList.remove("hidden");
376
+ document.getElementById("stage-track-panel")?.classList.add("open");
377
+ const content = document.getElementById("stage-track-content");
378
+ if (!content) return;
379
+ content.innerHTML = '<div class="loading-state"><div class="loading" style="margin:0 auto;"></div></div>';
380
+ try {
381
+ const item = await getJson(`/api/pending/${encodeURIComponent(docid)}`);
382
+ currentStageLog = item.stage_log ?? [];
383
+ content.innerHTML = currentStageLog.map((entry) => `
384
+ <div class="stage-toast-item">
385
+ <div class="stage-toast-item-title">${escapeHtml(entry.stage)}</div>
386
+ <div class="stage-toast-item-sub">${new Date(entry.ts).toLocaleTimeString("ko-KR")} ${entry.duration_ms ? `· ${entry.duration_ms}ms` : ""}</div>
387
+ </div>
388
+ `).join("") || '<div class="empty-state">기록 없음</div>';
389
+
390
+ // stage_log에서 kordoc 서브스테이지 복원
391
+ const kordocEntries = currentStageLog.filter((e) => e.stage === "extracting" && e.sub);
392
+ for (const e of kordocEntries) {
393
+ if (e.sub === "kordoc:provider_fallback") {
394
+ updateKordocPanel({ docid, kordocStage: "provider_fallback", phase: "start",
395
+ from: e.meta?.from, to: e.meta?.to, reason: e.meta?.reason });
396
+ } else {
397
+ const sub = e.sub.replace(/^kordoc:/, "");
398
+ updateKordocPanel({ docid, kordocStage: sub,
399
+ phase: e.phase === "end" ? "done" : e.phase,
400
+ duration_ms: e.duration_ms, error: e.error });
401
+ }
402
+ }
403
+ } catch (error) {
404
+ content.innerHTML = '<div class="empty-state">로드 실패</div>';
405
+ }
406
+ }
407
+
408
+ function bindBulkActions() {
409
+ qs("#pending-select-all")?.addEventListener("change", (event) => {
410
+ document.querySelectorAll("[data-pending-select]").forEach((el) => {
411
+ el.checked = event.currentTarget.checked;
412
+ });
413
+ });
414
+
415
+ qs("#pending-approve-selected")?.addEventListener("click", async () => {
416
+ const ids = Array.from(document.querySelectorAll("[data-pending-select]:checked")).map((el) => el.dataset.pendingSelect);
417
+ await Promise.all(ids.map((id) => postJson(`/api/pending/${encodeURIComponent(id)}/approve`)));
418
+ await loadPending();
419
+ });
420
+
421
+ qs("#pending-reject-selected")?.addEventListener("click", async () => {
422
+ const ids = Array.from(document.querySelectorAll("[data-pending-select]:checked")).map((el) => el.dataset.pendingSelect);
423
+ await Promise.all(ids.map((id) => postJson(`/api/pending/${encodeURIComponent(id)}/reject`)));
424
+ await loadPending();
425
+ });
426
+ }
427
+
428
+ export async function mount() {
429
+ bindBulkActions();
430
+ unsubscribeItemStage = subscribe("item_stage", (payload) => {
431
+ const { docid, stage, phase } = payload ?? {};
432
+ if (!docid) return;
433
+
434
+ // 새 항목 진입 or 실패 → 전체 재조회 (행 데이터·에러 메시지 필요)
435
+ if ((stage === "waiting" && phase === "start") || phase === "error") {
436
+ schedulePendingReload();
437
+ return;
438
+ }
439
+ // 완료·기각 → 행 즉시 제거
440
+ if ((stage === "done" || stage === "dismissed") && phase === "end") {
441
+ removeRowByDocid(docid);
442
+ return;
443
+ }
444
+ // 상태 전환 → chip + kordoc 인디케이터 직접 업데이트
445
+ if (phase === "start") {
446
+ const found = updateRowStatus(docid, stage);
447
+ // 행이 아직 없으면 (예: 다른 탭에서 추가된 항목) 전체 재조회
448
+ if (!found) schedulePendingReload();
449
+ }
450
+ });
451
+
452
+ // pipeline_stats SSE → 파이프라인 카운터 직접 갱신 (폴링 없이)
453
+ unsubscribePipelineStats = subscribe("pipeline_stats", (payload) => {
454
+ if (!payload?.byStatus) return;
455
+ const pipeline = qs("#pending-pipeline");
456
+ if (!pipeline) return;
457
+ const failedCount = payload.byStatus.failed || 0;
458
+ renderPipelineBar(pipeline, payload.byStatus, failedCount);
459
+ });
460
+
461
+ unsubscribeKordocInline = subscribe("kordoc_stage", (payload) => {
462
+ if (payload?.docid) updateKordocInline(payload);
463
+ });
464
+
465
+ document.getElementById("kordoc-log-toggle")?.addEventListener("click", () => {
466
+ document.getElementById("kordoc-event-log")?.classList.toggle("is-collapsed");
467
+ });
468
+ document.getElementById("kordoc-copy-log")?.addEventListener("click", async () => {
469
+ const text = JSON.stringify(currentStageLog ?? [], null, 2);
470
+ try {
471
+ await navigator.clipboard.writeText(text);
472
+ } catch (err) {
473
+ console.warn("clipboard error", err);
474
+ }
475
+ });
476
+ document.getElementById("stage-track-close-btn")?.addEventListener("click", () => {
477
+ document.getElementById("drawer-overlay")?.classList.add("hidden");
478
+ document.getElementById("stage-track-panel")?.classList.remove("open");
479
+ unsubscribeKordoc?.();
480
+ unsubscribeKordoc = null;
481
+ trackedDocid = null;
482
+ });
483
+
484
+ await loadPending();
485
+ }
486
+
487
+ export async function unmount() {
488
+ unsubscribeItemStage?.();
489
+ unsubscribeItemStage = null;
490
+ unsubscribeKordoc?.();
491
+ unsubscribeKordoc = null;
492
+ unsubscribeKordocInline?.();
493
+ unsubscribeKordocInline = null;
494
+ unsubscribePipelineStats?.();
495
+ unsubscribePipelineStats = null;
496
+ trackedDocid = null;
497
+ currentStageLog = null;
498
+ clearTimeout(reloadTimer);
499
+ if (rafHandle) { cancelAnimationFrame(rafHandle); rafHandle = null; }
500
+ pendingKordoc = [];
501
+ }
502
+
503
+ export default { mount, unmount };
@@ -0,0 +1,134 @@
1
+ import { getJson, postJson, patchJson, deleteJson } from "../api.js";
2
+ import { qs, qsa, escapeHtml } from "../dom.js";
3
+
4
+ async function loadSources(targetId) {
5
+ const wrap = qs(`#${targetId}`);
6
+ if (!wrap) return;
7
+ const data = await getJson("/api/skills");
8
+ wrap.innerHTML = (data.skills || []).map((skill) => `
9
+ <label class="chip"><input type="checkbox" value="${escapeHtml(skill.id)}" /> <span>${escapeHtml(skill.name || skill.id)}</span></label>
10
+ `).join("");
11
+ }
12
+
13
+ function selectedSources(targetId) {
14
+ return qsa(`#${targetId} input[type="checkbox"]:checked`).map((el) => el.value);
15
+ }
16
+
17
+ async function loadSchedules() {
18
+ const data = await getJson("/api/schedules");
19
+ const items = data.items || [];
20
+ if (qs("#schedule-stat-total")) qs("#schedule-stat-total").textContent = String(items.length);
21
+ if (qs("#schedule-stat-active")) qs("#schedule-stat-active").textContent = String(items.filter((item) => item.is_active).length);
22
+ if (qs("#schedule-stat-auto-approve")) qs("#schedule-stat-auto-approve").textContent = String(items.filter((item) => item.auto_approve).length);
23
+ if (qs("#schedule-stat-next")) qs("#schedule-stat-next").textContent = items.find((item) => item.next_run_at)?.next_run_at ? "예정" : "-";
24
+ const list = qs("#schedules-list");
25
+ if (!list) return;
26
+ list.innerHTML = items.map((item) => `
27
+ <div class="t-row schedules-row">
28
+ <div>
29
+ <div style="font-weight:510;font-size:13.5px;">${escapeHtml(item.keyword)}</div>
30
+ <div style="font-size:12px;color:var(--label-3);margin-top:2px;">${escapeHtml((() => { try { return JSON.parse(item.sources || "[]").join(" · "); } catch { return item.sources || ""; } })())} · ${item.is_active ? "활성" : "비활성"}</div>
31
+ </div>
32
+ <span class="chip">${escapeHtml(item.schedule_type || "")}</span>
33
+ <span style="font-size:12.5px;color:var(--label-2);font-family:var(--ff-mono);">${escapeHtml(item.schedule_value || "-")}</span>
34
+ <div>${item.auto_approve ? '<span class="chip ok">자동</span>' : '<span class="chip">수동</span>'}</div>
35
+ <div class="schedules-actions" style="display:flex;gap:6px;">
36
+ <button class="btn sm" data-edit-id="${item.id}">수정</button>
37
+ <button class="btn sm" data-toggle-id="${item.id}">토글</button>
38
+ <button class="btn sm danger" data-delete-id="${item.id}">삭제</button>
39
+ </div>
40
+ </div>
41
+ `).join("") || '<div class="empty-state">스케줄 없음</div>';
42
+ qsa("[data-edit-id]").forEach((button) => button.addEventListener("click", () => openEdit(button.dataset.editId, items)));
43
+ qsa("[data-toggle-id]").forEach((button) => button.addEventListener("click", async () => {
44
+ await postJson(`/api/schedules/${button.dataset.toggleId}/toggle`);
45
+ loadSchedules();
46
+ }));
47
+ qsa("[data-delete-id]").forEach((button) => button.addEventListener("click", async () => {
48
+ await deleteJson(`/api/schedules/${button.dataset.deleteId}`);
49
+ loadSchedules();
50
+ }));
51
+ }
52
+
53
+ async function createSchedule() {
54
+ const keyword = qs("#schedule-keyword")?.value.trim();
55
+ if (!keyword) return;
56
+ let scheduleValue = qs("#schedule-time")?.value || "";
57
+ if (qs("#schedule-type")?.value === "weekly") scheduleValue = `${qs("#schedule-dow")?.value || "monday"} ${qs("#schedule-time")?.value || "09:00"}`;
58
+ if (qs("#schedule-type")?.value === "interval") scheduleValue = qs("#schedule-interval")?.value || "";
59
+ const body = {
60
+ keyword,
61
+ sources: JSON.stringify(selectedSources("schedule-sources-list")),
62
+ schedule_type: qs("#schedule-type")?.value || "daily",
63
+ schedule_value: scheduleValue,
64
+ auto_approve: Boolean(qs("#schedule-auto-approve")?.checked),
65
+ };
66
+ await postJson("/api/schedules", body);
67
+ qs("#schedule-form")?.classList.add("hidden");
68
+ loadSchedules();
69
+ }
70
+
71
+ async function openEdit(id, items) {
72
+ const item = items.find((entry) => String(entry.id) === String(id));
73
+ if (!item) return;
74
+ qs("#schedule-edit-modal")?.classList.remove("hidden");
75
+ qs("#edit-schedule-id").value = item.id;
76
+ qs("#edit-schedule-keyword").value = item.keyword || "";
77
+ qs("#edit-schedule-type").value = item.schedule_type || "daily";
78
+ qs("#edit-schedule-auto-approve").checked = Boolean(item.auto_approve);
79
+ qs("#edit-schedule-time").value = item.schedule_value || "09:00";
80
+ qs("#edit-schedule-interval").value = item.schedule_value || "";
81
+ qs("#edit-schedule-cron").value = item.schedule_value || "";
82
+ await loadSources("edit-schedule-sources-list");
83
+ let selected = [];
84
+ try { selected = JSON.parse(item.sources || "[]"); } catch {}
85
+ qsa('#edit-schedule-sources-list input[type="checkbox"]').forEach((checkbox) => {
86
+ checkbox.checked = selected.includes(checkbox.value);
87
+ });
88
+ }
89
+
90
+ async function saveEdit() {
91
+ const id = qs("#edit-schedule-id")?.value;
92
+ if (!id) return;
93
+ await patchJson(`/api/schedules/${id}`, {
94
+ keyword: qs("#edit-schedule-keyword")?.value.trim() || "",
95
+ sources: JSON.stringify(selectedSources("edit-schedule-sources-list")),
96
+ schedule_type: qs("#edit-schedule-type")?.value || "daily",
97
+ schedule_value: qs("#edit-schedule-time")?.value || "",
98
+ auto_approve: Boolean(qs("#edit-schedule-auto-approve")?.checked),
99
+ });
100
+ qs("#schedule-edit-modal")?.classList.add("hidden");
101
+ loadSchedules();
102
+ }
103
+
104
+ export async function mount() {
105
+ await loadSources("schedule-sources-list");
106
+ await loadSchedules();
107
+ qsa('[data-action="toggle-schedule-form"]').forEach((button) => button.addEventListener("click", () => qs("#schedule-form")?.classList.toggle("hidden")));
108
+ qs('[data-action="refresh-schedules"]')?.addEventListener("click", loadSchedules);
109
+ qs("#schedule-type")?.addEventListener("change", () => {
110
+ const type = qs("#schedule-type").value;
111
+ qs("#schedule-dow-group")?.classList.toggle("hidden", type !== "weekly");
112
+ qs("#schedule-interval-group")?.classList.toggle("hidden", type !== "interval");
113
+ });
114
+ qs("#edit-schedule-type")?.addEventListener("change", () => {
115
+ const type = qs("#edit-schedule-type").value;
116
+ qs("#edit-schedule-dow-group")?.classList.toggle("hidden", type !== "weekly");
117
+ qs("#edit-schedule-interval-group")?.classList.toggle("hidden", type !== "interval");
118
+ qs("#edit-schedule-cron-group")?.classList.toggle("hidden", type !== "cron");
119
+ });
120
+ qs("#schedule-create")?.addEventListener("click", createSchedule);
121
+ qs("#schedule-edit-save")?.addEventListener("click", saveEdit);
122
+ qs("#schedule-edit-delete")?.addEventListener("click", async () => {
123
+ const id = qs("#edit-schedule-id")?.value;
124
+ if (!id) return;
125
+ await deleteJson(`/api/schedules/${id}`);
126
+ qs("#schedule-edit-modal")?.classList.add("hidden");
127
+ loadSchedules();
128
+ });
129
+ qsa('[data-action="close-edit-schedule"]').forEach((button) => button.addEventListener("click", () => qs("#schedule-edit-modal")?.classList.add("hidden")));
130
+ }
131
+
132
+ export async function unmount() {}
133
+
134
+ export default { mount, unmount };