@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.
- package/package.json +2 -2
- package/scripts/e2e-mcp.js +214 -0
- package/scripts/postinstall.js +56 -0
- package/scripts/setup-graphify.js +107 -0
- package/scripts/update_css.js +799 -0
- package/web/old/scripts/app.js +2618 -0
- package/web/old/scripts/router.js +15 -0
- package/web/old/scripts/tailwind.min.js +83 -0
- package/web/scripts/api.js +41 -0
- package/web/scripts/app.js +76 -0
- package/web/scripts/components/command-palette.js +88 -0
- package/web/scripts/components/drawer.js +9 -0
- package/web/scripts/components/file-viewer.js +120 -0
- package/web/scripts/components/pdf-viewer.js +188 -0
- package/web/scripts/components/pipeline-footer.js +511 -0
- package/web/scripts/components/pipeline-strip.js +19 -0
- package/web/scripts/components/stat-cards.js +6 -0
- package/web/scripts/components/table.js +9 -0
- package/web/scripts/components/toast.js +12 -0
- package/web/scripts/dom.js +14 -0
- package/web/scripts/format.js +20 -0
- package/web/scripts/pages/catalog.js +134 -0
- package/web/scripts/pages/failures.js +72 -0
- package/web/scripts/pages/graph.js +521 -0
- package/web/scripts/pages/home.js +231 -0
- package/web/scripts/pages/pending.js +503 -0
- package/web/scripts/pages/schedules.js +134 -0
- package/web/scripts/pages/search.js +655 -0
- package/web/scripts/pages/settings.js +196 -0
- package/web/scripts/pages/wiki.js +135 -0
- package/web/scripts/router.js +58 -0
- package/web/scripts/shell.js +81 -0
- package/web/scripts/sse.js +36 -0
- package/web/scripts/store.js +32 -0
|
@@ -0,0 +1,2618 @@
|
|
|
1
|
+
// ─── App State ───
|
|
2
|
+
const app = window.app = {
|
|
3
|
+
currentPage: '/',
|
|
4
|
+
theme: localStorage.getItem('urban.theme') || 'dark',
|
|
5
|
+
sidebarPinned: localStorage.getItem('urban.sidebarPinned') === 'true',
|
|
6
|
+
sseConnected: false,
|
|
7
|
+
lastSync: 0,
|
|
8
|
+
stats: {},
|
|
9
|
+
_extPipeline: new Map(), // docid → stage (외부 수집 항목 SSE 추적)
|
|
10
|
+
_lastServerPipeline: {}, // /api/health 에서 폴링한 파이프라인 카운트
|
|
11
|
+
_lastFailCount: 0, // 마지막 실패 카운트
|
|
12
|
+
|
|
13
|
+
// ─── Init ───
|
|
14
|
+
async init() {
|
|
15
|
+
this.applyTheme();
|
|
16
|
+
this.applySidebarPin();
|
|
17
|
+
this.setupEventListeners();
|
|
18
|
+
this.connectSSE();
|
|
19
|
+
window.addEventListener('popstate', (e) => {
|
|
20
|
+
if (e.state?.path) this.navigateTo(e.state.path, false);
|
|
21
|
+
else this.navigateTo(window.location.pathname || '/', false);
|
|
22
|
+
});
|
|
23
|
+
this.loadStats();
|
|
24
|
+
this.loadExternalSources();
|
|
25
|
+
const initPath = window.location.pathname || '/';
|
|
26
|
+
this.navigateTo(initPath, false);
|
|
27
|
+
setInterval(() => this.loadStats(), 30000);
|
|
28
|
+
// 마지막 동기화 시간 1분마다 갱신 (방금 → N분 전)
|
|
29
|
+
setInterval(() => { if (this.lastSync > 0) this.updateStatusBar(); }, 60000);
|
|
30
|
+
// 파이프라인 상태바는 SSE 이벤트로 실시간 갱신 (폴링 제거)
|
|
31
|
+
// 서버 DB에서 저장된 설정 로드 (localStorage 덮어쓰기)
|
|
32
|
+
this.loadPrefs();
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async loadPrefs() {
|
|
36
|
+
try {
|
|
37
|
+
const prefs = await fetch('/api/prefs').then(r => r.json());
|
|
38
|
+
if (prefs.theme && prefs.theme !== this.theme) {
|
|
39
|
+
this.theme = prefs.theme;
|
|
40
|
+
localStorage.setItem('urban.theme', this.theme);
|
|
41
|
+
this.applyTheme();
|
|
42
|
+
}
|
|
43
|
+
if (prefs.sidebarPinned !== this.sidebarPinned) {
|
|
44
|
+
this.sidebarPinned = prefs.sidebarPinned;
|
|
45
|
+
localStorage.setItem('urban.sidebarPinned', this.sidebarPinned);
|
|
46
|
+
this.applySidebarPin();
|
|
47
|
+
}
|
|
48
|
+
} catch { /* 서버 미응답 시 localStorage 값 유지 */ }
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
savePrefs(patch) {
|
|
52
|
+
localStorage.setItem('urban.theme', this.theme);
|
|
53
|
+
localStorage.setItem('urban.sidebarPinned', this.sidebarPinned);
|
|
54
|
+
fetch('/api/prefs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }).catch(() => {});
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// ─── Theme ───
|
|
58
|
+
applyTheme() {
|
|
59
|
+
// 'dark' → 'urban-dark', 'system' → media query CSS가 처리
|
|
60
|
+
const themeAttr = this.theme === 'dark' ? 'urban-dark' : this.theme;
|
|
61
|
+
document.documentElement.setAttribute('data-theme', themeAttr);
|
|
62
|
+
// 두 테마 버튼 아이콘 동기화 (다크→☀️ 라이트→🌙 시스템→반반)
|
|
63
|
+
const cls = this.theme === 'light' ? 'fas fa-moon'
|
|
64
|
+
: this.theme === 'dark' ? 'fas fa-sun'
|
|
65
|
+
: 'fas fa-circle-half-stroke';
|
|
66
|
+
const label = this.theme === 'light' ? '라이트 모드'
|
|
67
|
+
: this.theme === 'dark' ? '다크 모드'
|
|
68
|
+
: '시스템 모드';
|
|
69
|
+
document.getElementById('sidebar-theme-icon')?.setAttribute('class', cls);
|
|
70
|
+
const headerBtn = document.getElementById('theme-btn');
|
|
71
|
+
if (headerBtn) {
|
|
72
|
+
headerBtn.querySelector('i')?.setAttribute('class', cls);
|
|
73
|
+
headerBtn.title = label;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
toggleTheme() {
|
|
78
|
+
const themes = ['dark', 'light', 'system'];
|
|
79
|
+
const idx = themes.indexOf(this.theme);
|
|
80
|
+
this.theme = themes[(idx + 1) % themes.length];
|
|
81
|
+
this.applyTheme();
|
|
82
|
+
this.savePrefs({ theme: this.theme });
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// ─── Sidebar Pin ───
|
|
86
|
+
applySidebarPin() {
|
|
87
|
+
const sidebar = document.getElementById('sidebar');
|
|
88
|
+
const pinBtn = document.getElementById('sidebar-pin-btn');
|
|
89
|
+
if (!sidebar || !pinBtn) return;
|
|
90
|
+
if (this.sidebarPinned) {
|
|
91
|
+
sidebar.classList.add('expanded', 'pinned');
|
|
92
|
+
pinBtn.classList.add('pinned');
|
|
93
|
+
pinBtn.title = '사이드바 고정 해제';
|
|
94
|
+
} else {
|
|
95
|
+
sidebar.classList.remove('pinned', 'expanded');
|
|
96
|
+
pinBtn.classList.remove('pinned');
|
|
97
|
+
pinBtn.title = '사이드바 고정';
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
toggleSidebarPin() {
|
|
102
|
+
this.sidebarPinned = !this.sidebarPinned;
|
|
103
|
+
this.applySidebarPin();
|
|
104
|
+
this.savePrefs({ sidebarPinned: this.sidebarPinned });
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// ─── Navigation ───
|
|
108
|
+
setupEventListeners() {
|
|
109
|
+
// Sidebar nav (data-page 버튼만)
|
|
110
|
+
document.querySelectorAll('.sidebar-item[data-page]').forEach(btn => {
|
|
111
|
+
btn.addEventListener('click', () => {
|
|
112
|
+
const page = btn.dataset.page;
|
|
113
|
+
this.navigateTo(page);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Sidebar hover (핀 고정 시 무시)
|
|
118
|
+
const sidebar = document.getElementById('sidebar');
|
|
119
|
+
sidebar.addEventListener('mouseenter', () => { if (!this.sidebarPinned) sidebar.classList.add('expanded'); });
|
|
120
|
+
sidebar.addEventListener('mouseleave', () => { if (!this.sidebarPinned) sidebar.classList.remove('expanded'); });
|
|
121
|
+
|
|
122
|
+
// Pin button
|
|
123
|
+
document.getElementById('sidebar-pin-btn').addEventListener('click', () => this.toggleSidebarPin());
|
|
124
|
+
|
|
125
|
+
// Theme (헤더 + 사이드바 모두)
|
|
126
|
+
document.getElementById('theme-btn').addEventListener('click', () => this.toggleTheme());
|
|
127
|
+
document.getElementById('sidebar-theme-btn').addEventListener('click', () => this.toggleTheme());
|
|
128
|
+
|
|
129
|
+
// Search
|
|
130
|
+
document.getElementById('search-main').addEventListener('keydown', (e) => {
|
|
131
|
+
if (e.key === 'Enter') {
|
|
132
|
+
const q = e.target.value.trim();
|
|
133
|
+
if (q) this.navigateTo(`/search?q=${encodeURIComponent(q)}`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// #search-hero는 pages/home.html에서 동적 로드 — 이벤트 위임 사용
|
|
138
|
+
document.addEventListener('keydown', (e) => {
|
|
139
|
+
if (e.target.id === 'search-hero' && e.key === 'Enter') {
|
|
140
|
+
const q = e.target.value.trim();
|
|
141
|
+
if (q) this.navigateTo(`/search?q=${encodeURIComponent(q)}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// 토스트 외부 클릭 시 닫기
|
|
146
|
+
document.addEventListener('click', (e) => {
|
|
147
|
+
const toast = document.getElementById('stage-toast');
|
|
148
|
+
if (toast && toast.classList.contains('open') && !toast.contains(e.target)) {
|
|
149
|
+
this.closeStageToast();
|
|
150
|
+
}
|
|
151
|
+
}, { capture: true });
|
|
152
|
+
|
|
153
|
+
// Keyboard shortcuts
|
|
154
|
+
document.addEventListener('keydown', (e) => {
|
|
155
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
this.showCommandPalette();
|
|
158
|
+
}
|
|
159
|
+
if (e.key === 'Escape') {
|
|
160
|
+
this.hideCommandPalette();
|
|
161
|
+
this.closeDrawer();
|
|
162
|
+
this.closeStageToast();
|
|
163
|
+
}
|
|
164
|
+
// Number keys for nav (only when no input focused and no overlay open)
|
|
165
|
+
const overlayOpen = !document.getElementById('cmd-overlay').classList.contains('hidden');
|
|
166
|
+
const key = parseInt(e.key);
|
|
167
|
+
if (key >= 1 && key <= 9 && !overlayOpen && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
|
|
168
|
+
const buttons = document.querySelectorAll('.sidebar-item');
|
|
169
|
+
if (buttons[key - 1]) buttons[key - 1].click();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async navigateTo(path, push = true) {
|
|
176
|
+
const [route, query] = path.split('?');
|
|
177
|
+
const pageName = route.slice(1) || 'home';
|
|
178
|
+
|
|
179
|
+
// HTML 프래그먼트 fetch (메모리 캐시)
|
|
180
|
+
if (!this._pageCache) this._pageCache = {};
|
|
181
|
+
if (!this._pageCache[pageName]) {
|
|
182
|
+
try {
|
|
183
|
+
const html = await fetch(`/pages/${pageName}.html`).then(r => {
|
|
184
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
185
|
+
return r.text();
|
|
186
|
+
});
|
|
187
|
+
this._pageCache[pageName] = html;
|
|
188
|
+
} catch(e) {
|
|
189
|
+
console.error('Page load failed:', pageName, e);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const view = document.getElementById('view');
|
|
195
|
+
if (!view) return;
|
|
196
|
+
view.innerHTML = this._pageCache[pageName];
|
|
197
|
+
|
|
198
|
+
// sidebar active 업데이트
|
|
199
|
+
document.querySelectorAll('.sidebar-item').forEach(btn => {
|
|
200
|
+
btn.classList.toggle('active', btn.dataset.page === route);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
this.currentPage = route;
|
|
204
|
+
if (push) history.pushState({ path }, '', path);
|
|
205
|
+
|
|
206
|
+
// 페이지별 초기화
|
|
207
|
+
if (route === '/search' && query) {
|
|
208
|
+
const params = new URLSearchParams(query);
|
|
209
|
+
const q = params.get('q');
|
|
210
|
+
if (q) {
|
|
211
|
+
const el = document.getElementById('search-q');
|
|
212
|
+
if (el) { el.value = q; this.searchReports(); }
|
|
213
|
+
}
|
|
214
|
+
} else if (route === '/catalog') { this.loadCatalog();
|
|
215
|
+
} else if (route === '/wiki') { this.loadWiki();
|
|
216
|
+
} else if (route === '/graph') { this.loadGraph();
|
|
217
|
+
} else if (route === '/pending') { this.loadPending();
|
|
218
|
+
} else if (route === '/failures') { this.loadFailures();
|
|
219
|
+
} else if (route === '/schedules') { this.loadSchedules();
|
|
220
|
+
} else if (route === '/bookmarks') { this.loadBookmarks();
|
|
221
|
+
} else if (route === '/settings') { this.loadSettings(); }
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// ─── SSE ───
|
|
225
|
+
connectSSE() {
|
|
226
|
+
this._sseEverConnected = false;
|
|
227
|
+
try {
|
|
228
|
+
this.evtSource = new EventSource('/api/events');
|
|
229
|
+
|
|
230
|
+
this.evtSource.onmessage = (e) => {
|
|
231
|
+
try {
|
|
232
|
+
const data = JSON.parse(e.data);
|
|
233
|
+
this.handleSSEEvent(data);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
console.error('SSE parse error:', err);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
this.evtSource.addEventListener('connected', () => {
|
|
240
|
+
if (this._sseEverConnected) {
|
|
241
|
+
// 재연결 → 이전 진행 중/오류 카드 상태 초기화
|
|
242
|
+
this._resetAllCards();
|
|
243
|
+
}
|
|
244
|
+
this._sseEverConnected = true;
|
|
245
|
+
this.setSseStatus(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
this.evtSource.onerror = () => {
|
|
249
|
+
this.setSseStatus(false);
|
|
250
|
+
};
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.error('SSE connection failed:', e);
|
|
253
|
+
this.setSseStatus(false);
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
_resetAllCards() {
|
|
258
|
+
// 진행 중이던 진행바 숨김
|
|
259
|
+
document.querySelectorAll('.ext-progress').forEach(el => { el.style.display = 'none'; });
|
|
260
|
+
// 실패/진행 중 배지 초기화
|
|
261
|
+
const transientBadges = new Set(['실패', '대기', '↓', '변환', 'AI']);
|
|
262
|
+
document.querySelectorAll('.ext-status-badge').forEach(el => {
|
|
263
|
+
if (transientBadges.has(el.textContent)) el.style.cssText = 'display:none';
|
|
264
|
+
});
|
|
265
|
+
// "재시도" / "..." 버튼 → "수집"으로 복원
|
|
266
|
+
document.querySelectorAll('.ext-collect-btn').forEach(btn => {
|
|
267
|
+
if (btn.textContent === '재시도' || btn.textContent === '...') {
|
|
268
|
+
btn.disabled = false;
|
|
269
|
+
btn.textContent = '수집';
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
handleSSEEvent(data) {
|
|
275
|
+
// 마지막 동기화: 실제 KB 저장 완료 이벤트에만 갱신
|
|
276
|
+
// (다운로드 진행 중, 변환 중 등 중간 단계는 제외)
|
|
277
|
+
const isSavedEvent = data.type && (
|
|
278
|
+
data.type === 'session:done' || // 보고서 1건 완전 처리 완료
|
|
279
|
+
data.type === 'wiki:updated' || // Wiki 페이지 저장됨
|
|
280
|
+
data.type === 'done' // 파이프라인 완료 신호
|
|
281
|
+
);
|
|
282
|
+
if (isSavedEvent) {
|
|
283
|
+
this.lastSync = Date.now();
|
|
284
|
+
}
|
|
285
|
+
this.updateStatusBar();
|
|
286
|
+
|
|
287
|
+
// session:done / wiki:updated 는 실제 DB 변경 → 전체 통계 갱신
|
|
288
|
+
// 중간 단계(collect:download 등)는 SSE 카운터로만 처리 (loadStats 불필요)
|
|
289
|
+
if (['done', 'failure', 'pending', 'session:done', 'wiki:updated'].includes(data.type)) {
|
|
290
|
+
this.loadStats();
|
|
291
|
+
}
|
|
292
|
+
if (data.type === 'pending' || data.type === 'item_stage') {
|
|
293
|
+
this.loadPending();
|
|
294
|
+
// 추적 패널이 열려 있고 해당 항목이면 자동 새로고침
|
|
295
|
+
if (data.type === 'item_stage' && this._trackingDocid) {
|
|
296
|
+
if (!data.docid || data.docid === this._trackingDocid) {
|
|
297
|
+
this.showStageTrack(this._trackingDocid);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// 단계 토스트 팝업이 열려있으면 즉시 본문 갱신
|
|
301
|
+
if (this._stageToastCurrentStage) {
|
|
302
|
+
this._refreshStagePillBody(this._stageToastCurrentStage);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 실패 목록 아이템 진행상황 업데이트 (docid → data-docid 매핑)
|
|
307
|
+
const docid = data.docid;
|
|
308
|
+
if (docid) {
|
|
309
|
+
const failureItem = document.querySelector(`.list-item[data-docid="${docid}"]`);
|
|
310
|
+
if (failureItem) {
|
|
311
|
+
const hash = failureItem.dataset.hash;
|
|
312
|
+
const STAGE_MAP = {
|
|
313
|
+
'extract:start': [60, 'AI 분석 중...'],
|
|
314
|
+
'extract:complete': [75, '그래프 업데이트...'],
|
|
315
|
+
'graph:updated': [88, '위키 생성 중...'],
|
|
316
|
+
'wiki:updated': [95, '마무리 중...'],
|
|
317
|
+
'session:done': [100, '완료', true],
|
|
318
|
+
'collect:download': [20, '다운로드 중...'],
|
|
319
|
+
'collect:convert': [45, 'MD 변환 중...'],
|
|
320
|
+
'extract:error': [100, `오류: ${(data.error||'').slice(0,50)}`, false, true],
|
|
321
|
+
'graph:error': [100, `그래프 오류: ${(data.error||'').slice(0,40)}`, false, true],
|
|
322
|
+
'wiki:error': [100, `위키 오류: ${(data.error||'').slice(0,40)}`, false, true],
|
|
323
|
+
};
|
|
324
|
+
const args = STAGE_MAP[data.type];
|
|
325
|
+
if (args) this._setFailureProgress(hash, ...args);
|
|
326
|
+
// 완료 시 뱃지 갱신
|
|
327
|
+
if (data.type === 'session:done') {
|
|
328
|
+
const badge = document.querySelector(`.failure-status-badge[data-hash="${hash}"]`);
|
|
329
|
+
if (badge) { badge.textContent = '완료'; badge.className = 'badge success failure-status-badge'; }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 외부 검색 카드 진행률 업데이트 (docid 기반)
|
|
335
|
+
if (!docid) return;
|
|
336
|
+
|
|
337
|
+
switch (data.type) {
|
|
338
|
+
case 'collect:start':
|
|
339
|
+
this._setCardProgress(docid, 5, '수집 시작...');
|
|
340
|
+
this._updateStatusBadge(docid, '대기', '#888');
|
|
341
|
+
break;
|
|
342
|
+
case 'collect:download':
|
|
343
|
+
this._setCardProgress(docid, 20, '다운로드 중...');
|
|
344
|
+
this._updateStatusBadge(docid, '↓', '#2563eb');
|
|
345
|
+
break;
|
|
346
|
+
case 'collect:convert':
|
|
347
|
+
this._setCardProgress(docid, 45, 'MD 변환 중...');
|
|
348
|
+
this._updateStatusBadge(docid, '변환', '#7c3aed');
|
|
349
|
+
break;
|
|
350
|
+
case 'extract:start':
|
|
351
|
+
this._setCardProgress(docid, 60, 'AI 분석 중...');
|
|
352
|
+
this._updateStatusBadge(docid, 'AI', '#d97706');
|
|
353
|
+
break;
|
|
354
|
+
case 'extract:complete':
|
|
355
|
+
this._setCardProgress(docid, 75, '그래프 업데이트...');
|
|
356
|
+
break;
|
|
357
|
+
case 'graph:updated':
|
|
358
|
+
this._setCardProgress(docid, 88, '위키 생성 중...');
|
|
359
|
+
break;
|
|
360
|
+
case 'wiki:updated':
|
|
361
|
+
this._setCardProgress(docid, 95, '아카이브...');
|
|
362
|
+
break;
|
|
363
|
+
case 'session:done': {
|
|
364
|
+
this._setCardProgress(docid, 100, '완료', true);
|
|
365
|
+
this._updateStatusBadge(docid, '완료', '#16a34a');
|
|
366
|
+
const btn = document.querySelector(`.ext-collect-btn[data-docid="${docid}"]`);
|
|
367
|
+
if (btn) { btn.textContent = '수집됨'; btn.style.opacity = '0.5'; }
|
|
368
|
+
const chk = document.querySelector(`.ext-item-check[data-docid="${docid}"]`);
|
|
369
|
+
if (chk) { chk.checked = false; chk.disabled = true; }
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case 'session:skip':
|
|
373
|
+
this._setCardProgress(docid, 100, '이미 수집됨', true);
|
|
374
|
+
this._updateStatusBadge(docid, '중복', '#888');
|
|
375
|
+
break;
|
|
376
|
+
case 'collect:error':
|
|
377
|
+
case 'extract:error':
|
|
378
|
+
case 'graph:error':
|
|
379
|
+
case 'wiki:error': {
|
|
380
|
+
const errMsg = data.error ? data.error.slice(0, 60) : '오류';
|
|
381
|
+
this._setCardProgress(docid, 100, `오류: ${errMsg}`, false, true);
|
|
382
|
+
this._updateStatusBadge(docid, '실패', '#dc2626');
|
|
383
|
+
const errBtn = document.querySelector(`.ext-collect-btn[data-docid="${docid}"]`);
|
|
384
|
+
if (errBtn) { errBtn.disabled = false; errBtn.textContent = '재시도'; }
|
|
385
|
+
// 3초 후 진행바 숨김
|
|
386
|
+
setTimeout(() => {
|
|
387
|
+
const prog = document.querySelector(`.ext-progress[data-docid="${docid}"]`);
|
|
388
|
+
if (prog) prog.style.display = 'none';
|
|
389
|
+
}, 3000);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── 외부 수집 파이프라인 카운터 SSE 추적 (하단 상태바) ──
|
|
395
|
+
const EXT_STAGE = {
|
|
396
|
+
'collect:start': 'downloading',
|
|
397
|
+
'collect:download': 'downloading',
|
|
398
|
+
'collect:convert': 'converting',
|
|
399
|
+
'extract:start': 'extracting',
|
|
400
|
+
'extract:complete': 'graph',
|
|
401
|
+
'graph:updated': 'wiki',
|
|
402
|
+
'wiki:updated': 'wiki',
|
|
403
|
+
};
|
|
404
|
+
const extStage = EXT_STAGE[data.type];
|
|
405
|
+
if (extStage) {
|
|
406
|
+
// collect:start / collect:download 는 새 항목 진입점 — 이후 단계는 이미 추적 중인 것만
|
|
407
|
+
if (this._extPipeline.has(docid) || data.type === 'collect:start' || data.type === 'collect:download') {
|
|
408
|
+
this._extPipeline.set(docid, extStage);
|
|
409
|
+
this._refreshPipelineBar();
|
|
410
|
+
}
|
|
411
|
+
} else if (data.type === 'session:done' || data.type === 'session:skip' || data.type === 'session:meta-updated') {
|
|
412
|
+
if (this._extPipeline.delete(docid)) this._refreshPipelineBar();
|
|
413
|
+
} else if (['collect:error', 'extract:error', 'graph:error', 'wiki:error'].includes(data.type)) {
|
|
414
|
+
if (this._extPipeline.delete(docid)) this._refreshPipelineBar();
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
setSseStatus(connected) {
|
|
419
|
+
this.sseConnected = connected;
|
|
420
|
+
const ind = document.getElementById('sse-status');
|
|
421
|
+
const text = document.getElementById('status-text');
|
|
422
|
+
if (connected) {
|
|
423
|
+
ind.classList.remove('disconnected');
|
|
424
|
+
text.textContent = 'SSE 연결됨';
|
|
425
|
+
} else {
|
|
426
|
+
ind.classList.add('disconnected');
|
|
427
|
+
text.textContent = 'SSE 연결 끊김';
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
updateStatusBar() {
|
|
432
|
+
const minAgo = Math.floor((Date.now() - this.lastSync) / 60000);
|
|
433
|
+
const syncText = minAgo === 0 ? '방금' : `${minAgo}분 전`;
|
|
434
|
+
document.getElementById('status-sync').textContent = `마지막 동기화 ${syncText}`;
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
// 서버 파이프라인 + SSE 추적 외부 수집 항목을 합산해 파이프라인 바 갱신
|
|
438
|
+
_refreshPipelineBar() {
|
|
439
|
+
const merged = Object.assign({}, this._lastServerPipeline);
|
|
440
|
+
for (const stage of this._extPipeline.values()) {
|
|
441
|
+
merged[stage] = (merged[stage] || 0) + 1;
|
|
442
|
+
}
|
|
443
|
+
this.renderPipelineBar(merged, this._lastFailCount);
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
// ─── API Calls ───
|
|
447
|
+
async loadStats() {
|
|
448
|
+
try {
|
|
449
|
+
const [stats, health, failures] = await Promise.all([
|
|
450
|
+
fetch('/api/stats').then(r => r.json()),
|
|
451
|
+
fetch('/api/health').then(r => r.json()),
|
|
452
|
+
fetch('/api/failures/count').then(r => r.json()),
|
|
453
|
+
]);
|
|
454
|
+
|
|
455
|
+
this.stats = stats;
|
|
456
|
+
const _s = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = (val || 0).toLocaleString('ko-KR'); };
|
|
457
|
+
_s('stat-total', stats.total_reports);
|
|
458
|
+
_s('stat-week', stats.week_collected);
|
|
459
|
+
_s('stat-pending', stats.pending_count);
|
|
460
|
+
_s('stat-queue', stats.queue_size);
|
|
461
|
+
|
|
462
|
+
// Health (home 페이지 외에서는 요소가 없을 수 있음)
|
|
463
|
+
const dbEl = document.getElementById('db-status');
|
|
464
|
+
if (dbEl) dbEl.textContent = health.db.ok ? `${health.db.size_mb}MB` : '❌ 오류';
|
|
465
|
+
const llmEl = document.getElementById('llm-status');
|
|
466
|
+
if (llmEl) llmEl.textContent = `${health.llm_cache.entries} (적중률 ${health.llm_cache.hit_rate_pct}%)`;
|
|
467
|
+
|
|
468
|
+
// 파이프라인 상태바 칩 렌더링 (서버 데이터 저장 → SSE 추적분 합산)
|
|
469
|
+
this._lastServerPipeline = health.pipeline || {};
|
|
470
|
+
this._lastFailCount = failures.count || 0;
|
|
471
|
+
this._refreshPipelineBar();
|
|
472
|
+
|
|
473
|
+
// Recent reports
|
|
474
|
+
const recentHtml = (stats.recent_reports || [])
|
|
475
|
+
.map(r => `
|
|
476
|
+
<div class="list-item" style="cursor:pointer;" onclick="window.app.showReportDetail('${this.escapeHtml(r.hash)}')">
|
|
477
|
+
<div style="flex:1;min-width:0;">
|
|
478
|
+
<div class="list-item-title">${this.escapeHtml(r.title || '제목 없음')}</div>
|
|
479
|
+
<div class="list-item-meta">
|
|
480
|
+
<span>${this.escapeHtml(r.source || '')}</span>
|
|
481
|
+
<span>${r.created_at ? new Date(r.created_at).toLocaleDateString('ko-KR') : ''}</span>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
<i class="fas fa-chevron-right" style="color:var(--text-tertiary);font-size:12px;flex-shrink:0;"></i>
|
|
485
|
+
</div>
|
|
486
|
+
`).join('');
|
|
487
|
+
document.getElementById('recent-reports').innerHTML = recentHtml || '<div class="empty-state">최근 보고서 없음</div>';
|
|
488
|
+
|
|
489
|
+
// Failures banner
|
|
490
|
+
const banner = document.getElementById('failures-banner');
|
|
491
|
+
if (failures.count > 0) {
|
|
492
|
+
banner.classList.remove('hidden');
|
|
493
|
+
document.getElementById('failures-count').textContent = failures.count;
|
|
494
|
+
} else {
|
|
495
|
+
banner.classList.add('hidden');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
this.lastSync = Date.now();
|
|
499
|
+
this.updateStatusBar();
|
|
500
|
+
} catch (e) {
|
|
501
|
+
console.error('Failed to load stats:', e);
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
// ─── 검색 탭 전환 ───
|
|
506
|
+
switchSearchTab(tab) {
|
|
507
|
+
const searchPage = document.getElementById('page-search');
|
|
508
|
+
searchPage.querySelectorAll('.search-tab').forEach(el => el.classList.remove('active'));
|
|
509
|
+
searchPage.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
|
510
|
+
document.getElementById(`search-tab-${tab}`).classList.add('active');
|
|
511
|
+
searchPage.querySelector(`.tab-btn[data-tab="${tab}"]`).classList.add('active');
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
// ─── 외부 소스 목록 동적 로드 ───
|
|
515
|
+
async loadExternalSources() {
|
|
516
|
+
try {
|
|
517
|
+
const data = await fetch('/api/skills').then(r => r.json());
|
|
518
|
+
const container = document.getElementById('ext-source-checkboxes');
|
|
519
|
+
if (!container) return;
|
|
520
|
+
container.innerHTML = '';
|
|
521
|
+
for (const skill of (data.skills || [])) {
|
|
522
|
+
const label = document.createElement('label');
|
|
523
|
+
label.className = 'source-pill';
|
|
524
|
+
const checkbox = document.createElement('input');
|
|
525
|
+
checkbox.type = 'checkbox';
|
|
526
|
+
checkbox.dataset.sourceId = skill.id;
|
|
527
|
+
checkbox.checked = true;
|
|
528
|
+
label.appendChild(checkbox);
|
|
529
|
+
label.appendChild(document.createTextNode(skill.name));
|
|
530
|
+
container.appendChild(label);
|
|
531
|
+
}
|
|
532
|
+
if ((data.skills || []).length === 0) {
|
|
533
|
+
container.innerHTML = '<span style="color:var(--text-tertiary);font-size:var(--text-sm);">등록된 외부 소스 없음</span>';
|
|
534
|
+
}
|
|
535
|
+
} catch (e) {
|
|
536
|
+
console.warn('스킬 목록 로드 실패:', e);
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
// ─── 외부 검색 ───
|
|
541
|
+
async searchExternal() {
|
|
542
|
+
const q = document.getElementById('ext-search-q').value.trim();
|
|
543
|
+
if (!q) return;
|
|
544
|
+
|
|
545
|
+
const sources = [...document.querySelectorAll('#ext-source-checkboxes input[type=checkbox]:checked')]
|
|
546
|
+
.map(el => el.dataset.sourceId);
|
|
547
|
+
if (sources.length === 0) {
|
|
548
|
+
alert('검색할 소스를 하나 이상 선택하세요');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
document.getElementById('ext-search-loading').style.display = '';
|
|
553
|
+
document.getElementById('ext-search-results').innerHTML = '';
|
|
554
|
+
document.getElementById('ext-collect-toolbar').style.display = 'none';
|
|
555
|
+
this._extItems = {};
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const params = new URLSearchParams({ q, sources: sources.join(','), limit: 20 });
|
|
559
|
+
const data = await fetch(`/api/search/external?${params}`).then(r => r.json());
|
|
560
|
+
|
|
561
|
+
let html = '';
|
|
562
|
+
let hasCollectable = false;
|
|
563
|
+
for (const [sourceId, sourceData] of Object.entries(data.sources || {})) {
|
|
564
|
+
if (sourceData.status === 'error') {
|
|
565
|
+
html += `<div style="padding:var(--space-3);color:var(--error);border-bottom:1px solid var(--border-subtle);">
|
|
566
|
+
<strong>${this.escapeHtml(sourceId.toUpperCase())}</strong> 검색 실패: ${this.escapeHtml(sourceData.error || '')}
|
|
567
|
+
</div>`;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
for (const item of (sourceData.items || [])) {
|
|
571
|
+
if (item.docid) this._extItems[item.docid] = item;
|
|
572
|
+
if (!item.collected) hasCollectable = true;
|
|
573
|
+
html += this.renderExternalResultCard(item);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const meta = `<div style="padding:var(--space-2) var(--space-3);background:var(--surface-raised);border-bottom:1px solid var(--border-subtle);font-size:var(--text-sm);color:var(--text-secondary);">
|
|
578
|
+
총 ${data.totalCount}건 · ${data.elapsed_ms}ms
|
|
579
|
+
</div>`;
|
|
580
|
+
|
|
581
|
+
document.getElementById('ext-search-results').innerHTML =
|
|
582
|
+
html ? meta + html : '<div class="empty-state">결과 없음</div>';
|
|
583
|
+
|
|
584
|
+
if (hasCollectable) {
|
|
585
|
+
document.getElementById('ext-collect-toolbar').style.display = 'flex';
|
|
586
|
+
const countEl = document.getElementById('ext-selected-count');
|
|
587
|
+
if (countEl) countEl.textContent = '0개 선택됨';
|
|
588
|
+
const allChk = document.getElementById('ext-select-all');
|
|
589
|
+
if (allChk) { allChk.checked = false; allChk.indeterminate = false; }
|
|
590
|
+
}
|
|
591
|
+
} catch (e) {
|
|
592
|
+
console.error('External search failed:', e);
|
|
593
|
+
document.getElementById('ext-search-results').innerHTML =
|
|
594
|
+
'<div class="empty-state">외부 검색 실패</div>';
|
|
595
|
+
} finally {
|
|
596
|
+
document.getElementById('ext-search-loading').style.display = 'none';
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
renderExternalResultCard(item) {
|
|
601
|
+
const badgeClass = item.source === 'prism' ? 'prism' : item.source === 'nanet' ? 'nanet' : 'default';
|
|
602
|
+
const badgeText = item.source === 'prism' ? 'PRISM' : item.source === 'nanet' ? '국회도서관' : item.source;
|
|
603
|
+
const year = item.year && item.year !== '미상' ? item.year : '';
|
|
604
|
+
const fileSize = item.fileSize > 0 ? `${(item.fileSize / 1048576).toFixed(1)}MB` : '';
|
|
605
|
+
const fileName = item.fileName && item.fileName !== '파일명없음' ? item.fileName : '';
|
|
606
|
+
const summary = (item.summary || item.outline || item.contentPreview || '').slice(0, 150);
|
|
607
|
+
const safeDocid = this.escapeHtml(item.docid);
|
|
608
|
+
|
|
609
|
+
const checkbox = item.collected
|
|
610
|
+
? `<input type="checkbox" class="ext-item-check" data-docid="${safeDocid}" disabled style="margin-top:2px;flex-shrink:0;" />`
|
|
611
|
+
: `<input type="checkbox" class="ext-item-check" data-docid="${safeDocid}" style="margin-top:2px;flex-shrink:0;" onchange="window.app.onItemCheckChange()" />`;
|
|
612
|
+
|
|
613
|
+
const collectBtn = item.collected
|
|
614
|
+
? `<button class="button" style="flex-shrink:0;font-size:var(--text-xs);opacity:0.5;cursor:default;" disabled>수집됨</button>`
|
|
615
|
+
: `<button class="button ext-collect-btn" data-docid="${safeDocid}" style="flex-shrink:0;font-size:var(--text-xs);" onclick="window.app.collectExternalItem(this.dataset.docid)">수집</button>`;
|
|
616
|
+
|
|
617
|
+
const collectedBadge = item.collected
|
|
618
|
+
? `<span class="source-badge" style="background:var(--success);color:white;">수집됨</span>`
|
|
619
|
+
: '';
|
|
620
|
+
|
|
621
|
+
return `<div class="list-item ext-result-card" data-docid="${safeDocid}" style="padding:var(--space-3);border-bottom:1px solid var(--border-subtle);">
|
|
622
|
+
<div style="display:flex;align-items:flex-start;gap:var(--space-2);width:100%;">
|
|
623
|
+
${checkbox}
|
|
624
|
+
<div style="flex:1;min-width:0;">
|
|
625
|
+
<div class="list-item-title" style="margin-bottom:var(--space-1);">
|
|
626
|
+
<span class="source-badge ${badgeClass}">${badgeText}</span>${collectedBadge}<span class="ext-status-badge" data-docid="${safeDocid}"></span>${this.escapeHtml(item.title)}
|
|
627
|
+
</div>
|
|
628
|
+
<div class="list-item-meta" style="display:flex;gap:var(--space-3);flex-wrap:wrap;">
|
|
629
|
+
${item.institution ? `<span>${this.escapeHtml(item.institution)}</span>` : ''}
|
|
630
|
+
${year ? `<span>${year}</span>` : ''}
|
|
631
|
+
${fileSize ? `<span>${fileSize}</span>` : ''}
|
|
632
|
+
${fileName ? `<span style="color:var(--text-tertiary);font-style:italic;">${this.escapeHtml(fileName)}</span>` : ''}
|
|
633
|
+
</div>
|
|
634
|
+
${summary ? `<div style="margin-top:var(--space-1);font-size:var(--text-sm);color:var(--text-secondary);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;">${this.escapeHtml(summary)}</div>` : ''}
|
|
635
|
+
<div class="ext-progress" data-docid="${safeDocid}" style="display:none;margin-top:var(--space-2);">
|
|
636
|
+
<div class="ext-progress-bar"><div class="ext-progress-fill"></div></div>
|
|
637
|
+
<div class="ext-progress-label" style="font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;"></div>
|
|
638
|
+
</div>
|
|
639
|
+
</div>
|
|
640
|
+
<div style="flex-shrink:0;align-self:flex-start;margin-left:auto;">${collectBtn}</div>
|
|
641
|
+
</div>
|
|
642
|
+
</div>`;
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
async collectExternalItem(docid) {
|
|
646
|
+
const item = this._extItems?.[docid];
|
|
647
|
+
if (!item) return;
|
|
648
|
+
await this._startCollect([item]);
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
async collectSelectedItems() {
|
|
652
|
+
const checked = [...document.querySelectorAll('.ext-item-check:checked:not(:disabled)')]
|
|
653
|
+
.map(el => el.dataset.docid)
|
|
654
|
+
.filter(Boolean);
|
|
655
|
+
if (checked.length === 0) { alert('수집할 항목을 선택하세요.'); return; }
|
|
656
|
+
const items = checked.map(d => this._extItems?.[d]).filter(Boolean);
|
|
657
|
+
await this._startCollect(items);
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
async _startCollect(items) {
|
|
661
|
+
if (!items.length) return;
|
|
662
|
+
try {
|
|
663
|
+
const res = await fetch('/api/search/external/collect', {
|
|
664
|
+
method: 'POST',
|
|
665
|
+
headers: { 'Content-Type': 'application/json' },
|
|
666
|
+
body: JSON.stringify({ items }),
|
|
667
|
+
});
|
|
668
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
669
|
+
// 진행률은 SSE로 수신
|
|
670
|
+
for (const item of items) {
|
|
671
|
+
this._setCardProgress(item.docid, 0, '대기 중...');
|
|
672
|
+
const btn = document.querySelector(`.ext-collect-btn[data-docid="${item.docid}"]`);
|
|
673
|
+
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
|
674
|
+
}
|
|
675
|
+
} catch (e) {
|
|
676
|
+
console.error('Collect failed:', e);
|
|
677
|
+
alert('수집 요청 실패: ' + e.message);
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
|
|
681
|
+
toggleSelectAll(checked) {
|
|
682
|
+
document.querySelectorAll('.ext-item-check:not(:disabled)').forEach(el => { el.checked = checked; });
|
|
683
|
+
this.onItemCheckChange();
|
|
684
|
+
},
|
|
685
|
+
|
|
686
|
+
onItemCheckChange() {
|
|
687
|
+
const total = document.querySelectorAll('.ext-item-check:not(:disabled):checked').length;
|
|
688
|
+
const countEl = document.getElementById('ext-selected-count');
|
|
689
|
+
if (countEl) countEl.textContent = `${total}개 선택됨`;
|
|
690
|
+
const allEl = document.getElementById('ext-select-all');
|
|
691
|
+
const allCount = document.querySelectorAll('.ext-item-check:not(:disabled)').length;
|
|
692
|
+
if (allEl) allEl.indeterminate = total > 0 && total < allCount;
|
|
693
|
+
if (allEl) allEl.checked = allCount > 0 && total === allCount;
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
_setCardProgress(docid, pct, label, done = false, error = false) {
|
|
697
|
+
const prog = document.querySelector(`.ext-progress[data-docid="${docid}"]`);
|
|
698
|
+
if (!prog) return;
|
|
699
|
+
prog.style.display = '';
|
|
700
|
+
const fill = prog.querySelector('.ext-progress-fill');
|
|
701
|
+
const lbl = prog.querySelector('.ext-progress-label');
|
|
702
|
+
if (fill) {
|
|
703
|
+
fill.style.width = `${Math.min(100, pct)}%`;
|
|
704
|
+
fill.style.background = error ? 'var(--danger)' : done ? 'var(--success)' : 'var(--accent)';
|
|
705
|
+
}
|
|
706
|
+
if (lbl) lbl.textContent = label;
|
|
707
|
+
},
|
|
708
|
+
|
|
709
|
+
_updateStatusBadge(docid, text, color) {
|
|
710
|
+
const badge = document.querySelector(`.ext-status-badge[data-docid="${docid}"]`);
|
|
711
|
+
if (!badge) return;
|
|
712
|
+
badge.textContent = text;
|
|
713
|
+
badge.style.cssText = `display:inline-block;margin:0 4px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:${color};color:white;`;
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
escapeHtml(str) {
|
|
717
|
+
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
async searchReports() {
|
|
721
|
+
const q = document.getElementById('search-q').value.trim();
|
|
722
|
+
const source = document.getElementById('search-source').value;
|
|
723
|
+
const fts = document.getElementById('search-fts').checked ? '1' : '0';
|
|
724
|
+
|
|
725
|
+
if (!q) return;
|
|
726
|
+
|
|
727
|
+
const el = document.getElementById('search-results');
|
|
728
|
+
el.innerHTML = '<div class="empty-state"><span class="loading loading-spinner loading-sm"></span></div>';
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
const params = new URLSearchParams({ q, source, fts, limit: 50 });
|
|
732
|
+
const data = await fetch(`/api/search?${params}`).then(r => r.json());
|
|
733
|
+
|
|
734
|
+
const isFts = fts === '1';
|
|
735
|
+
const items = data.items || [];
|
|
736
|
+
|
|
737
|
+
const meta = items.length > 0
|
|
738
|
+
? `<div style="padding:var(--space-2) var(--space-4);background:var(--surface-raised);border-bottom:1px solid var(--border-subtle);font-size:11px;color:var(--text-tertiary);">총 ${data.total ?? items.length}건</div>`
|
|
739
|
+
: '';
|
|
740
|
+
|
|
741
|
+
const html = items.map(r => {
|
|
742
|
+
// FTS(위키) 결과: slug만 있음
|
|
743
|
+
if (isFts && r.slug) {
|
|
744
|
+
const safeSlug = this.escapeHtml(r.slug);
|
|
745
|
+
const safeTitle = this.escapeHtml(r.title || r.slug);
|
|
746
|
+
return `
|
|
747
|
+
<div class="list-item" style="cursor:pointer;gap:var(--space-3);" onclick="app.navigateTo('/wiki');app.wikiNavigate('${safeSlug}');">
|
|
748
|
+
<div style="flex:1;min-width:0;">
|
|
749
|
+
<div class="list-item-title">${safeTitle}</div>
|
|
750
|
+
<div class="list-item-meta"><span style="color:var(--accent);font-size:11px;">📖 위키</span></div>
|
|
751
|
+
</div>
|
|
752
|
+
<i class="fas fa-arrow-right" style="color:var(--text-tertiary);font-size:12px;flex-shrink:0;"></i>
|
|
753
|
+
</div>`;
|
|
754
|
+
}
|
|
755
|
+
// 보고서 결과
|
|
756
|
+
const hash = this.escapeHtml(r.hash || '');
|
|
757
|
+
const title = this.escapeHtml(r.title || '제목 없음');
|
|
758
|
+
const hasPdf = !!(r.archive_path);
|
|
759
|
+
const hasMd = !!(r.md_path);
|
|
760
|
+
const hasFileOnly = !hasPdf && !hasMd && !!(r.file_path);
|
|
761
|
+
const hasAny = hasPdf || hasMd || hasFileOnly;
|
|
762
|
+
const dateStr = r.created_at ? new Date(r.created_at).toLocaleDateString('ko-KR') : '';
|
|
763
|
+
const dlBtnStyle = `display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);font-size:11px;text-decoration:none;background:var(--surface-raised);transition:all .15s;`;
|
|
764
|
+
const dlBtnHover = `onmouseover="this.style.borderColor='var(--accent)';this.style.color='var(--accent)'" onmouseout="this.style.borderColor='var(--border)';this.style.color='var(--text-secondary)'"`;
|
|
765
|
+
return `
|
|
766
|
+
<div class="list-item" style="gap:var(--space-3);">
|
|
767
|
+
<div style="flex:1;min-width:0;cursor:pointer;" onclick="app.showReportDetail('${hash}')">
|
|
768
|
+
<div class="list-item-title">${title}</div>
|
|
769
|
+
<div class="list-item-meta">
|
|
770
|
+
${r.source ? `<span>${this.escapeHtml(r.source)}</span>` : ''}
|
|
771
|
+
${dateStr ? `<span>${dateStr}</span>` : ''}
|
|
772
|
+
${r.url ? `<a href="${this.escapeHtml(r.url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" style="color:var(--accent);font-size:11px;" title="${this.escapeHtml(r.url)}">원문 ↗</a>` : ''}
|
|
773
|
+
</div>
|
|
774
|
+
</div>
|
|
775
|
+
<div style="display:flex;gap:6px;flex-shrink:0;align-items:center;" onclick="event.stopPropagation()">
|
|
776
|
+
${hasPdf ? `
|
|
777
|
+
<a href="/api/reports/${hash}/file" download title="PDF 다운로드"
|
|
778
|
+
style="${dlBtnStyle}" ${dlBtnHover}>
|
|
779
|
+
<i class="fas fa-file-pdf" style="font-size:10px;"></i> PDF
|
|
780
|
+
</a>` : ''}
|
|
781
|
+
${hasMd ? `
|
|
782
|
+
<a href="/api/reports/${hash}/md" download title="MD 다운로드"
|
|
783
|
+
style="${dlBtnStyle}" ${dlBtnHover}>
|
|
784
|
+
<i class="fas fa-file-lines" style="font-size:10px;"></i> MD
|
|
785
|
+
</a>` : ''}
|
|
786
|
+
${hasFileOnly ? `
|
|
787
|
+
<a href="/api/reports/${hash}/file" download title="파일 다운로드"
|
|
788
|
+
style="${dlBtnStyle}" ${dlBtnHover}>
|
|
789
|
+
<i class="fas fa-download" style="font-size:10px;"></i> 다운로드
|
|
790
|
+
</a>` : ''}
|
|
791
|
+
${!hasAny ? `
|
|
792
|
+
<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border:1px solid var(--border-subtle);border-radius:4px;color:var(--text-tertiary);font-size:11px;cursor:default;" title="파일 없음">
|
|
793
|
+
<i class="fas fa-file-slash" style="font-size:10px;"></i> 파일 없음
|
|
794
|
+
</span>` : ''}
|
|
795
|
+
</div>
|
|
796
|
+
</div>`;
|
|
797
|
+
}).join('');
|
|
798
|
+
|
|
799
|
+
el.innerHTML = meta + (html || '<div class="empty-state">결과 없음</div>');
|
|
800
|
+
} catch (e) {
|
|
801
|
+
console.error('Search failed:', e);
|
|
802
|
+
el.innerHTML = '<div class="empty-state">검색 실패</div>';
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
|
|
806
|
+
async loadCatalog(page = 1) {
|
|
807
|
+
try {
|
|
808
|
+
const source = document.getElementById('catalog-source').value;
|
|
809
|
+
const status = document.getElementById('catalog-status').value;
|
|
810
|
+
const lang = document.getElementById('catalog-lang').value;
|
|
811
|
+
const q = document.getElementById('catalog-q').value.trim();
|
|
812
|
+
const params = new URLSearchParams({ page, limit: 20 });
|
|
813
|
+
if (source) params.set('source', source);
|
|
814
|
+
if (status) params.set('status', status);
|
|
815
|
+
if (lang) params.set('lang', lang);
|
|
816
|
+
if (q) params.set('q', q);
|
|
817
|
+
const data = await fetch(`/api/reports?${params}`).then(r => r.json());
|
|
818
|
+
|
|
819
|
+
const html = (data.items || [])
|
|
820
|
+
.map(r => `
|
|
821
|
+
<div class="list-item" style="cursor:pointer;" onclick="window.app.showReportDetail('${this.escapeHtml(r.hash)}')">
|
|
822
|
+
<div style="flex:1;min-width:0;">
|
|
823
|
+
<div class="list-item-title">${this.escapeHtml(r.title || '제목 없음')}</div>
|
|
824
|
+
<div class="list-item-meta">
|
|
825
|
+
<span>${this.escapeHtml(r.source || '')}</span>
|
|
826
|
+
<span>${r.created_at ? new Date(r.created_at).toLocaleDateString('ko-KR') : ''}</span>
|
|
827
|
+
<span class="badge ${r.status === 'INDEXED' ? 'success' : r.status === 'FAILED' ? 'error' : ''}">${r.status}</span>
|
|
828
|
+
</div>
|
|
829
|
+
</div>
|
|
830
|
+
<i class="fas fa-chevron-right" style="color:var(--text-tertiary);font-size:12px;flex-shrink:0;"></i>
|
|
831
|
+
</div>
|
|
832
|
+
`).join('');
|
|
833
|
+
|
|
834
|
+
document.getElementById('catalog-list').innerHTML = html || '<div class="empty-state">보고서 없음</div>';
|
|
835
|
+
|
|
836
|
+
// Pagination
|
|
837
|
+
const totalPages = Math.ceil(data.total / data.limit);
|
|
838
|
+
const paginationHtml = Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
839
|
+
.map(p => `<button class="button ${p === page ? 'button-primary' : ''}" onclick="app.loadCatalog(${p})">${p}</button>`)
|
|
840
|
+
.join('');
|
|
841
|
+
document.getElementById('catalog-pagination').innerHTML = paginationHtml;
|
|
842
|
+
} catch (e) {
|
|
843
|
+
console.error('Load catalog failed:', e);
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
// ─── 위키 (#30) ───
|
|
848
|
+
_wikiCurrentSlug: 'INDEX',
|
|
849
|
+
|
|
850
|
+
async loadWiki() {
|
|
851
|
+
await this.wikiNavigate('INDEX');
|
|
852
|
+
this._loadWikiNav();
|
|
853
|
+
},
|
|
854
|
+
|
|
855
|
+
async wikiNavigate(slug) {
|
|
856
|
+
this._wikiCurrentSlug = slug;
|
|
857
|
+
const el = document.getElementById('wiki-content');
|
|
858
|
+
el.innerHTML = '<div class="empty-state"><div class="loading"></div></div>';
|
|
859
|
+
try {
|
|
860
|
+
const encodedSlug = slug.split('/').map(encodeURIComponent).join('/');
|
|
861
|
+
const data = await fetch(`/api/wiki/${encodedSlug}`).then(r => r.json());
|
|
862
|
+
// YAML 프론트매터 제거 (--- ... --- 블록)
|
|
863
|
+
let content = data.content || '';
|
|
864
|
+
content = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
865
|
+
// 파일명 헤더 제거 (## topics/파일명.md ... 형식)
|
|
866
|
+
content = content.replace(/^##\s+[^\n]+\.md[^\n]*\n?/m, '');
|
|
867
|
+
// core_information 메타 블록 제거 (**core_information:** ~ *** 까지)
|
|
868
|
+
content = content.replace(/\*\*core_information:\*\*[\s\S]*?\*{3,}\s*/m, '');
|
|
869
|
+
// [제목](report:hash) → marked 전에 HTML span으로 변환 (report: 스킴은 브라우저가 해석 불가)
|
|
870
|
+
content = content.replace(/\[([^\]]+)\]\(report:([a-f0-9]+)\)/g,
|
|
871
|
+
(_, title, hash) => `<span class="report-cite-link" data-hash="${hash}" style="color:var(--color-primary,#4c6ef5);cursor:pointer;text-decoration:underline;">${title}</span>`);
|
|
872
|
+
// [[슬러그]] 위키 링크 → 클릭 가능한 마크다운 링크로 변환
|
|
873
|
+
content = content.replace(/\[\[([^\]]+)\]\]/g, (_, slug) => `[${slug}](#wiki-${slug.replace(/\s/g,'_')})`);
|
|
874
|
+
const rawHtml = marked.parse(content);
|
|
875
|
+
el.innerHTML = rawHtml;
|
|
876
|
+
// report-cite-link → showReportDetail 팝업
|
|
877
|
+
el.querySelectorAll('.report-cite-link').forEach(span => {
|
|
878
|
+
span.addEventListener('click', () => this.showReportDetail(span.dataset.hash));
|
|
879
|
+
});
|
|
880
|
+
// [[슬러그]] 링크 → wikiNavigate 연결
|
|
881
|
+
el.querySelectorAll('a[href^="#wiki-"]').forEach(a => {
|
|
882
|
+
const slug = decodeURIComponent(a.getAttribute('href').replace('#wiki-', '').replace(/_/g, ' '));
|
|
883
|
+
a.href = 'javascript:void(0)';
|
|
884
|
+
a.style.cursor = 'pointer';
|
|
885
|
+
a.addEventListener('click', e => { e.preventDefault(); this.wikiNavigate(slug); });
|
|
886
|
+
});
|
|
887
|
+
// 내부 위키 링크 인터셉트 [[slug]] → wikiNavigate
|
|
888
|
+
el.querySelectorAll('a').forEach(a => {
|
|
889
|
+
const href = a.getAttribute('href') || '';
|
|
890
|
+
if (href.startsWith('#') || href.startsWith('http') || href.startsWith('javascript')) return;
|
|
891
|
+
a.addEventListener('click', e => {
|
|
892
|
+
e.preventDefault();
|
|
893
|
+
const target = href.replace(/\.md$/, '').replace(/^\//, '');
|
|
894
|
+
this.wikiNavigate(target);
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
// 활성 nav 아이템 업데이트
|
|
898
|
+
document.querySelectorAll('#wiki-nav-list .wiki-nav-item').forEach(item => {
|
|
899
|
+
item.classList.toggle('active', item.dataset.slug === slug);
|
|
900
|
+
});
|
|
901
|
+
} catch (e) {
|
|
902
|
+
el.innerHTML = `<div class="empty-state">페이지를 찾을 수 없습니다: ${this.escapeHtml(slug)}</div>`;
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
|
|
906
|
+
async _loadWikiNav() {
|
|
907
|
+
try {
|
|
908
|
+
const data = await fetch('/api/wiki/INDEX').then(r => r.json());
|
|
909
|
+
// INDEX 내용에서 링크 추출
|
|
910
|
+
const links = [...(data.content || '').matchAll(/\[\[([^\]]+)\]\]/g)].map(m => m[1]);
|
|
911
|
+
const mdLinks = [...(data.content || '').matchAll(/\[([^\]]+)\]\(([^)]+\.md[^)]*)\)/g)].map(m => ({ label: m[1], slug: m[2].replace(/\.md$/, '') }));
|
|
912
|
+
const navList = document.getElementById('wiki-nav-list');
|
|
913
|
+
const items = [{ label: 'INDEX', slug: 'INDEX' }, ...links.map(l => ({ label: l, slug: l })), ...mdLinks];
|
|
914
|
+
const seen = new Set();
|
|
915
|
+
navList.innerHTML = items.filter(item => {
|
|
916
|
+
if (seen.has(item.slug)) return false;
|
|
917
|
+
seen.add(item.slug); return true;
|
|
918
|
+
}).map(item => `<div class="wiki-nav-item${item.slug === this._wikiCurrentSlug ? ' active' : ''}" data-slug="${this.escapeHtml(item.slug)}" onclick="window.app.wikiNavigate('${this.escapeHtml(item.slug)}')">${this.escapeHtml(item.label)}</div>`).join('');
|
|
919
|
+
} catch {}
|
|
920
|
+
},
|
|
921
|
+
|
|
922
|
+
// ─── 그래프 시각화 (#33) ───
|
|
923
|
+
_graph: { nodes: [], edges: [], sim: null, canvas: null, ctx: null, zoom: 1, panX: 0, panY: 0, dragging: null, hoveredNode: null },
|
|
924
|
+
|
|
925
|
+
setGraphType(btn) {
|
|
926
|
+
document.querySelectorAll('#graph-type-btns .graph-type-btn').forEach(b => b.classList.remove('button-primary'));
|
|
927
|
+
btn.classList.add('button-primary');
|
|
928
|
+
this.loadGraph();
|
|
929
|
+
},
|
|
930
|
+
|
|
931
|
+
async loadGraph() {
|
|
932
|
+
const typeFilter = document.querySelector('#graph-type-btns .graph-type-btn.button-primary')?.dataset.value || '';
|
|
933
|
+
const topN = parseInt(document.getElementById('graph-top-n')?.value || '100', 10);
|
|
934
|
+
const params = new URLSearchParams({ compact: 'true', top: topN });
|
|
935
|
+
if (typeFilter) params.set('type', typeFilter);
|
|
936
|
+
|
|
937
|
+
document.getElementById('graph-empty').style.display = 'flex';
|
|
938
|
+
try {
|
|
939
|
+
const data = await fetch(`/api/graph?${params}`).then(r => r.json());
|
|
940
|
+
const nodes = data.nodes || [];
|
|
941
|
+
const edges = data.edges || [];
|
|
942
|
+
|
|
943
|
+
document.getElementById('graph-node-count').textContent = (data.stats?.nodeCount ?? nodes.length).toLocaleString('ko-KR');
|
|
944
|
+
document.getElementById('graph-edge-count').textContent = (data.stats?.edgeCount ?? edges.length).toLocaleString('ko-KR');
|
|
945
|
+
document.getElementById('graph-cluster-count').textContent = data.stats?.communityCount ? data.stats.communityCount.toLocaleString('ko-KR') : '-';
|
|
946
|
+
|
|
947
|
+
document.getElementById('graph-empty').style.display = nodes.length ? 'none' : 'flex';
|
|
948
|
+
if (!nodes.length) { document.getElementById('graph-empty').querySelector('div:last-child').textContent = '그래프 데이터 없음'; return; }
|
|
949
|
+
|
|
950
|
+
this._initGraphCanvas(nodes, edges);
|
|
951
|
+
} catch (e) {
|
|
952
|
+
document.getElementById('graph-empty').querySelector('div:last-child').textContent = '그래프 로드 실패';
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
_initGraphCanvas(nodes, edges) {
|
|
957
|
+
const g = this._graph;
|
|
958
|
+
const container = document.getElementById('graph-canvas');
|
|
959
|
+
const canvas = document.getElementById('graph-canvas-el');
|
|
960
|
+
const ctx = canvas.getContext('2d');
|
|
961
|
+
g.canvas = canvas; g.ctx = ctx;
|
|
962
|
+
|
|
963
|
+
// 캔버스 크기 설정
|
|
964
|
+
const resize = () => {
|
|
965
|
+
canvas.width = container.clientWidth * devicePixelRatio;
|
|
966
|
+
canvas.height = container.clientHeight * devicePixelRatio;
|
|
967
|
+
canvas.style.width = container.clientWidth + 'px';
|
|
968
|
+
canvas.style.height = container.clientHeight + 'px';
|
|
969
|
+
// setTransform 으로 절대값 설정 — ctx.scale 누적 방지
|
|
970
|
+
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
|
971
|
+
};
|
|
972
|
+
resize();
|
|
973
|
+
// 이미 초기화된 경우 pan/zoom 유지 (재진입 시 화면 튐 방지)
|
|
974
|
+
const isFirstLoad = !g._initialized;
|
|
975
|
+
if (isFirstLoad) {
|
|
976
|
+
g.panX = container.clientWidth / 2;
|
|
977
|
+
g.panY = container.clientHeight / 2;
|
|
978
|
+
g.zoom = 1;
|
|
979
|
+
g._initialized = true;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// 전체 노드가 캔버스에 맞도록 자동 조정
|
|
983
|
+
const fitAll = () => {
|
|
984
|
+
if (!g.nodes.length) return;
|
|
985
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
986
|
+
for (const n of g.nodes) {
|
|
987
|
+
minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x);
|
|
988
|
+
minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y);
|
|
989
|
+
}
|
|
990
|
+
const w = container.clientWidth, h = container.clientHeight;
|
|
991
|
+
const pad = 60;
|
|
992
|
+
const scaleX = (w - pad * 2) / Math.max(maxX - minX, 1);
|
|
993
|
+
const scaleY = (h - pad * 2) / Math.max(maxY - minY, 1);
|
|
994
|
+
g.zoom = Math.min(scaleX, scaleY, 3);
|
|
995
|
+
g.panX = w / 2 - ((minX + maxX) / 2) * g.zoom;
|
|
996
|
+
g.panY = h / 2 - ((minY + maxY) / 2) * g.zoom;
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
// 색상 맵 — 진한 색상으로 업데이트
|
|
1000
|
+
const typeColors = { topic: '#ea6c00', org: '#1d6ed8', report: '#7c3aed', default: '#4b5563' };
|
|
1001
|
+
const clusterColors = ['#ea6c00','#1d6ed8','#7c3aed','#059669','#dc2626','#d97706','#0891b2','#db2777'];
|
|
1002
|
+
|
|
1003
|
+
// 노드 초기화 (위치는 아래에서 계층적으로 배치)
|
|
1004
|
+
g.nodes = nodes.map(n => ({ ...n, x: 0, y: 0, vx: 0, vy: 0 }));
|
|
1005
|
+
g.edges = edges;
|
|
1006
|
+
|
|
1007
|
+
// 인덱스 맵
|
|
1008
|
+
const nodeById = new Map(g.nodes.map(n => [n.id, n]));
|
|
1009
|
+
|
|
1010
|
+
// ─ 양방향 인접 맵 ─
|
|
1011
|
+
const adjacency = new Map(g.nodes.map(n => [n.id, []]));
|
|
1012
|
+
for (const e of g.edges) {
|
|
1013
|
+
adjacency.get(e.source)?.push(e.target);
|
|
1014
|
+
adjacency.get(e.target)?.push(e.source);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ─ 계층적 초기 배치 ─
|
|
1018
|
+
// 1) org: 바깥 원형
|
|
1019
|
+
const orgNodes = g.nodes.filter(n => n.type === 'org');
|
|
1020
|
+
const topicNodes = g.nodes.filter(n => n.type === 'topic');
|
|
1021
|
+
const otherNodes = g.nodes.filter(n => n.type !== 'org' && n.type !== 'topic');
|
|
1022
|
+
const orgR = Math.max(160, orgNodes.length * 70);
|
|
1023
|
+
|
|
1024
|
+
orgNodes.forEach((n, i) => {
|
|
1025
|
+
const angle = (i / Math.max(orgNodes.length, 1)) * Math.PI * 2;
|
|
1026
|
+
n.x = Math.cos(angle) * orgR + (Math.random() - 0.5) * 10;
|
|
1027
|
+
n.y = Math.sin(angle) * orgR + (Math.random() - 0.5) * 10;
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// 2) topic: org 방향 중간 링 (orgR * 0.60) — org 하위
|
|
1031
|
+
// topic → report → org 2단계 탐색으로 속한 org 방향 결정
|
|
1032
|
+
for (const n of topicNodes) {
|
|
1033
|
+
const reportNeighbors = (adjacency.get(n.id) || []).map(id => nodeById.get(id)).filter(nb => nb?.type === 'report');
|
|
1034
|
+
const orgScore = new Map();
|
|
1035
|
+
for (const rpt of reportNeighbors) {
|
|
1036
|
+
for (const orgId of (adjacency.get(rpt.id) || [])) {
|
|
1037
|
+
const orgNode = nodeById.get(orgId);
|
|
1038
|
+
if (orgNode?.type === 'org') orgScore.set(orgId, (orgScore.get(orgId) || 0) + 1);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
let bestOrg = null, bestScore = 0;
|
|
1042
|
+
for (const [orgId, score] of orgScore) {
|
|
1043
|
+
if (score > bestScore) { bestScore = score; bestOrg = nodeById.get(orgId); }
|
|
1044
|
+
}
|
|
1045
|
+
if (bestOrg) {
|
|
1046
|
+
const angle = Math.atan2(bestOrg.y, bestOrg.x);
|
|
1047
|
+
const dist = orgR * 0.60 + (Math.random() - 0.5) * 50;
|
|
1048
|
+
n.x = Math.cos(angle) * dist + (Math.random() - 0.5) * 35;
|
|
1049
|
+
n.y = Math.sin(angle) * dist + (Math.random() - 0.5) * 35;
|
|
1050
|
+
} else {
|
|
1051
|
+
n.x = (Math.random() - 0.5) * orgR * 0.6;
|
|
1052
|
+
n.y = (Math.random() - 0.5) * orgR * 0.6;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// 3) report: org 방향 안쪽 링 (orgR * 0.30) — topic 하위 (가장 내부)
|
|
1057
|
+
// report → org 직접 연결로 방향 결정
|
|
1058
|
+
for (const n of otherNodes) { // report
|
|
1059
|
+
const neighbors = adjacency.get(n.id) || [];
|
|
1060
|
+
const orgParent = neighbors.map(id => nodeById.get(id)).find(nb => nb?.type === 'org');
|
|
1061
|
+
if (orgParent) {
|
|
1062
|
+
const angle = Math.atan2(orgParent.y, orgParent.x);
|
|
1063
|
+
const dist = orgR * 0.30 + (Math.random() - 0.5) * 50;
|
|
1064
|
+
n.x = Math.cos(angle) * dist + (Math.random() - 0.5) * 30;
|
|
1065
|
+
n.y = Math.sin(angle) * dist + (Math.random() - 0.5) * 30;
|
|
1066
|
+
} else {
|
|
1067
|
+
n.x = (Math.random() - 0.5) * orgR * 0.3;
|
|
1068
|
+
n.y = (Math.random() - 0.5) * orgR * 0.3;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// ─ Force simulation (D3-스타일 alpha decay) ─
|
|
1073
|
+
// g._simAlpha: 1.0 → 0으로 감쇠, 0.005 미만이면 정지
|
|
1074
|
+
g._simAlpha = 1.0;
|
|
1075
|
+
|
|
1076
|
+
const simulate = () => {
|
|
1077
|
+
if (g._simAlpha < 0.005) return;
|
|
1078
|
+
const a = g._simAlpha;
|
|
1079
|
+
g._simAlpha *= 0.992; // 감쇠율 — 약 280프레임에서 0.1 이하
|
|
1080
|
+
|
|
1081
|
+
const repulsion = 1200;
|
|
1082
|
+
const spring = 0.04;
|
|
1083
|
+
const edgeLen = 90;
|
|
1084
|
+
|
|
1085
|
+
// 속도 감쇠 (마찰)
|
|
1086
|
+
for (const n of g.nodes) { n.vx *= 0.75; n.vy *= 0.75; }
|
|
1087
|
+
|
|
1088
|
+
// 중심 인력 — 노드가 화면 밖으로 탈출하지 못하도록
|
|
1089
|
+
for (const n of g.nodes) {
|
|
1090
|
+
n.vx -= n.x * 0.04 * a;
|
|
1091
|
+
n.vy -= n.y * 0.04 * a;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// 반발력 (샘플링으로 성능 최적화)
|
|
1095
|
+
const sample = g.nodes.length > 150 ? g.nodes.filter((_, i) => i % 3 === 0) : g.nodes;
|
|
1096
|
+
for (let i = 0; i < g.nodes.length; i++) {
|
|
1097
|
+
const na = g.nodes[i];
|
|
1098
|
+
for (const nb of sample) {
|
|
1099
|
+
if (na === nb) continue;
|
|
1100
|
+
const dx = na.x - nb.x, dy = na.y - nb.y;
|
|
1101
|
+
const d2 = dx * dx + dy * dy + 1;
|
|
1102
|
+
const f = (repulsion * a) / d2;
|
|
1103
|
+
na.vx += dx * f; na.vy += dy * f;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// 엣지 인력 (spring) — org 연결은 더 강하고 짧게
|
|
1108
|
+
for (const e of g.edges) {
|
|
1109
|
+
const na = nodeById.get(e.source), nb = nodeById.get(e.target);
|
|
1110
|
+
if (!na || !nb) continue;
|
|
1111
|
+
const isOrgEdge = na.type === 'org' || nb.type === 'org';
|
|
1112
|
+
const springK = isOrgEdge ? spring * 2.5 : spring;
|
|
1113
|
+
const targetLen = isOrgEdge ? edgeLen * 0.65 : edgeLen;
|
|
1114
|
+
const dx = nb.x - na.x, dy = nb.y - na.y;
|
|
1115
|
+
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
1116
|
+
const f = (d - targetLen) * springK * a;
|
|
1117
|
+
const fx = (dx / d) * f, fy = (dy / d) * f;
|
|
1118
|
+
// org는 더 무거워서 덜 움직임 (작은 노드가 주로 따라옴)
|
|
1119
|
+
const orgMass = 3;
|
|
1120
|
+
if (isOrgEdge) {
|
|
1121
|
+
if (na.type === 'org') { na.vx += fx / orgMass; na.vy += fy / orgMass; nb.vx -= fx; nb.vy -= fy; }
|
|
1122
|
+
else { na.vx += fx; na.vy += fy; nb.vx -= fx / orgMass; nb.vy -= fy / orgMass; }
|
|
1123
|
+
} else {
|
|
1124
|
+
na.vx += fx; na.vy += fy; nb.vx -= fx; nb.vy -= fy;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// 위치 업데이트 + 속도 상한 (폭발 방지)
|
|
1129
|
+
for (const n of g.nodes) {
|
|
1130
|
+
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
|
1131
|
+
if (speed > 15) { n.vx = (n.vx / speed) * 15; n.vy = (n.vy / speed) * 15; }
|
|
1132
|
+
n.x += n.vx; n.y += n.vy;
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
// 사전 워밍업: 80스텝 미리 실행 → 첫 프레임이 이미 안정된 배치로 시작
|
|
1137
|
+
g._simAlpha = 1.0;
|
|
1138
|
+
for (let i = 0; i < 80; i++) simulate();
|
|
1139
|
+
// 사전 워밍업 후 전체 보기 자동 맞춤
|
|
1140
|
+
if (isFirstLoad) fitAll();
|
|
1141
|
+
|
|
1142
|
+
const draw = () => {
|
|
1143
|
+
|
|
1144
|
+
const w = canvas.width / devicePixelRatio;
|
|
1145
|
+
const h = canvas.height / devicePixelRatio;
|
|
1146
|
+
ctx.clearRect(0, 0, w, h);
|
|
1147
|
+
ctx.save();
|
|
1148
|
+
ctx.translate(g.panX, g.panY);
|
|
1149
|
+
ctx.scale(g.zoom, g.zoom);
|
|
1150
|
+
|
|
1151
|
+
// 엣지 — primary-primary 엣지와 secondary 연결 엣지 구분
|
|
1152
|
+
// 1) secondary 연결 엣지 (더 연하게)
|
|
1153
|
+
ctx.strokeStyle = '#64748b';
|
|
1154
|
+
ctx.lineWidth = 0.8;
|
|
1155
|
+
ctx.globalAlpha = 0.35;
|
|
1156
|
+
ctx.beginPath();
|
|
1157
|
+
for (const e of g.edges) {
|
|
1158
|
+
const a = nodeById.get(e.source), b = nodeById.get(e.target);
|
|
1159
|
+
if (!a || !b) continue;
|
|
1160
|
+
if (!a.secondary && !b.secondary) continue; // primary-primary는 다음 패스
|
|
1161
|
+
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
|
|
1162
|
+
}
|
|
1163
|
+
ctx.stroke();
|
|
1164
|
+
// 2) primary-primary 엣지 (진하게)
|
|
1165
|
+
ctx.strokeStyle = '#94a3b8';
|
|
1166
|
+
ctx.lineWidth = 1.2;
|
|
1167
|
+
ctx.globalAlpha = 0.75;
|
|
1168
|
+
ctx.beginPath();
|
|
1169
|
+
for (const e of g.edges) {
|
|
1170
|
+
const a = nodeById.get(e.source), b = nodeById.get(e.target);
|
|
1171
|
+
if (!a || !b) continue;
|
|
1172
|
+
if (a.secondary || b.secondary) continue; // secondary 연결은 이미 그림
|
|
1173
|
+
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
|
|
1174
|
+
}
|
|
1175
|
+
ctx.stroke();
|
|
1176
|
+
ctx.globalAlpha = 1;
|
|
1177
|
+
|
|
1178
|
+
// 노드 — 타입 기반 크기: org(최대) → topic → report(최소), 각 약 2배 차이
|
|
1179
|
+
const typeBaseR = { org: 14, topic: 7, report: 4 };
|
|
1180
|
+
const typeMaxR = { org: 22, topic: 12, report: 7 };
|
|
1181
|
+
|
|
1182
|
+
// 현재 활성 타입 필터 — 색상 기준 결정
|
|
1183
|
+
const _activeTypeFilter = document.querySelector('#graph-type-btns .graph-type-btn.button-primary')?.dataset.value ?? '';
|
|
1184
|
+
const _maxDeg = g.nodes.length ? Math.max(...g.nodes.map(n => n.degree || 0), 1) : 1;
|
|
1185
|
+
|
|
1186
|
+
for (const n of g.nodes) {
|
|
1187
|
+
const isSecondary = !!n.secondary; // typeFilter 시 이웃 노드 (dim 처리)
|
|
1188
|
+
const base = typeBaseR[n.type] ?? 5;
|
|
1189
|
+
const max = typeMaxR[n.type] ?? 9;
|
|
1190
|
+
const rFull = Math.min(max, base + (n.degree || 0) * 0.25);
|
|
1191
|
+
const r = isSecondary ? rFull * 0.7 : rFull; // secondary는 70% 크기
|
|
1192
|
+
|
|
1193
|
+
// 필터별 색상 기준
|
|
1194
|
+
// 모든 타입: 타입별 고유색 (세 종류 구분)
|
|
1195
|
+
// 토픽/보고서: 커뮤니티 클러스터색 (그룹 파악)
|
|
1196
|
+
// 기관: 연결도 기반 명도 (허브 기관 강조)
|
|
1197
|
+
let color;
|
|
1198
|
+
if (_activeTypeFilter === '') {
|
|
1199
|
+
color = typeColors[n.type] || typeColors.default;
|
|
1200
|
+
} else if (_activeTypeFilter === 'org') {
|
|
1201
|
+
const t = (n.degree || 0) / _maxDeg;
|
|
1202
|
+
const l = Math.round(72 - t * 44);
|
|
1203
|
+
color = `hsl(215,78%,${l}%)`;
|
|
1204
|
+
} else {
|
|
1205
|
+
color = n.cluster_id != null
|
|
1206
|
+
? clusterColors[n.cluster_id % clusterColors.length]
|
|
1207
|
+
: (typeColors[n.type] || typeColors.default);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const isHover = n === g.hoveredNode;
|
|
1211
|
+
const labelColor = typeColors[n.type] || typeColors.default;
|
|
1212
|
+
|
|
1213
|
+
ctx.beginPath();
|
|
1214
|
+
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
|
1215
|
+
ctx.fillStyle = isHover ? '#ffffff' : color;
|
|
1216
|
+
ctx.globalAlpha = isSecondary ? 0.30 : 1;
|
|
1217
|
+
ctx.fill();
|
|
1218
|
+
ctx.strokeStyle = isHover ? color : (isSecondary ? 'rgba(0,0,0,0.08)' : 'rgba(0,0,0,0.18)');
|
|
1219
|
+
ctx.lineWidth = isHover ? 2.5 : 1;
|
|
1220
|
+
ctx.stroke();
|
|
1221
|
+
ctx.globalAlpha = 1;
|
|
1222
|
+
|
|
1223
|
+
// 레이블 — secondary 노드는 hover 시에만 표시
|
|
1224
|
+
if (!isSecondary || isHover) {
|
|
1225
|
+
const fontSize = Math.max(9, Math.min(13, 11 / g.zoom));
|
|
1226
|
+
ctx.font = `${isHover ? 'bold ' : ''}${fontSize}px -apple-system,sans-serif`;
|
|
1227
|
+
ctx.textAlign = 'center';
|
|
1228
|
+
const label = n.label || n.id;
|
|
1229
|
+
const tw = ctx.measureText(label).width;
|
|
1230
|
+
ctx.globalAlpha = isSecondary ? 0.55 : 1;
|
|
1231
|
+
ctx.fillStyle = 'rgba(255,255,255,0.88)';
|
|
1232
|
+
ctx.fillRect(n.x - tw/2 - 2, n.y - r - fontSize - 2, tw + 4, fontSize + 2);
|
|
1233
|
+
ctx.fillStyle = isHover ? '#000000' : labelColor;
|
|
1234
|
+
ctx.fillText(label, n.x, n.y - r - 3);
|
|
1235
|
+
ctx.globalAlpha = 1;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
ctx.restore();
|
|
1239
|
+
simulate();
|
|
1240
|
+
g._animFrame = requestAnimationFrame(draw);
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
if (g._animFrame) cancelAnimationFrame(g._animFrame);
|
|
1244
|
+
g._animFrame = requestAnimationFrame(draw);
|
|
1245
|
+
g._nodeById = nodeById;
|
|
1246
|
+
g._fitAll = fitAll;
|
|
1247
|
+
|
|
1248
|
+
// ─ 이벤트 ─
|
|
1249
|
+
const toGraph = (cx, cy) => ({
|
|
1250
|
+
x: (cx - g.panX) / g.zoom,
|
|
1251
|
+
y: (cy - g.panY) / g.zoom,
|
|
1252
|
+
});
|
|
1253
|
+
const hitNode = (gx, gy) => {
|
|
1254
|
+
const typeBaseR = { org: 14, topic: 7, report: 4 };
|
|
1255
|
+
const typeMaxR = { org: 22, topic: 12, report: 7 };
|
|
1256
|
+
for (const n of g.nodes) {
|
|
1257
|
+
const base = typeBaseR[n.type] ?? 5;
|
|
1258
|
+
const max = typeMaxR[n.type] ?? 9;
|
|
1259
|
+
const r = Math.min(max, base + (n.degree || 0) * 0.25) + 4; // +4 여유
|
|
1260
|
+
if ((n.x - gx) ** 2 + (n.y - gy) ** 2 < r * r) return n;
|
|
1261
|
+
}
|
|
1262
|
+
return null;
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
let lastPan = null;
|
|
1266
|
+
let dragPrev = null;
|
|
1267
|
+
|
|
1268
|
+
canvas.onmousedown = e => {
|
|
1269
|
+
const { x, y } = toGraph(e.offsetX, e.offsetY);
|
|
1270
|
+
const hit = hitNode(x, y);
|
|
1271
|
+
if (hit) {
|
|
1272
|
+
g.dragging = hit;
|
|
1273
|
+
dragPrev = { x, y };
|
|
1274
|
+
// org 드래그 시 연결된 모든 이웃 노드를 그룹으로 수집
|
|
1275
|
+
g.dragGroup = hit.type === 'org'
|
|
1276
|
+
? (adjacency.get(hit.id) || []).map(id => nodeById.get(id)).filter(Boolean)
|
|
1277
|
+
: [];
|
|
1278
|
+
} else {
|
|
1279
|
+
lastPan = { x: e.offsetX, y: e.offsetY };
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
canvas.onmousemove = e => {
|
|
1284
|
+
const { x, y } = toGraph(e.offsetX, e.offsetY);
|
|
1285
|
+
g.hoveredNode = hitNode(x, y);
|
|
1286
|
+
const tip = document.getElementById('graph-tooltip');
|
|
1287
|
+
if (g.hoveredNode) {
|
|
1288
|
+
tip.style.display = 'block';
|
|
1289
|
+
tip.style.left = (e.offsetX + 12) + 'px';
|
|
1290
|
+
tip.style.top = (e.offsetY - 8) + 'px';
|
|
1291
|
+
const n = g.hoveredNode;
|
|
1292
|
+
tip.innerHTML = `<strong>${this.escapeHtml(n.label || n.id)}</strong><br><span style="color:var(--text-tertiary)">${n.type} · degree ${n.degree || 0}</span>`;
|
|
1293
|
+
} else { tip.style.display = 'none'; }
|
|
1294
|
+
|
|
1295
|
+
if (g.dragging) {
|
|
1296
|
+
const dx = x - dragPrev.x;
|
|
1297
|
+
const dy = y - dragPrev.y;
|
|
1298
|
+
g.dragging.x = x; g.dragging.y = y;
|
|
1299
|
+
g.dragging.vx = 0; g.dragging.vy = 0;
|
|
1300
|
+
// 그룹 노드도 같은 delta 이동 (org의 자식들이 따라옴)
|
|
1301
|
+
for (const gn of (g.dragGroup || [])) {
|
|
1302
|
+
gn.x += dx; gn.y += dy;
|
|
1303
|
+
gn.vx = 0; gn.vy = 0;
|
|
1304
|
+
}
|
|
1305
|
+
dragPrev = { x, y };
|
|
1306
|
+
} else if (lastPan) {
|
|
1307
|
+
g.panX += e.offsetX - lastPan.x; g.panY += e.offsetY - lastPan.y;
|
|
1308
|
+
lastPan = { x: e.offsetX, y: e.offsetY };
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
canvas.onmouseup = () => { g.dragging = null; g.dragGroup = []; lastPan = null; dragPrev = null; };
|
|
1313
|
+
canvas.onmouseleave = () => { g.dragging = null; g.dragGroup = []; lastPan = null; dragPrev = null; document.getElementById('graph-tooltip').style.display = 'none'; };
|
|
1314
|
+
canvas.onclick = e => {
|
|
1315
|
+
const { x, y } = toGraph(e.offsetX, e.offsetY);
|
|
1316
|
+
const hit = hitNode(x, y);
|
|
1317
|
+
if (hit?.type === 'report') this.showReportDetail(hit.id.replace(/^report:/, ''));
|
|
1318
|
+
};
|
|
1319
|
+
canvas.onwheel = e => {
|
|
1320
|
+
e.preventDefault();
|
|
1321
|
+
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
|
1322
|
+
const ox = e.offsetX, oy = e.offsetY;
|
|
1323
|
+
g.panX = ox - (ox - g.panX) * factor;
|
|
1324
|
+
g.panY = oy - (oy - g.panY) * factor;
|
|
1325
|
+
g.zoom *= factor;
|
|
1326
|
+
g.zoom = Math.min(8, Math.max(0.1, g.zoom));
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
// 윈도우 리사이즈 대응 — 이전 핸들러 제거 후 재등록
|
|
1330
|
+
if (g._resizeHandler) window.removeEventListener('resize', g._resizeHandler);
|
|
1331
|
+
g._resizeHandler = () => resize();
|
|
1332
|
+
window.addEventListener('resize', g._resizeHandler);
|
|
1333
|
+
},
|
|
1334
|
+
|
|
1335
|
+
graphZoom(f) {
|
|
1336
|
+
const g = this._graph;
|
|
1337
|
+
const c = document.getElementById('graph-canvas');
|
|
1338
|
+
const cx = c.clientWidth / 2, cy = c.clientHeight / 2;
|
|
1339
|
+
g.panX = cx - (cx - g.panX) * f;
|
|
1340
|
+
g.panY = cy - (cy - g.panY) * f;
|
|
1341
|
+
g.zoom = Math.min(8, Math.max(0.1, g.zoom * f));
|
|
1342
|
+
},
|
|
1343
|
+
|
|
1344
|
+
graphReset() {
|
|
1345
|
+
const g = this._graph;
|
|
1346
|
+
if (g._fitAll) { g._fitAll(); return; }
|
|
1347
|
+
const c = document.getElementById('graph-canvas');
|
|
1348
|
+
g.panX = c.clientWidth / 2; g.panY = c.clientHeight / 2; g.zoom = 1;
|
|
1349
|
+
},
|
|
1350
|
+
|
|
1351
|
+
async exportGraphReport() {
|
|
1352
|
+
try {
|
|
1353
|
+
const r = await fetch('/api/graph/report', { method: 'POST' });
|
|
1354
|
+
const d = await r.json();
|
|
1355
|
+
if (d.ok) { alert('GRAPH_REPORT.md 생성 완료'); this.loadGraph(); }
|
|
1356
|
+
else alert('실패: ' + (d.error || ''));
|
|
1357
|
+
} catch (e) { alert('오류: ' + e.message); }
|
|
1358
|
+
},
|
|
1359
|
+
|
|
1360
|
+
async loadPending() {
|
|
1361
|
+
try {
|
|
1362
|
+
const data = await fetch('/api/pending').then(r => r.json());
|
|
1363
|
+
const all = data.items || [];
|
|
1364
|
+
|
|
1365
|
+
// ── 파이프라인 단계 정의 ──
|
|
1366
|
+
const STAGES = [
|
|
1367
|
+
{ key: 'waiting', label: '대기', color: '#6b7280', icon: '⏳' },
|
|
1368
|
+
{ key: 'queued', label: '큐 대기', color: '#64748b', icon: '📋' },
|
|
1369
|
+
{ key: 'downloading',label: '다운로드', color: '#f59e0b', icon: '⬇️' },
|
|
1370
|
+
{ key: 'converting', label: 'MD변환', color: '#f97316', icon: '📄' },
|
|
1371
|
+
{ key: 'extracting', label: 'AI추출', color: '#ec4899', icon: '🤖' },
|
|
1372
|
+
{ key: 'graph', label: '그래프저장',color: '#3b82f6', icon: '🔗' },
|
|
1373
|
+
{ key: 'wiki', label: '위키변환', color: '#8b5cf6', icon: '📖' },
|
|
1374
|
+
{ key: 'done', label: '완료', color: '#10b981', icon: '✅' },
|
|
1375
|
+
];
|
|
1376
|
+
|
|
1377
|
+
// 단계별 카운트
|
|
1378
|
+
const counts = {};
|
|
1379
|
+
for (const s of STAGES) counts[s.key] = 0;
|
|
1380
|
+
counts.failed = 0;
|
|
1381
|
+
for (const item of all) {
|
|
1382
|
+
if (item.status in counts) counts[item.status]++;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// ── 파이프라인 요약 렌더링 ──
|
|
1386
|
+
const pipelineEl = document.getElementById('pending-pipeline');
|
|
1387
|
+
if (pipelineEl) {
|
|
1388
|
+
const hasActivity = STAGES.some(s => counts[s.key] > 0) || counts.failed > 0;
|
|
1389
|
+
if (!hasActivity) {
|
|
1390
|
+
pipelineEl.style.display = 'none';
|
|
1391
|
+
} else {
|
|
1392
|
+
pipelineEl.style.display = 'flex';
|
|
1393
|
+
const parts = STAGES.map((s, i) => {
|
|
1394
|
+
const n = counts[s.key];
|
|
1395
|
+
const badge = `<span style="display:inline-flex;align-items:center;gap:3px;padding:3px 8px;border-radius:12px;background:${n > 0 ? s.color + '22' : 'transparent'};border:1px solid ${n > 0 ? s.color + '66' : 'var(--border-subtle)'};color:${n > 0 ? s.color : 'var(--text-tertiary)'};">${s.icon} ${s.label}<strong style="margin-left:3px;">${n}</strong></span>`;
|
|
1396
|
+
const arrow = i < STAGES.length - 1 ? `<span style="color:var(--text-tertiary);margin:0 2px;">→</span>` : '';
|
|
1397
|
+
return badge + arrow;
|
|
1398
|
+
}).join('');
|
|
1399
|
+
const failedBadge = counts.failed > 0
|
|
1400
|
+
? `<span style="margin-left:6px;padding:3px 8px;border-radius:12px;background:#ef444422;border:1px solid #ef444466;color:#ef4444;">❌ 실패 <strong>${counts.failed}</strong></span>`
|
|
1401
|
+
: '';
|
|
1402
|
+
pipelineEl.innerHTML = parts + failedBadge;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// ── 아이템 목록: 활성 단계 + 대기 표시, 완료/거절은 제외 ──
|
|
1407
|
+
const activeStatuses = new Set(['waiting', 'queued', 'downloading', 'converting', 'extracting', 'graph', 'wiki', 'failed']);
|
|
1408
|
+
const items = all.filter(item => activeStatuses.has(item.status));
|
|
1409
|
+
|
|
1410
|
+
// 정렬: 활성(downloading→converting→extracting→graph→wiki) → 큐 대기(queued) → 대기(waiting) → 실패(failed)
|
|
1411
|
+
const stageOrder = { downloading: 0, converting: 1, extracting: 2, graph: 3, wiki: 4, queued: 5, waiting: 6, failed: 7 };
|
|
1412
|
+
items.sort((a, b) => (stageOrder[a.status] ?? 9) - (stageOrder[b.status] ?? 9));
|
|
1413
|
+
|
|
1414
|
+
const html = items.map(item => {
|
|
1415
|
+
let d = {};
|
|
1416
|
+
try { d = JSON.parse(item.data || '{}'); } catch {}
|
|
1417
|
+
const title = d.title || item.docid || '제목 없음';
|
|
1418
|
+
const keyword = d.keyword ? `<span style="opacity:.7;">[${this.escapeHtml(d.keyword)}]</span>` : '';
|
|
1419
|
+
const isWaiting = item.status === 'waiting';
|
|
1420
|
+
const isActive = ['queued', 'downloading', 'converting', 'extracting', 'graph', 'wiki'].includes(item.status);
|
|
1421
|
+
const stage = STAGES.find(s => s.key === item.status) ?? { label: item.status, color: '#6b7280', icon: '?' };
|
|
1422
|
+
|
|
1423
|
+
// 활성 단계 애니메이션 점
|
|
1424
|
+
const spinner = isActive
|
|
1425
|
+
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${stage.color};margin-right:4px;animation:pulse 1.2s ease-in-out infinite;"></span>`
|
|
1426
|
+
: '';
|
|
1427
|
+
|
|
1428
|
+
const safeDocid = this.escapeHtml(item.docid);
|
|
1429
|
+
return `
|
|
1430
|
+
<div class="list-item" style="gap:var(--space-3);cursor:pointer;" onclick="app.showStageTrack('${safeDocid}')">
|
|
1431
|
+
<div style="flex:1;min-width:0;">
|
|
1432
|
+
<div class="list-item-title">${this.escapeHtml(title)}</div>
|
|
1433
|
+
<div class="list-item-meta" style="align-items:center;gap:var(--space-2);">
|
|
1434
|
+
<span style="display:inline-flex;align-items:center;padding:2px 7px;border-radius:10px;font-size:11px;background:${stage.color}22;border:1px solid ${stage.color}55;color:${stage.color};">${spinner}${stage.icon} ${stage.label}</span>
|
|
1435
|
+
<span>${this.escapeHtml(item.source||'')} ${keyword}</span>
|
|
1436
|
+
</div>
|
|
1437
|
+
</div>
|
|
1438
|
+
<div style="display:flex;gap:6px;flex-shrink:0;" onclick="event.stopPropagation()">
|
|
1439
|
+
${isWaiting ? `<button class="button" onclick="app.approvePending('${safeDocid}')" style="height:30px;padding:0 10px;font-size:var(--text-xs);border-radius:5px;">다운로드</button>` : ''}
|
|
1440
|
+
${isWaiting ? `<button onclick="app.rejectPending('${safeDocid}')" style="height:30px;padding:0 10px;font-size:var(--text-xs);border-radius:5px;border:1px solid var(--border);background:none;color:var(--text-secondary);cursor:pointer;">거절</button>` : ''}
|
|
1441
|
+
</div>
|
|
1442
|
+
</div>`;
|
|
1443
|
+
}).join('');
|
|
1444
|
+
|
|
1445
|
+
document.getElementById('pending-list').innerHTML = html || '<div class="empty-state"><div style="font-size:13px;color:var(--text-secondary);margin-bottom:6px;">수집 대기 항목 없음 ✓</div><div style="font-size:11px;color:var(--text-tertiary);">스케줄 탭에서 수집을 실행하거나 검색 결과에서 추가하세요</div></div>';
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
console.error('Load pending failed:', e);
|
|
1448
|
+
}
|
|
1449
|
+
},
|
|
1450
|
+
|
|
1451
|
+
async approvePending(id) {
|
|
1452
|
+
try {
|
|
1453
|
+
await fetch(`/api/pending/${id}/approve`, { method: 'POST' });
|
|
1454
|
+
this.loadPending();
|
|
1455
|
+
} catch (e) {
|
|
1456
|
+
alert('승인 실패');
|
|
1457
|
+
}
|
|
1458
|
+
},
|
|
1459
|
+
|
|
1460
|
+
async rejectPending(id) {
|
|
1461
|
+
try {
|
|
1462
|
+
await fetch(`/api/pending/${id}/reject`, { method: 'POST' });
|
|
1463
|
+
this.loadPending();
|
|
1464
|
+
} catch (e) {
|
|
1465
|
+
alert('거절 실패');
|
|
1466
|
+
}
|
|
1467
|
+
},
|
|
1468
|
+
|
|
1469
|
+
// ── 파일 상태 추적 패널 ──
|
|
1470
|
+
async showStageTrack(docid) {
|
|
1471
|
+
const overlay = document.getElementById('stage-track-overlay');
|
|
1472
|
+
const panel = document.getElementById('stage-track-panel');
|
|
1473
|
+
const content = document.getElementById('stage-track-content');
|
|
1474
|
+
if (!panel) return;
|
|
1475
|
+
|
|
1476
|
+
// 열기 & 로딩
|
|
1477
|
+
overlay.style.display = 'block';
|
|
1478
|
+
panel.style.display = 'flex';
|
|
1479
|
+
content.innerHTML = '<div class="loading" style="margin:40px auto;"></div>';
|
|
1480
|
+
|
|
1481
|
+
try {
|
|
1482
|
+
const item = await fetch(`/api/pending/${encodeURIComponent(docid)}`).then(r => r.json());
|
|
1483
|
+
let d = {};
|
|
1484
|
+
try { d = JSON.parse(item.data || '{}'); } catch {}
|
|
1485
|
+
const title = d.title || item.docid || '제목 없음';
|
|
1486
|
+
const stageLog = item.stage_log || [];
|
|
1487
|
+
|
|
1488
|
+
const STAGES = [
|
|
1489
|
+
{ key: 'waiting', label: '대기', color: '#6b7280', icon: '⏳' },
|
|
1490
|
+
{ key: 'queued', label: '큐 대기', color: '#64748b', icon: '📋' },
|
|
1491
|
+
{ key: 'downloading',label: '다운로드', color: '#f59e0b', icon: '⬇️' },
|
|
1492
|
+
{ key: 'converting', label: 'MD변환', color: '#f97316', icon: '📄' },
|
|
1493
|
+
{ key: 'extracting', label: 'AI추출', color: '#ec4899', icon: '🤖' },
|
|
1494
|
+
{ key: 'graph', label: '그래프저장', color: '#3b82f6', icon: '🔗' },
|
|
1495
|
+
{ key: 'wiki', label: '위키변환', color: '#8b5cf6', icon: '📖' },
|
|
1496
|
+
{ key: 'done', label: '완료', color: '#10b981', icon: '✅' },
|
|
1497
|
+
{ key: 'failed', label: '실패', color: '#ef4444', icon: '❌' },
|
|
1498
|
+
{ key: 'dismissed', label: '거절', color: '#6b7280', icon: '🚫' },
|
|
1499
|
+
];
|
|
1500
|
+
|
|
1501
|
+
const fmtDuration = ms => {
|
|
1502
|
+
if (ms < 1000) return `${ms}ms`;
|
|
1503
|
+
if (ms < 60000) return `${(ms/1000).toFixed(1)}초`;
|
|
1504
|
+
return `${Math.floor(ms/60000)}분 ${Math.floor((ms%60000)/1000)}초`;
|
|
1505
|
+
};
|
|
1506
|
+
const fmtTime = ts => new Date(ts).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
1507
|
+
|
|
1508
|
+
// 타임라인 렌더링
|
|
1509
|
+
const timelineHtml = stageLog.length === 0
|
|
1510
|
+
? '<p style="color:var(--text-tertiary);text-align:center;margin-top:40px;">기록 없음 (이전 항목)</p>'
|
|
1511
|
+
: stageLog.map((entry, i) => {
|
|
1512
|
+
const s = STAGES.find(x => x.key === entry.stage) ?? { label: entry.stage, color: '#6b7280', icon: '?' };
|
|
1513
|
+
const isLast = i === stageLog.length - 1;
|
|
1514
|
+
const isCurrent = isLast && !['done', 'failed', 'dismissed'].includes(entry.stage);
|
|
1515
|
+
return `
|
|
1516
|
+
<div style="display:flex;gap:12px;position:relative;">
|
|
1517
|
+
<!-- 세로선 -->
|
|
1518
|
+
${!isLast ? `<div style="position:absolute;left:11px;top:24px;bottom:-8px;width:2px;background:var(--border-subtle);"></div>` : ''}
|
|
1519
|
+
<!-- 원형 아이콘 -->
|
|
1520
|
+
<div style="flex-shrink:0;width:24px;height:24px;border-radius:50%;background:${s.color}22;border:2px solid ${s.color};display:flex;align-items:center;justify-content:center;font-size:12px;margin-top:2px;">
|
|
1521
|
+
${isCurrent ? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.color};animation:pulse 1.2s ease-in-out infinite;"></span>` : s.icon}
|
|
1522
|
+
</div>
|
|
1523
|
+
<!-- 내용 -->
|
|
1524
|
+
<div style="flex:1;padding-bottom:16px;">
|
|
1525
|
+
<div style="font-weight:600;font-size:var(--text-xs);color:${s.color};">${s.label}</div>
|
|
1526
|
+
<div style="font-size:11px;color:var(--text-tertiary);margin-top:2px;">${fmtTime(entry.ts)}</div>
|
|
1527
|
+
${entry.duration_ms > 0 ? `<div style="font-size:11px;color:var(--text-secondary);margin-top:3px;padding:2px 7px;background:var(--surface-raised);border-radius:4px;display:inline-block;">소요 ${fmtDuration(entry.duration_ms)}</div>` : ''}
|
|
1528
|
+
</div>
|
|
1529
|
+
</div>`;
|
|
1530
|
+
}).join('');
|
|
1531
|
+
|
|
1532
|
+
// 현재 단계 진행 중이면 다음 단계 예고
|
|
1533
|
+
const currentStage = item.status;
|
|
1534
|
+
const currentIdx = STAGES.findIndex(s => s.key === currentStage);
|
|
1535
|
+
const nextStage = currentIdx >= 0 && currentIdx < STAGES.length - 1 ? STAGES[currentIdx + 1] : null;
|
|
1536
|
+
const pendingNextHtml = (nextStage && !['done','failed','dismissed'].includes(currentStage))
|
|
1537
|
+
? `<div style="display:flex;gap:12px;opacity:.4;">
|
|
1538
|
+
<div style="flex-shrink:0;width:24px;height:24px;border-radius:50%;border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;font-size:12px;">${nextStage.icon}</div>
|
|
1539
|
+
<div style="padding-bottom:8px;"><div style="font-size:var(--text-xs);color:var(--text-tertiary);">${nextStage.label} (대기 중)</div></div>
|
|
1540
|
+
</div>`
|
|
1541
|
+
: '';
|
|
1542
|
+
|
|
1543
|
+
content.innerHTML = `
|
|
1544
|
+
<div style="margin-bottom:var(--space-4);">
|
|
1545
|
+
<div style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:4px;">${this.escapeHtml(item.source || '')}</div>
|
|
1546
|
+
<div style="font-weight:600;font-size:var(--text-sm);line-height:1.4;">${this.escapeHtml(title)}</div>
|
|
1547
|
+
<div style="font-size:11px;color:var(--text-tertiary);margin-top:4px;word-break:break-all;">${this.escapeHtml(item.docid)}</div>
|
|
1548
|
+
</div>
|
|
1549
|
+
<hr style="border:none;border-top:1px solid var(--border-subtle);margin-bottom:var(--space-4);">
|
|
1550
|
+
<div style="font-size:var(--text-xs);font-weight:600;color:var(--text-secondary);margin-bottom:var(--space-3);">처리 단계 이력</div>
|
|
1551
|
+
<div>${timelineHtml}</div>
|
|
1552
|
+
${pendingNextHtml}
|
|
1553
|
+
`;
|
|
1554
|
+
|
|
1555
|
+
// 실시간 갱신: SSE item_stage 이벤트 시 자동 새로고침
|
|
1556
|
+
this._trackingDocid = docid;
|
|
1557
|
+
} catch (e) {
|
|
1558
|
+
content.innerHTML = '<p style="color:var(--error);padding:20px;">로드 실패</p>';
|
|
1559
|
+
}
|
|
1560
|
+
},
|
|
1561
|
+
|
|
1562
|
+
closeStageTrack() {
|
|
1563
|
+
document.getElementById('stage-track-overlay').style.display = 'none';
|
|
1564
|
+
document.getElementById('stage-track-panel').style.display = 'none';
|
|
1565
|
+
this._trackingDocid = null;
|
|
1566
|
+
},
|
|
1567
|
+
|
|
1568
|
+
// ── 파이프라인 상태바 칩 렌더링 ──
|
|
1569
|
+
renderPipelineBar(p, failCount) {
|
|
1570
|
+
const el = document.getElementById('status-jobs');
|
|
1571
|
+
if (!el) return;
|
|
1572
|
+
const PIPE = [
|
|
1573
|
+
{ key: 'waiting', label: '대기', color: '#6b7280' },
|
|
1574
|
+
{ key: 'queued', label: '큐대기', color: '#64748b' },
|
|
1575
|
+
{ key: 'downloading',label: '다운로드',color: '#f59e0b' },
|
|
1576
|
+
{ key: 'converting', label: 'MD변환', color: '#f97316' },
|
|
1577
|
+
{ key: 'extracting', label: 'AI추출', color: '#ec4899' },
|
|
1578
|
+
{ key: 'graph', label: '그래프', color: '#3b82f6' },
|
|
1579
|
+
{ key: 'wiki', label: '위키', color: '#8b5cf6' },
|
|
1580
|
+
{ key: 'done', label: '완료', color: '#10b981' },
|
|
1581
|
+
{ key: 'failed', label: '실패', color: '#ef4444' },
|
|
1582
|
+
];
|
|
1583
|
+
el.innerHTML = PIPE.map((s, i) => {
|
|
1584
|
+
const n = s.key === 'failed' ? (failCount || 0) : (p[s.key] || 0);
|
|
1585
|
+
const active = n > 0;
|
|
1586
|
+
const chipStyle = active ? `color:${s.color};` : '';
|
|
1587
|
+
const clickAttr = active
|
|
1588
|
+
? `onclick="app.showStagePill('${s.key}','${s.label}','${s.color}')" title="${s.label} ${n}건 보기"`
|
|
1589
|
+
: '';
|
|
1590
|
+
const chip = `<button class="stage-chip${active ? ' active' : ''}" style="${chipStyle}" ${clickAttr}>${s.label}(${n})</button>`;
|
|
1591
|
+
const sep = i < PIPE.length - 1 ? `<span class="stage-chip-sep">|</span>` : '';
|
|
1592
|
+
return chip + sep;
|
|
1593
|
+
}).join('');
|
|
1594
|
+
},
|
|
1595
|
+
|
|
1596
|
+
// ── 파이프라인 단계 표시줄 렌더링 ──
|
|
1597
|
+
_renderStagePipe(status) {
|
|
1598
|
+
if (status === 'waiting' || status === 'queued') return '';
|
|
1599
|
+
const PCT = { downloading: 35, converting: 55, extracting: 70, graph: 85, wiki: 95, done: 100 };
|
|
1600
|
+
const LABEL = { downloading: '다운로드 중', converting: 'MD 변환 중', extracting: 'AI 추출 중', graph: '그래프 구축 중', wiki: '위키 생성 중', done: '완료', failed: '실패' };
|
|
1601
|
+
const isFailed = status === 'failed';
|
|
1602
|
+
const isDone = status === 'done';
|
|
1603
|
+
const pct = isFailed ? 0 : (PCT[status] ?? 0);
|
|
1604
|
+
const label = LABEL[status] ?? status;
|
|
1605
|
+
const color = isDone ? 'var(--success,#27ae60)' : isFailed ? 'var(--error,#e74c3c)' : 'var(--accent)';
|
|
1606
|
+
const textColor = isFailed ? 'var(--error,#e74c3c)' : isDone ? 'var(--success,#27ae60)' : 'var(--text-tertiary)';
|
|
1607
|
+
return `<div style="margin-top:6px;">
|
|
1608
|
+
<div style="height:4px;background:var(--border-subtle,#eee);border-radius:2px;overflow:hidden;">
|
|
1609
|
+
<div style="height:100%;width:${pct}%;background:${color};border-radius:2px;transition:width .4s ease;"></div>
|
|
1610
|
+
</div>
|
|
1611
|
+
<div style="margin-top:3px;font-size:10px;color:${textColor};display:flex;justify-content:space-between;align-items:center;">
|
|
1612
|
+
<span>${label}</span><span style="font-variant-numeric:tabular-nums;">${pct}%</span>
|
|
1613
|
+
</div>
|
|
1614
|
+
</div>`;
|
|
1615
|
+
},
|
|
1616
|
+
|
|
1617
|
+
// ── 단계 토스트 본문만 갱신 (폴링용) ──
|
|
1618
|
+
async _refreshStagePillBody(stage) {
|
|
1619
|
+
const body = document.getElementById('stage-toast-body');
|
|
1620
|
+
if (!body) return;
|
|
1621
|
+
try {
|
|
1622
|
+
let rows = [];
|
|
1623
|
+
if (stage === 'failed') {
|
|
1624
|
+
const data = await fetch('/api/failures?dismissed=false').then(r => r.json());
|
|
1625
|
+
rows = (data.items || []).map(it => ({
|
|
1626
|
+
title: it.title || it.docid || '제목 없음',
|
|
1627
|
+
sub: it.source || '',
|
|
1628
|
+
status: 'failed',
|
|
1629
|
+
}));
|
|
1630
|
+
} else {
|
|
1631
|
+
const data = await fetch('/api/pending').then(r => r.json());
|
|
1632
|
+
rows = (data.items || [])
|
|
1633
|
+
.filter(it => it.status === stage)
|
|
1634
|
+
.map(it => {
|
|
1635
|
+
let d = {};
|
|
1636
|
+
try { d = JSON.parse(it.data || '{}'); } catch {}
|
|
1637
|
+
return { title: d.title || it.docid || '제목 없음', sub: it.source || '', status: it.status };
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
if (rows.length === 0) {
|
|
1641
|
+
body.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-tertiary);font-size:var(--text-xs);">항목 없음</div>';
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
body.innerHTML = rows.map(r => `
|
|
1645
|
+
<div class="stage-toast-item">
|
|
1646
|
+
<div class="stage-toast-item-title">${this.escapeHtml(r.title)}</div>
|
|
1647
|
+
${r.sub ? `<div class="stage-toast-item-sub">${this.escapeHtml(r.sub)}</div>` : ''}
|
|
1648
|
+
${this._renderStagePipe(r.status)}
|
|
1649
|
+
</div>`).join('');
|
|
1650
|
+
} catch {
|
|
1651
|
+
/* 폴링 실패는 무시 */
|
|
1652
|
+
}
|
|
1653
|
+
},
|
|
1654
|
+
|
|
1655
|
+
// ── 단계 토스트 표시 (아래서 위로 슬라이드) ──
|
|
1656
|
+
async showStagePill(stage, label, color) {
|
|
1657
|
+
// 기존 토스트 + 갱신 인터벌 닫기
|
|
1658
|
+
this.closeStageToast();
|
|
1659
|
+
const toast = document.getElementById('stage-toast');
|
|
1660
|
+
const titleEl = document.getElementById('stage-toast-title');
|
|
1661
|
+
const body = document.getElementById('stage-toast-body');
|
|
1662
|
+
if (!toast) return;
|
|
1663
|
+
|
|
1664
|
+
this._stageToastCurrentStage = stage;
|
|
1665
|
+
titleEl.textContent = `${label} 항목`;
|
|
1666
|
+
titleEl.style.color = color;
|
|
1667
|
+
body.innerHTML = '<div style="padding:20px;text-align:center;"><div class="loading" style="margin:0 auto;width:20px;height:20px;"></div></div>';
|
|
1668
|
+
|
|
1669
|
+
toast.style.display = 'flex';
|
|
1670
|
+
requestAnimationFrame(() => requestAnimationFrame(() => toast.classList.add('open')));
|
|
1671
|
+
|
|
1672
|
+
await this._refreshStagePillBody(stage);
|
|
1673
|
+
|
|
1674
|
+
// 2초마다 자동 갱신 (진행 상태 실시간 반영)
|
|
1675
|
+
this._stageToastTimer = setInterval(() => this._refreshStagePillBody(stage), 2000);
|
|
1676
|
+
},
|
|
1677
|
+
|
|
1678
|
+
closeStageToast() {
|
|
1679
|
+
if (this._stageToastTimer) {
|
|
1680
|
+
clearInterval(this._stageToastTimer);
|
|
1681
|
+
this._stageToastTimer = null;
|
|
1682
|
+
}
|
|
1683
|
+
this._stageToastCurrentStage = null;
|
|
1684
|
+
const toast = document.getElementById('stage-toast');
|
|
1685
|
+
if (!toast) return;
|
|
1686
|
+
toast.classList.remove('open');
|
|
1687
|
+
setTimeout(() => { if (!toast.classList.contains('open')) toast.style.display = 'none'; }, 200);
|
|
1688
|
+
},
|
|
1689
|
+
|
|
1690
|
+
async loadFailures() {
|
|
1691
|
+
try {
|
|
1692
|
+
const data = await fetch('/api/failures?dismissed=false').then(r => r.json());
|
|
1693
|
+
const html = (data.items || [])
|
|
1694
|
+
.map(item => `
|
|
1695
|
+
<div class="list-item" data-hash="${item.hash}" data-docid="${item.docid || item.hash}">
|
|
1696
|
+
<div style="flex:1;min-width:0;">
|
|
1697
|
+
<div class="list-item-title" style="cursor:pointer;" onclick="window.app.showReportDetail('${item.hash}')">${this.escapeHtml(item.title || '제목 없음')}</div>
|
|
1698
|
+
<div class="list-item-meta" style="flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-1);">
|
|
1699
|
+
<span class="badge error failure-status-badge" data-hash="${item.hash}">${item.status}</span>
|
|
1700
|
+
${item.failed_stage ? `<span class="badge" style="background:var(--warning-bg,#fff3cd);color:var(--warning,#856404);font-size:var(--text-xs);">${item.failed_stage}</span>` : ''}
|
|
1701
|
+
${item.attempt != null ? `<span style="font-size:var(--text-xs);color:var(--text-secondary);">시도 ${item.attempt}회</span>` : ''}
|
|
1702
|
+
<span style="color:var(--error);font-size:var(--text-xs);max-width:500px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" title="${this.escapeHtml(item.failed_reason || item.last_error || item.error_message || '')}" class="failure-error-msg" data-hash="${item.hash}">${this.escapeHtml(item.failed_reason || item.last_error || item.error_message || '')}</span>
|
|
1703
|
+
${item.source ? `<span>${this.escapeHtml(item.source)}</span>` : ''}
|
|
1704
|
+
</div>
|
|
1705
|
+
<!-- 진행상황 바 (재시도 시 표시) -->
|
|
1706
|
+
<div class="failure-progress" data-hash="${item.hash}" style="display:none;margin-top:var(--space-2);">
|
|
1707
|
+
<div style="display:flex;align-items:center;gap:var(--space-2);">
|
|
1708
|
+
<div style="flex:1;height:4px;background:var(--border);border-radius:2px;overflow:hidden;">
|
|
1709
|
+
<div class="failure-progress-bar" data-hash="${item.hash}" style="height:100%;background:var(--accent);width:0%;transition:width 0.4s;border-radius:2px;"></div>
|
|
1710
|
+
</div>
|
|
1711
|
+
<span class="failure-progress-label" data-hash="${item.hash}" style="font-size:var(--text-xs);color:var(--text-secondary);white-space:nowrap;min-width:80px;text-align:right;"></span>
|
|
1712
|
+
</div>
|
|
1713
|
+
</div>
|
|
1714
|
+
</div>
|
|
1715
|
+
<div style="display:flex;gap:var(--space-2);flex-shrink:0;">
|
|
1716
|
+
<button class="button failure-retry-btn" data-hash="${item.hash}" onclick="window.app.retryFailure('${item.hash}')"><i class="fas fa-redo"></i></button>
|
|
1717
|
+
<button class="button button-danger" onclick="window.app.dismissFailure('${item.hash}')"><i class="fas fa-eye-slash"></i></button>
|
|
1718
|
+
</div>
|
|
1719
|
+
</div>
|
|
1720
|
+
`).join('');
|
|
1721
|
+
document.getElementById('failures-list').innerHTML = html || '<div class="empty-state">실패 항목 없음 ✓</div>';
|
|
1722
|
+
} catch (e) {
|
|
1723
|
+
console.error('Load failures failed:', e);
|
|
1724
|
+
}
|
|
1725
|
+
},
|
|
1726
|
+
|
|
1727
|
+
async retryFailure(hash) {
|
|
1728
|
+
try {
|
|
1729
|
+
const r = await fetch(`/api/failures/${hash}/retry`, { method: 'POST' });
|
|
1730
|
+
if (r.ok) {
|
|
1731
|
+
// 즉시 UI 상태 업데이트 (SSE 연결 전 피드백)
|
|
1732
|
+
this._setFailureProgress(hash, 5, '재시도 대기 중...');
|
|
1733
|
+
const btn = document.querySelector(`.failure-retry-btn[data-hash="${hash}"]`);
|
|
1734
|
+
if (btn) btn.disabled = true;
|
|
1735
|
+
const badge = document.querySelector(`.failure-status-badge[data-hash="${hash}"]`);
|
|
1736
|
+
if (badge) { badge.textContent = '대기중'; badge.className = 'badge failure-status-badge'; badge.style.background = '#888'; }
|
|
1737
|
+
} else alert('재시도 실패');
|
|
1738
|
+
} catch (e) { alert('오류: ' + e.message); }
|
|
1739
|
+
},
|
|
1740
|
+
|
|
1741
|
+
_setFailureProgress(hash, pct, label, done = false, error = false) {
|
|
1742
|
+
const bar = document.querySelector(`.failure-progress-bar[data-hash="${hash}"]`);
|
|
1743
|
+
const lbl = document.querySelector(`.failure-progress-label[data-hash="${hash}"]`);
|
|
1744
|
+
const wrap = document.querySelector(`.failure-progress[data-hash="${hash}"]`);
|
|
1745
|
+
if (!wrap) return;
|
|
1746
|
+
wrap.style.display = 'block';
|
|
1747
|
+
if (bar) { bar.style.width = `${pct}%`; bar.style.background = error ? 'var(--error)' : 'var(--accent)'; }
|
|
1748
|
+
if (lbl) lbl.textContent = label;
|
|
1749
|
+
if (done || error) {
|
|
1750
|
+
setTimeout(() => {
|
|
1751
|
+
wrap.style.display = 'none';
|
|
1752
|
+
if (done) this.loadFailures();
|
|
1753
|
+
}, done ? 1500 : 4000);
|
|
1754
|
+
}
|
|
1755
|
+
},
|
|
1756
|
+
|
|
1757
|
+
async retryAllFailures() {
|
|
1758
|
+
try {
|
|
1759
|
+
const r = await fetch('/api/failures/retry-all', { method: 'POST' });
|
|
1760
|
+
const d = await r.json();
|
|
1761
|
+
if (d.ok) { alert(`${d.queued ?? '전체'} 건 재시도 큐 등록`); this.loadFailures(); }
|
|
1762
|
+
else alert('전체 재시도 실패');
|
|
1763
|
+
} catch (e) { alert('오류: ' + e.message); }
|
|
1764
|
+
},
|
|
1765
|
+
|
|
1766
|
+
async dismissFailure(hash) {
|
|
1767
|
+
try {
|
|
1768
|
+
await fetch(`/api/failures/${hash}/dismiss`, { method: 'POST' });
|
|
1769
|
+
this.loadFailures();
|
|
1770
|
+
} catch (e) { alert('무시 실패'); }
|
|
1771
|
+
},
|
|
1772
|
+
|
|
1773
|
+
async dismissAllFailures() {
|
|
1774
|
+
if (!confirm('모든 실패 항목을 목록에서 숨기시겠습니까?\n(삭제가 아니라 목록에서 숨기는 것입니다)')) return;
|
|
1775
|
+
try {
|
|
1776
|
+
await fetch('/api/failures/dismiss-all', { method: 'POST' });
|
|
1777
|
+
this.loadFailures(); this.loadStats();
|
|
1778
|
+
} catch (e) { alert('오류: ' + e.message); }
|
|
1779
|
+
},
|
|
1780
|
+
|
|
1781
|
+
async resetAllData() {
|
|
1782
|
+
const confirmed = confirm(
|
|
1783
|
+
'⚠️ 전체 초기화\n\n다음 항목이 모두 삭제됩니다:\n• 수집된 보고서, 재시도 큐, 캐시, 이력\n• 위키 파일 전체\n• 그래프 DB\n• inbox, archive, logs 폴더 내용\n\n인증정보·스케줄 설정·backup 폴더는 유지됩니다.\n\n정말 계속하시겠습니까?'
|
|
1784
|
+
);
|
|
1785
|
+
if (!confirmed) return;
|
|
1786
|
+
try {
|
|
1787
|
+
const r = await fetch('/api/reset', { method: 'POST' });
|
|
1788
|
+
const d = await r.json();
|
|
1789
|
+
if (d.ok) {
|
|
1790
|
+
alert('초기화 완료. 페이지를 새로고침합니다.');
|
|
1791
|
+
location.reload();
|
|
1792
|
+
} else {
|
|
1793
|
+
alert('초기화 실패: ' + (d.error ?? '알 수 없는 오류'));
|
|
1794
|
+
}
|
|
1795
|
+
} catch (e) { alert('오류: ' + e.message); }
|
|
1796
|
+
},
|
|
1797
|
+
|
|
1798
|
+
async loadSchedules() {
|
|
1799
|
+
try {
|
|
1800
|
+
const data = await fetch('/api/schedules').then(r => r.json());
|
|
1801
|
+
const typeLabel = { daily: '매일', weekly: '매주', interval: '주기', cron: 'Cron', manual: '수동' };
|
|
1802
|
+
const html = (data.items || [])
|
|
1803
|
+
.map(item => {
|
|
1804
|
+
let sourcesArr = [];
|
|
1805
|
+
try { sourcesArr = JSON.parse(item.sources); } catch { sourcesArr = item.sources ? item.sources.split(',').map(s => s.trim()) : []; }
|
|
1806
|
+
const sourcesDisplay = Array.isArray(sourcesArr) ? sourcesArr.join(', ') : (item.sources || '');
|
|
1807
|
+
const schedLabel = (typeLabel[item.schedule_type] || item.schedule_type) + (item.schedule_value ? ` (${item.schedule_value})` : '');
|
|
1808
|
+
return `
|
|
1809
|
+
<div class="list-item" style="gap:var(--space-3);">
|
|
1810
|
+
<div style="flex:1;min-width:0;">
|
|
1811
|
+
<div class="list-item-title">${this.escapeHtml(item.keyword)}</div>
|
|
1812
|
+
<div class="list-item-meta">
|
|
1813
|
+
<span>${this.escapeHtml(schedLabel)}</span>
|
|
1814
|
+
<span>${this.escapeHtml(sourcesDisplay)}</span>
|
|
1815
|
+
</div>
|
|
1816
|
+
</div>
|
|
1817
|
+
<div style="display:flex;gap:6px;flex-shrink:0;">
|
|
1818
|
+
<button id="run-btn-${item.id}" onclick="app.runScheduleNow(${item.id}, '${this.escapeHtml(item.keyword).replace(/'/g, "\\'")}')"
|
|
1819
|
+
style="height:30px;padding:0 10px;font-size:var(--text-xs);border-radius:5px;border:1px solid var(--accent);background:none;color:var(--accent);cursor:pointer;font-weight:500;display:flex;align-items:center;gap:4px;">
|
|
1820
|
+
<i class="fas fa-play"></i> 지금 실행
|
|
1821
|
+
</button>
|
|
1822
|
+
<button onclick="app.toggleSchedule(${item.id})"
|
|
1823
|
+
style="height:30px;padding:0 10px;font-size:var(--text-xs);border-radius:5px;border:1px solid var(--border);background:${item.is_active ? 'var(--accent)' : 'none'};color:${item.is_active ? '#fff' : 'var(--text-secondary)'};cursor:pointer;font-weight:500;">
|
|
1824
|
+
${item.is_active ? '활성' : '비활성'}
|
|
1825
|
+
</button>
|
|
1826
|
+
<button onclick="app.editSchedule(${item.id})"
|
|
1827
|
+
style="height:30px;padding:0 10px;font-size:var(--text-xs);border-radius:5px;border:1px solid var(--border);background:none;color:var(--text-secondary);cursor:pointer;">
|
|
1828
|
+
<i class="fas fa-pencil-alt"></i>
|
|
1829
|
+
</button>
|
|
1830
|
+
<button onclick="app.deleteSchedule(${item.id}, '${this.escapeHtml(item.keyword).replace(/'/g, "\\'")}')"
|
|
1831
|
+
style="height:30px;padding:0 10px;font-size:var(--text-xs);border-radius:5px;border:1px solid var(--border);background:none;color:var(--error,#e74c3c);cursor:pointer;">
|
|
1832
|
+
<i class="fas fa-trash"></i>
|
|
1833
|
+
</button>
|
|
1834
|
+
</div>
|
|
1835
|
+
</div>
|
|
1836
|
+
`}).join('');
|
|
1837
|
+
document.getElementById('schedules-list').innerHTML = html || '<div class="empty-state">스케줄 없음</div>';
|
|
1838
|
+
} catch (e) {
|
|
1839
|
+
console.error('Load schedules failed:', e);
|
|
1840
|
+
}
|
|
1841
|
+
},
|
|
1842
|
+
|
|
1843
|
+
toggleScheduleForm() {
|
|
1844
|
+
const form = document.getElementById('schedule-form');
|
|
1845
|
+
form.classList.toggle('hidden');
|
|
1846
|
+
if (!form.classList.contains('hidden')) {
|
|
1847
|
+
window.app.loadScheduleSources();
|
|
1848
|
+
}
|
|
1849
|
+
},
|
|
1850
|
+
|
|
1851
|
+
async loadScheduleSources() {
|
|
1852
|
+
const container = document.getElementById('schedule-sources-list');
|
|
1853
|
+
if (!container) return;
|
|
1854
|
+
try {
|
|
1855
|
+
const data = await fetch('/api/skills').then(r => r.json());
|
|
1856
|
+
const skills = data.skills || [];
|
|
1857
|
+
if (skills.length === 0) {
|
|
1858
|
+
container.innerHTML = '<span style="color:var(--text-tertiary);font-size:var(--text-sm);">등록된 소스 없음</span>';
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
container.innerHTML = skills.map(s => `
|
|
1862
|
+
<label style="display:flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:3px;cursor:pointer;font-size:var(--text-xs);user-select:none;transition:background .12s;">
|
|
1863
|
+
<input type="checkbox" value="${s.id}" style="accent-color:var(--accent);" />
|
|
1864
|
+
${this.escapeHtml(s.name || s.id)}
|
|
1865
|
+
</label>
|
|
1866
|
+
`).join('');
|
|
1867
|
+
} catch (e) {
|
|
1868
|
+
container.innerHTML = '<span style="color:var(--error);font-size:var(--text-sm);">소스 목록 로드 실패</span>';
|
|
1869
|
+
}
|
|
1870
|
+
},
|
|
1871
|
+
|
|
1872
|
+
_updateScheduleTypeUI(type) {
|
|
1873
|
+
const timeG = document.getElementById('schedule-time-group');
|
|
1874
|
+
const dowG = document.getElementById('schedule-dow-group');
|
|
1875
|
+
const intG = document.getElementById('schedule-interval-group');
|
|
1876
|
+
if (!timeG) return;
|
|
1877
|
+
timeG.style.display = (type === 'daily' || type === 'weekly') ? '' : 'none';
|
|
1878
|
+
dowG.style.display = (type === 'weekly') ? '' : 'none';
|
|
1879
|
+
intG.style.display = (type === 'interval') ? '' : 'none';
|
|
1880
|
+
},
|
|
1881
|
+
|
|
1882
|
+
async createSchedule() {
|
|
1883
|
+
try {
|
|
1884
|
+
const keyword = document.getElementById('schedule-keyword').value.trim();
|
|
1885
|
+
const sourcesArr = [...document.querySelectorAll('#schedule-sources-list input[type=checkbox]:checked')].map(c => c.value);
|
|
1886
|
+
const sources = JSON.stringify(sourcesArr);
|
|
1887
|
+
if (!keyword) { alert('키워드를 입력하세요.'); return; }
|
|
1888
|
+
if (!sourcesArr.length) { alert('소스를 1개 이상 선택하세요.'); return; }
|
|
1889
|
+
const scheduleType = document.getElementById('schedule-type').value;
|
|
1890
|
+
// schedule_value 구성 — 타입별
|
|
1891
|
+
let scheduleValue = null;
|
|
1892
|
+
if (scheduleType === 'daily') {
|
|
1893
|
+
scheduleValue = document.getElementById('schedule-time').value || '09:00';
|
|
1894
|
+
} else if (scheduleType === 'weekly') {
|
|
1895
|
+
const dow = document.getElementById('schedule-dow').value;
|
|
1896
|
+
const time = document.getElementById('schedule-time').value || '09:00';
|
|
1897
|
+
scheduleValue = `${dow} ${time}`;
|
|
1898
|
+
} else if (scheduleType === 'interval') {
|
|
1899
|
+
scheduleValue = document.getElementById('schedule-interval').value.trim() || '6h';
|
|
1900
|
+
}
|
|
1901
|
+
const data = {
|
|
1902
|
+
keyword,
|
|
1903
|
+
sources,
|
|
1904
|
+
schedule_type: scheduleType,
|
|
1905
|
+
schedule_value: scheduleValue,
|
|
1906
|
+
auto_approve: document.getElementById('schedule-auto-approve').checked,
|
|
1907
|
+
};
|
|
1908
|
+
await fetch('/api/schedules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
|
1909
|
+
this.toggleScheduleForm();
|
|
1910
|
+
this.loadSchedules();
|
|
1911
|
+
} catch (e) {
|
|
1912
|
+
alert('스케줄 생성 실패');
|
|
1913
|
+
}
|
|
1914
|
+
},
|
|
1915
|
+
|
|
1916
|
+
async runScheduleNow(id, keyword) {
|
|
1917
|
+
const btn = document.getElementById(`run-btn-${id}`);
|
|
1918
|
+
if (!btn || btn.disabled) return;
|
|
1919
|
+
btn.disabled = true;
|
|
1920
|
+
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 실행 중...';
|
|
1921
|
+
btn.style.opacity = '0.6';
|
|
1922
|
+
try {
|
|
1923
|
+
const res = await fetch(`/api/schedules/${id}/run`, { method: 'POST' });
|
|
1924
|
+
if (res.ok) {
|
|
1925
|
+
btn.innerHTML = '<i class="fas fa-check"></i> 시작됨';
|
|
1926
|
+
btn.style.color = 'var(--success, #27ae60)';
|
|
1927
|
+
btn.style.borderColor = 'var(--success, #27ae60)';
|
|
1928
|
+
setTimeout(() => {
|
|
1929
|
+
btn.disabled = false;
|
|
1930
|
+
btn.innerHTML = '<i class="fas fa-play"></i> 지금 실행';
|
|
1931
|
+
btn.style.color = 'var(--accent)';
|
|
1932
|
+
btn.style.borderColor = 'var(--accent)';
|
|
1933
|
+
btn.style.opacity = '1';
|
|
1934
|
+
}, 3000);
|
|
1935
|
+
} else {
|
|
1936
|
+
throw new Error(`HTTP ${res.status}`);
|
|
1937
|
+
}
|
|
1938
|
+
} catch (e) {
|
|
1939
|
+
btn.disabled = false;
|
|
1940
|
+
btn.innerHTML = '<i class="fas fa-play"></i> 지금 실행';
|
|
1941
|
+
btn.style.color = 'var(--accent)';
|
|
1942
|
+
btn.style.borderColor = 'var(--accent)';
|
|
1943
|
+
btn.style.opacity = '1';
|
|
1944
|
+
alert(`"${keyword}" 실행 실패: ${e.message}`);
|
|
1945
|
+
}
|
|
1946
|
+
},
|
|
1947
|
+
|
|
1948
|
+
async toggleSchedule(id) {
|
|
1949
|
+
try {
|
|
1950
|
+
await fetch(`/api/schedules/${id}/toggle`, { method: 'POST' });
|
|
1951
|
+
this.loadSchedules();
|
|
1952
|
+
} catch (e) {
|
|
1953
|
+
alert('토글 실패');
|
|
1954
|
+
}
|
|
1955
|
+
},
|
|
1956
|
+
|
|
1957
|
+
async editSchedule(id) {
|
|
1958
|
+
try {
|
|
1959
|
+
const data = await fetch('/api/schedules').then(r => r.json());
|
|
1960
|
+
const item = (data.items || []).find(s => s.id === id);
|
|
1961
|
+
if (!item) { alert('스케줄을 찾을 수 없습니다.'); return; }
|
|
1962
|
+
|
|
1963
|
+
// 편집 모달 표시
|
|
1964
|
+
document.getElementById('edit-schedule-id').value = id;
|
|
1965
|
+
document.getElementById('edit-schedule-keyword').value = item.keyword || '';
|
|
1966
|
+
document.getElementById('edit-schedule-auto-approve').checked = !!item.auto_approve;
|
|
1967
|
+
|
|
1968
|
+
// schedule_type / value 설정
|
|
1969
|
+
const typeEl = document.getElementById('edit-schedule-type');
|
|
1970
|
+
typeEl.value = item.schedule_type || 'daily';
|
|
1971
|
+
this._updateEditScheduleTypeUI(item.schedule_type || 'daily');
|
|
1972
|
+
const sv = item.schedule_value || '';
|
|
1973
|
+
if (item.schedule_type === 'daily') {
|
|
1974
|
+
document.getElementById('edit-schedule-time').value = sv || '09:00';
|
|
1975
|
+
} else if (item.schedule_type === 'weekly') {
|
|
1976
|
+
const parts = sv.trim().split(/\s+/);
|
|
1977
|
+
document.getElementById('edit-schedule-dow').value = parts[0] || 'monday';
|
|
1978
|
+
document.getElementById('edit-schedule-time').value = parts[1] || '09:00';
|
|
1979
|
+
} else if (item.schedule_type === 'interval') {
|
|
1980
|
+
document.getElementById('edit-schedule-interval').value = sv;
|
|
1981
|
+
} else if (item.schedule_type === 'cron') {
|
|
1982
|
+
document.getElementById('edit-schedule-cron').value = sv;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// 소스 로드 후 체크
|
|
1986
|
+
let selectedSources = [];
|
|
1987
|
+
try { selectedSources = JSON.parse(item.sources); } catch { selectedSources = item.sources ? item.sources.split(',').map(s => s.trim()) : []; }
|
|
1988
|
+
await this.loadEditScheduleSources(selectedSources);
|
|
1989
|
+
|
|
1990
|
+
// 모달 표시
|
|
1991
|
+
const modal = document.getElementById('schedule-edit-modal');
|
|
1992
|
+
modal.classList.remove('hidden');
|
|
1993
|
+
modal.style.display = 'flex';
|
|
1994
|
+
} catch (e) {
|
|
1995
|
+
alert('스케줄 로드 실패: ' + e.message);
|
|
1996
|
+
}
|
|
1997
|
+
},
|
|
1998
|
+
|
|
1999
|
+
async loadEditScheduleSources(selectedSources = []) {
|
|
2000
|
+
const container = document.getElementById('edit-schedule-sources-list');
|
|
2001
|
+
if (!container) return;
|
|
2002
|
+
try {
|
|
2003
|
+
const data = await fetch('/api/skills').then(r => r.json());
|
|
2004
|
+
const skills = data.skills || [];
|
|
2005
|
+
if (skills.length === 0) {
|
|
2006
|
+
container.innerHTML = '<span style="color:var(--text-tertiary);font-size:var(--text-sm);">등록된 소스 없음</span>';
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
container.innerHTML = skills.map(s => `
|
|
2010
|
+
<label style="display:flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:3px;cursor:pointer;font-size:var(--text-xs);user-select:none;transition:background .12s;">
|
|
2011
|
+
<input type="checkbox" value="${s.id}" ${selectedSources.includes(s.id) ? 'checked' : ''} style="accent-color:var(--accent);" />
|
|
2012
|
+
${this.escapeHtml(s.name || s.id)}
|
|
2013
|
+
</label>
|
|
2014
|
+
`).join('');
|
|
2015
|
+
} catch (e) {
|
|
2016
|
+
container.innerHTML = '<span style="color:var(--error);font-size:var(--text-sm);">소스 목록 로드 실패</span>';
|
|
2017
|
+
}
|
|
2018
|
+
},
|
|
2019
|
+
|
|
2020
|
+
_updateEditScheduleTypeUI(type) {
|
|
2021
|
+
const timeG = document.getElementById('edit-schedule-time-group');
|
|
2022
|
+
const dowG = document.getElementById('edit-schedule-dow-group');
|
|
2023
|
+
const intG = document.getElementById('edit-schedule-interval-group');
|
|
2024
|
+
const cronG = document.getElementById('edit-schedule-cron-group');
|
|
2025
|
+
if (!timeG) return;
|
|
2026
|
+
timeG.style.display = (type === 'daily' || type === 'weekly') ? '' : 'none';
|
|
2027
|
+
dowG.style.display = (type === 'weekly') ? '' : 'none';
|
|
2028
|
+
intG.style.display = (type === 'interval') ? '' : 'none';
|
|
2029
|
+
cronG.style.display = (type === 'cron') ? '' : 'none';
|
|
2030
|
+
},
|
|
2031
|
+
|
|
2032
|
+
closeEditModal() {
|
|
2033
|
+
const modal = document.getElementById('schedule-edit-modal');
|
|
2034
|
+
modal.classList.add('hidden');
|
|
2035
|
+
modal.style.display = 'none';
|
|
2036
|
+
},
|
|
2037
|
+
|
|
2038
|
+
async saveEditSchedule() {
|
|
2039
|
+
try {
|
|
2040
|
+
const id = document.getElementById('edit-schedule-id').value;
|
|
2041
|
+
const keyword = document.getElementById('edit-schedule-keyword').value.trim();
|
|
2042
|
+
const sources = [...document.querySelectorAll('#edit-schedule-sources-list input[type=checkbox]:checked')].map(c => c.value);
|
|
2043
|
+
if (!keyword) { alert('키워드를 입력하세요.'); return; }
|
|
2044
|
+
if (sources.length === 0) { alert('소스를 1개 이상 선택하세요.'); return; }
|
|
2045
|
+
const scheduleType = document.getElementById('edit-schedule-type').value;
|
|
2046
|
+
let scheduleValue = null;
|
|
2047
|
+
if (scheduleType === 'daily') {
|
|
2048
|
+
scheduleValue = document.getElementById('edit-schedule-time').value || '09:00';
|
|
2049
|
+
} else if (scheduleType === 'weekly') {
|
|
2050
|
+
const dow = document.getElementById('edit-schedule-dow').value;
|
|
2051
|
+
const time = document.getElementById('edit-schedule-time').value || '09:00';
|
|
2052
|
+
scheduleValue = `${dow} ${time}`;
|
|
2053
|
+
} else if (scheduleType === 'interval') {
|
|
2054
|
+
scheduleValue = document.getElementById('edit-schedule-interval').value.trim() || '6h';
|
|
2055
|
+
} else if (scheduleType === 'cron') {
|
|
2056
|
+
scheduleValue = document.getElementById('edit-schedule-cron').value.trim();
|
|
2057
|
+
}
|
|
2058
|
+
const body = {
|
|
2059
|
+
keyword,
|
|
2060
|
+
sources: JSON.stringify(sources),
|
|
2061
|
+
schedule_type: scheduleType,
|
|
2062
|
+
schedule_value: scheduleValue,
|
|
2063
|
+
auto_approve: document.getElementById('edit-schedule-auto-approve').checked,
|
|
2064
|
+
};
|
|
2065
|
+
const r = await fetch(`/api/schedules/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
2066
|
+
if (!r.ok) throw new Error(await r.text());
|
|
2067
|
+
this.closeEditModal();
|
|
2068
|
+
this.loadSchedules();
|
|
2069
|
+
} catch (e) {
|
|
2070
|
+
alert('저장 실패: ' + e.message);
|
|
2071
|
+
}
|
|
2072
|
+
},
|
|
2073
|
+
|
|
2074
|
+
async deleteSchedule(id, keyword) {
|
|
2075
|
+
if (!confirm(`"${keyword}" 스케줄을 삭제하시겠습니까?`)) return;
|
|
2076
|
+
try {
|
|
2077
|
+
const r = await fetch(`/api/schedules/${id}`, { method: 'DELETE' });
|
|
2078
|
+
if (!r.ok) throw new Error(await r.text());
|
|
2079
|
+
this.loadSchedules();
|
|
2080
|
+
} catch (e) {
|
|
2081
|
+
alert('삭제 실패: ' + e.message);
|
|
2082
|
+
}
|
|
2083
|
+
},
|
|
2084
|
+
|
|
2085
|
+
async loadBookmarks() {
|
|
2086
|
+
try {
|
|
2087
|
+
const data = await fetch('/api/bookmarks').then(r => r.json());
|
|
2088
|
+
const html = (data.items || [])
|
|
2089
|
+
.map(item => `
|
|
2090
|
+
<div class="list-item">
|
|
2091
|
+
<div class="list-item-title">북마크</div>
|
|
2092
|
+
</div>
|
|
2093
|
+
`).join('');
|
|
2094
|
+
document.getElementById('bookmarks-list').innerHTML = html || '<div class="empty-state"><div style="font-size:13px;color:var(--text-secondary);margin-bottom:6px;">북마크된 보고서가 없습니다</div><div style="font-size:11px;color:var(--text-tertiary);">보고서 상세에서 <i class=\'fas fa-star\' style=\'color:var(--accent-dim);\'></i> 아이콘으로 추가하세요</div></div>';
|
|
2095
|
+
} catch (e) {
|
|
2096
|
+
console.error('Load bookmarks failed:', e);
|
|
2097
|
+
}
|
|
2098
|
+
},
|
|
2099
|
+
|
|
2100
|
+
switchSettingsTab(tabName) {
|
|
2101
|
+
// 탭 버튼 active 상태 전환
|
|
2102
|
+
document.querySelectorAll('#settings-tab-bar .tab-btn').forEach(btn => {
|
|
2103
|
+
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
2104
|
+
});
|
|
2105
|
+
// 탭 패널 active 상태 전환
|
|
2106
|
+
['source-auth', 'llm-config', 'general'].forEach(t => {
|
|
2107
|
+
const el = document.getElementById(`settings-tab-${t}`);
|
|
2108
|
+
if (el) el.classList.toggle('active', t === tabName);
|
|
2109
|
+
});
|
|
2110
|
+
},
|
|
2111
|
+
|
|
2112
|
+
async loadSettings() {
|
|
2113
|
+
try {
|
|
2114
|
+
const data = await fetch('/api/settings').then(r => r.json());
|
|
2115
|
+
const config = data.config ?? {};
|
|
2116
|
+
|
|
2117
|
+
// 소스 인증 탭
|
|
2118
|
+
document.getElementById('settings-nanet-userid').value = config['source.nanet.userId'] ?? '';
|
|
2119
|
+
document.getElementById('settings-nanet-password').value = config['source.nanet.password'] ?? '';
|
|
2120
|
+
|
|
2121
|
+
// LLM 설정 탭
|
|
2122
|
+
const provider = config['llm.provider'] ?? '';
|
|
2123
|
+
const radio = document.querySelector(`input[name="llm-provider"][value="${provider}"]`);
|
|
2124
|
+
// ollama 전용 설정 로드
|
|
2125
|
+
const ollamaModel = document.getElementById('settings-ollama-model');
|
|
2126
|
+
const ollamaHost = document.getElementById('settings-ollama-host');
|
|
2127
|
+
const ollamaPort = document.getElementById('settings-ollama-port');
|
|
2128
|
+
const ollamaUrl = document.getElementById('settings-ollama-url');
|
|
2129
|
+
if (ollamaModel) ollamaModel.value = config['llm.ollama.model'] ?? 'gemma4:e2b';
|
|
2130
|
+
if (ollamaHost) ollamaHost.value = config['llm.ollama.host'] ?? 'localhost';
|
|
2131
|
+
if (ollamaPort) ollamaPort.value = config['llm.ollama.port'] ?? '11434';
|
|
2132
|
+
if (ollamaUrl) ollamaUrl.value = config['llm.ollama.httpUrl'] ?? 'http://localhost:11434/v1/chat/completions';
|
|
2133
|
+
const ollamaConfig = document.getElementById('ollama-config');
|
|
2134
|
+
if (ollamaConfig) ollamaConfig.style.display = provider === 'ollama' ? 'block' : 'none';
|
|
2135
|
+
if (radio) radio.checked = true;
|
|
2136
|
+
|
|
2137
|
+
// 일반 탭 — lint 스케줄
|
|
2138
|
+
const scheduleType = config['lint.scheduleType'] ?? 'disabled';
|
|
2139
|
+
document.getElementById('settings-lint-schedule-type').value = scheduleType;
|
|
2140
|
+
document.getElementById('settings-lint-schedule-time').value = config['lint.scheduleTime'] ?? '03:00';
|
|
2141
|
+
document.getElementById('settings-lint-schedule-day').value = config['lint.scheduleDay'] ?? 'sunday';
|
|
2142
|
+
document.getElementById('settings-lint-schedule-cron').value = config['lint.scheduleCron'] ?? '';
|
|
2143
|
+
document.getElementById('settings-lint-auto-fix').checked = config['lint.autoFix'] === 'true';
|
|
2144
|
+
document.getElementById('settings-lint-semantic').checked = config['lint.semantic'] === 'true';
|
|
2145
|
+
document.getElementById('settings-lint-notify').checked = config['lint.notifyOnWarning'] === 'true';
|
|
2146
|
+
this._updateLintScheduleUI(scheduleType);
|
|
2147
|
+
// cron 타입이면 저장된 표현식을 5-필드로 분해
|
|
2148
|
+
if (scheduleType === 'cron') {
|
|
2149
|
+
this._parseCronToFields(config['lint.scheduleCron'] ?? '');
|
|
2150
|
+
}
|
|
2151
|
+
// Leiden 커뮤니티 감지 주기
|
|
2152
|
+
const leidenCronVal = config['leiden.cron'] ?? '* * * * *';
|
|
2153
|
+
const leidenPresets = ['* * * * *', '*/5 * * * *', '*/10 * * * *', '*/30 * * * *', '0 * * * *'];
|
|
2154
|
+
const leidenPresetEl = document.getElementById('settings-leiden-preset');
|
|
2155
|
+
const leidenCronEl = document.getElementById('settings-leiden-cron');
|
|
2156
|
+
if (leidenPresetEl) {
|
|
2157
|
+
if (leidenPresets.includes(leidenCronVal)) {
|
|
2158
|
+
leidenPresetEl.value = leidenCronVal;
|
|
2159
|
+
} else {
|
|
2160
|
+
leidenPresetEl.value = 'custom';
|
|
2161
|
+
if (leidenCronEl) leidenCronEl.value = leidenCronVal;
|
|
2162
|
+
}
|
|
2163
|
+
this._updateLeidenCronUI(leidenPresetEl.value);
|
|
2164
|
+
}
|
|
2165
|
+
// 동시 다운로드 수
|
|
2166
|
+
const dlConc = document.getElementById('settings-download-concurrency');
|
|
2167
|
+
if (dlConc) dlConc.value = config['download.concurrency'] ?? '3';
|
|
2168
|
+
// 대기→큐 배치 크기
|
|
2169
|
+
const batchSizeEl = document.getElementById('settings-waiting-batch-size');
|
|
2170
|
+
if (batchSizeEl) batchSizeEl.value = config['pipeline.waiting_batch_size'] ?? '20';
|
|
2171
|
+
// 피더 실행 간격
|
|
2172
|
+
const feederIntervalEl = document.getElementById('settings-feeder-interval');
|
|
2173
|
+
if (feederIntervalEl) feederIntervalEl.value = config['pipeline.feeder_interval_sec'] ?? '10';
|
|
2174
|
+
// 데이터 저장 경로
|
|
2175
|
+
const dataDirInput = document.getElementById('settings-data-dir');
|
|
2176
|
+
const dataDirCurrent = document.getElementById('settings-data-dir-current');
|
|
2177
|
+
const storedDir = config['data.dir'] ?? '';
|
|
2178
|
+
const runtimeDir = config['_runtime.dataDir'] ?? '';
|
|
2179
|
+
if (dataDirInput) dataDirInput.value = storedDir;
|
|
2180
|
+
if (dataDirCurrent && runtimeDir) {
|
|
2181
|
+
dataDirCurrent.textContent = `현재 실행 중: ${runtimeDir}${storedDir && storedDir !== runtimeDir ? ' → 재시작 후 적용 예정: ' + storedDir : ''}`;
|
|
2182
|
+
}
|
|
2183
|
+
} catch (e) {
|
|
2184
|
+
console.error('Load settings failed:', e);
|
|
2185
|
+
}
|
|
2186
|
+
},
|
|
2187
|
+
|
|
2188
|
+
_updateLintScheduleUI(type) {
|
|
2189
|
+
const timeGroup = document.getElementById('lint-time-group');
|
|
2190
|
+
const dayGroup = document.getElementById('lint-day-group');
|
|
2191
|
+
const cronGroup = document.getElementById('lint-cron-group');
|
|
2192
|
+
if (!timeGroup) return;
|
|
2193
|
+
timeGroup.style.display = (type === 'daily' || type === 'weekly') ? '' : 'none';
|
|
2194
|
+
dayGroup.style.display = type === 'weekly' ? '' : 'none';
|
|
2195
|
+
cronGroup.style.display = type === 'cron' ? '' : 'none';
|
|
2196
|
+
if (type === 'cron') this._onCronFieldChange();
|
|
2197
|
+
},
|
|
2198
|
+
|
|
2199
|
+
// Cron 5-필드 → hidden input 동기화 + 자연어 미리보기
|
|
2200
|
+
_onCronFieldChange() {
|
|
2201
|
+
const min = document.getElementById('cron-f-min')?.value.trim() || '*';
|
|
2202
|
+
const hour = document.getElementById('cron-f-hour')?.value.trim() || '*';
|
|
2203
|
+
const day = document.getElementById('cron-f-day')?.value.trim() || '*';
|
|
2204
|
+
const month = document.getElementById('cron-f-month')?.value.trim() || '*';
|
|
2205
|
+
const dow = document.getElementById('cron-f-dow')?.value.trim() || '*';
|
|
2206
|
+
const expr = `${min} ${hour} ${day} ${month} ${dow}`;
|
|
2207
|
+
const hiddenEl = document.getElementById('settings-lint-schedule-cron');
|
|
2208
|
+
if (hiddenEl) hiddenEl.value = expr;
|
|
2209
|
+
const previewEl = document.getElementById('cron-nl-preview');
|
|
2210
|
+
if (previewEl) previewEl.textContent = this._cronToNaturalLanguage(expr);
|
|
2211
|
+
},
|
|
2212
|
+
|
|
2213
|
+
// 프리셋 버튼 → 5-필드 채우기
|
|
2214
|
+
_setCronPreset(expr) {
|
|
2215
|
+
const parts = expr.split(' ');
|
|
2216
|
+
const ids = ['cron-f-min','cron-f-hour','cron-f-day','cron-f-month','cron-f-dow'];
|
|
2217
|
+
ids.forEach((id, i) => {
|
|
2218
|
+
const el = document.getElementById(id);
|
|
2219
|
+
if (el) el.value = parts[i] ?? '*';
|
|
2220
|
+
});
|
|
2221
|
+
this._onCronFieldChange();
|
|
2222
|
+
},
|
|
2223
|
+
|
|
2224
|
+
// loadSettings 시 저장된 cron 표현식 → 5-필드 분해
|
|
2225
|
+
_parseCronToFields(expr) {
|
|
2226
|
+
if (!expr) return;
|
|
2227
|
+
const parts = expr.trim().split(/\s+/);
|
|
2228
|
+
if (parts.length !== 5) return;
|
|
2229
|
+
const ids = ['cron-f-min','cron-f-hour','cron-f-day','cron-f-month','cron-f-dow'];
|
|
2230
|
+
ids.forEach((id, i) => {
|
|
2231
|
+
const el = document.getElementById(id);
|
|
2232
|
+
if (el) el.value = parts[i];
|
|
2233
|
+
});
|
|
2234
|
+
this._onCronFieldChange();
|
|
2235
|
+
},
|
|
2236
|
+
|
|
2237
|
+
// cron 표현식 → 한국어 자연어 설명
|
|
2238
|
+
_cronToNaturalLanguage(expr) {
|
|
2239
|
+
if (!expr) return '';
|
|
2240
|
+
const [min, hour, day, month, dow] = expr.trim().split(/\s+/);
|
|
2241
|
+
if (!min) return '';
|
|
2242
|
+
const DOW_KO = ['일','월','화','수','목','금','토'];
|
|
2243
|
+
|
|
2244
|
+
if (expr === '* * * * *') return '매 1분마다 실행';
|
|
2245
|
+
if (/^\*\/(\d+) \* \* \* \*$/.test(expr)) {
|
|
2246
|
+
const n = expr.match(/^\*\/(\d+)/)[1];
|
|
2247
|
+
return `매 ${n}분마다 실행`;
|
|
2248
|
+
}
|
|
2249
|
+
if (/^0 \*\/(\d+) \* \* \*$/.test(expr)) {
|
|
2250
|
+
const n = expr.match(/^0 \*\/(\d+)/)[1];
|
|
2251
|
+
return `매 ${n}시간마다 실행`;
|
|
2252
|
+
}
|
|
2253
|
+
if (expr === '0 * * * *') return '매시간 정각에 실행';
|
|
2254
|
+
|
|
2255
|
+
const hh = parseInt(hour, 10), mm = parseInt(min, 10);
|
|
2256
|
+
const timeStr = (!isNaN(hh) && !isNaN(mm))
|
|
2257
|
+
? `${hh < 12 ? '오전' : '오후'} ${hh < 12 ? hh : hh - 12 || 12}시 ${mm > 0 ? mm + '분' : '정각'}`
|
|
2258
|
+
: null;
|
|
2259
|
+
|
|
2260
|
+
if (dow !== '*' && !isNaN(parseInt(dow, 10)) && day === '*' && month === '*' && timeStr) {
|
|
2261
|
+
return `매주 ${DOW_KO[parseInt(dow, 10)] ?? dow}요일 ${timeStr}에 실행`;
|
|
2262
|
+
}
|
|
2263
|
+
if (dow === '*' && day === '*' && month === '*' && timeStr) {
|
|
2264
|
+
return `매일 ${timeStr}에 실행`;
|
|
2265
|
+
}
|
|
2266
|
+
return `실행 주기: ${expr}`;
|
|
2267
|
+
},
|
|
2268
|
+
|
|
2269
|
+
_updateLeidenCronUI(value) {
|
|
2270
|
+
const cronGroup = document.getElementById('leiden-cron-group');
|
|
2271
|
+
if (cronGroup) cronGroup.style.display = value === 'custom' ? '' : 'none';
|
|
2272
|
+
},
|
|
2273
|
+
|
|
2274
|
+
async saveLeidenConfig() {
|
|
2275
|
+
try {
|
|
2276
|
+
const presetEl = document.getElementById('settings-leiden-preset');
|
|
2277
|
+
const cronEl = document.getElementById('settings-leiden-cron');
|
|
2278
|
+
if (!presetEl) return;
|
|
2279
|
+
const presetVal = presetEl.value;
|
|
2280
|
+
const cronVal = presetVal === 'custom' ? (cronEl?.value.trim() ?? '') : presetVal;
|
|
2281
|
+
if (!cronVal) return alert('Cron 표현식을 입력하세요.');
|
|
2282
|
+
const res = await fetch('/api/settings', {
|
|
2283
|
+
method: 'POST',
|
|
2284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2285
|
+
body: JSON.stringify({ settings: { 'leiden.cron': cronVal } }),
|
|
2286
|
+
});
|
|
2287
|
+
if (!res.ok) throw new Error('서버 오류');
|
|
2288
|
+
alert('Leiden 스케줄이 저장되었습니다.');
|
|
2289
|
+
} catch (e) {
|
|
2290
|
+
alert('저장 실패: ' + e.message);
|
|
2291
|
+
}
|
|
2292
|
+
},
|
|
2293
|
+
|
|
2294
|
+
async saveSourceAuth() {
|
|
2295
|
+
try {
|
|
2296
|
+
const settings = {
|
|
2297
|
+
'source.nanet.userId': document.getElementById('settings-nanet-userid').value.trim(),
|
|
2298
|
+
'source.nanet.password': document.getElementById('settings-nanet-password').value,
|
|
2299
|
+
};
|
|
2300
|
+
const res = await fetch('/api/settings', {
|
|
2301
|
+
method: 'POST',
|
|
2302
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2303
|
+
body: JSON.stringify({ settings }),
|
|
2304
|
+
});
|
|
2305
|
+
if (!res.ok) throw new Error('서버 오류');
|
|
2306
|
+
alert('인증정보가 저장되었습니다.');
|
|
2307
|
+
} catch (e) {
|
|
2308
|
+
alert('저장 실패: ' + e.message);
|
|
2309
|
+
}
|
|
2310
|
+
},
|
|
2311
|
+
|
|
2312
|
+
async saveLintConfig() {
|
|
2313
|
+
try {
|
|
2314
|
+
const settings = {
|
|
2315
|
+
'lint.scheduleType': document.getElementById('settings-lint-schedule-type').value,
|
|
2316
|
+
'lint.scheduleTime': document.getElementById('settings-lint-schedule-time').value,
|
|
2317
|
+
'lint.scheduleDay': document.getElementById('settings-lint-schedule-day').value,
|
|
2318
|
+
'lint.scheduleCron': document.getElementById('settings-lint-schedule-cron').value.trim(),
|
|
2319
|
+
'lint.autoFix': String(document.getElementById('settings-lint-auto-fix').checked),
|
|
2320
|
+
'lint.semantic': String(document.getElementById('settings-lint-semantic').checked),
|
|
2321
|
+
'lint.notifyOnWarning': String(document.getElementById('settings-lint-notify').checked),
|
|
2322
|
+
};
|
|
2323
|
+
const res = await fetch('/api/settings', {
|
|
2324
|
+
method: 'POST',
|
|
2325
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2326
|
+
body: JSON.stringify({ settings }),
|
|
2327
|
+
});
|
|
2328
|
+
if (!res.ok) throw new Error('서버 오류');
|
|
2329
|
+
alert('Lint 스케줄 설정이 저장되었습니다.');
|
|
2330
|
+
} catch (e) {
|
|
2331
|
+
alert('저장 실패: ' + e.message);
|
|
2332
|
+
}
|
|
2333
|
+
},
|
|
2334
|
+
|
|
2335
|
+
async saveDataDir() {
|
|
2336
|
+
try {
|
|
2337
|
+
const val = document.getElementById('settings-data-dir').value.trim();
|
|
2338
|
+
if (!val) { alert('경로를 입력해주세요.'); return; }
|
|
2339
|
+
const res = await fetch('/api/settings', {
|
|
2340
|
+
method: 'POST',
|
|
2341
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2342
|
+
body: JSON.stringify({ settings: { 'data.dir': val } }),
|
|
2343
|
+
});
|
|
2344
|
+
if (!res.ok) throw new Error('서버 오류');
|
|
2345
|
+
alert(`데이터 저장 경로가 저장되었습니다.\n경로: ${val}\n\n데몬을 재시작해야 적용됩니다.`);
|
|
2346
|
+
// 현재 실행 경로 표시 업데이트
|
|
2347
|
+
const currentEl = document.getElementById('settings-data-dir-current');
|
|
2348
|
+
const runtimeDir = currentEl?.textContent?.match(/현재 실행 중: ([^\s→]+)/)?.[1] ?? '';
|
|
2349
|
+
if (currentEl && runtimeDir) {
|
|
2350
|
+
currentEl.textContent = `현재 실행 중: ${runtimeDir} → 재시작 후 적용 예정: ${val}`;
|
|
2351
|
+
}
|
|
2352
|
+
} catch (e) {
|
|
2353
|
+
alert('저장 실패: ' + e.message);
|
|
2354
|
+
}
|
|
2355
|
+
},
|
|
2356
|
+
|
|
2357
|
+
async saveDownloadConfig() {
|
|
2358
|
+
try {
|
|
2359
|
+
const val = parseInt(document.getElementById('settings-download-concurrency').value, 10);
|
|
2360
|
+
if (isNaN(val) || val < 1 || val > 10) {
|
|
2361
|
+
alert('동시 다운로드 수는 1~10 사이의 숫자여야 합니다.');
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
const batchVal = parseInt(document.getElementById('settings-waiting-batch-size')?.value ?? '20', 10);
|
|
2365
|
+
if (isNaN(batchVal) || batchVal < 1 || batchVal > 200) {
|
|
2366
|
+
alert('대기→큐 배치 크기는 1~200 사이의 숫자여야 합니다.');
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
const feederVal = parseInt(document.getElementById('settings-feeder-interval')?.value ?? '10', 10);
|
|
2370
|
+
if (isNaN(feederVal) || feederVal < 1 || feederVal > 3600) {
|
|
2371
|
+
alert('피더 실행 간격은 1~3600초 사이의 숫자여야 합니다.');
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
const res = await fetch('/api/settings', {
|
|
2375
|
+
method: 'POST',
|
|
2376
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2377
|
+
body: JSON.stringify({ settings: {
|
|
2378
|
+
'download.concurrency': String(val),
|
|
2379
|
+
'pipeline.waiting_batch_size': String(batchVal),
|
|
2380
|
+
'pipeline.feeder_interval_sec': String(feederVal),
|
|
2381
|
+
}}),
|
|
2382
|
+
});
|
|
2383
|
+
if (!res.ok) throw new Error('서버 오류');
|
|
2384
|
+
alert(`저장 완료 — 동시 다운로드 ${val}개 / 배치 ${batchVal}건 / 간격 ${feederVal}초. 다음 tick부터 적용됩니다.`);
|
|
2385
|
+
} catch (e) {
|
|
2386
|
+
alert('저장 실패: ' + e.message);
|
|
2387
|
+
}
|
|
2388
|
+
},
|
|
2389
|
+
|
|
2390
|
+
// ─── 커맨드 팔레트 (#32) ───
|
|
2391
|
+
_cmdItems: [],
|
|
2392
|
+
_cmdSelected: 0,
|
|
2393
|
+
|
|
2394
|
+
showCommandPalette() {
|
|
2395
|
+
document.getElementById('cmd-overlay').classList.remove('hidden');
|
|
2396
|
+
const input = document.getElementById('cmd-input');
|
|
2397
|
+
input.value = '';
|
|
2398
|
+
this._renderCmdResults('');
|
|
2399
|
+
requestAnimationFrame(() => input.focus());
|
|
2400
|
+
},
|
|
2401
|
+
|
|
2402
|
+
hideCommandPalette() {
|
|
2403
|
+
document.getElementById('cmd-overlay').classList.add('hidden');
|
|
2404
|
+
},
|
|
2405
|
+
|
|
2406
|
+
onCmdInput(q) {
|
|
2407
|
+
this._cmdSelected = 0;
|
|
2408
|
+
this._renderCmdResults(q);
|
|
2409
|
+
if (q.length >= 2) this._fetchCmdResults(q);
|
|
2410
|
+
},
|
|
2411
|
+
|
|
2412
|
+
onCmdKey(e) {
|
|
2413
|
+
const items = document.querySelectorAll('.cmd-item');
|
|
2414
|
+
if (e.key === 'ArrowDown') {
|
|
2415
|
+
e.preventDefault();
|
|
2416
|
+
this._cmdSelected = Math.min(this._cmdSelected + 1, items.length - 1);
|
|
2417
|
+
items.forEach((el, i) => el.classList.toggle('selected', i === this._cmdSelected));
|
|
2418
|
+
items[this._cmdSelected]?.scrollIntoView({ block: 'nearest' });
|
|
2419
|
+
} else if (e.key === 'ArrowUp') {
|
|
2420
|
+
e.preventDefault();
|
|
2421
|
+
this._cmdSelected = Math.max(this._cmdSelected - 1, 0);
|
|
2422
|
+
items.forEach((el, i) => el.classList.toggle('selected', i === this._cmdSelected));
|
|
2423
|
+
items[this._cmdSelected]?.scrollIntoView({ block: 'nearest' });
|
|
2424
|
+
} else if (e.key === 'Enter') {
|
|
2425
|
+
items[this._cmdSelected]?.click();
|
|
2426
|
+
} else if (e.key === 'Escape') {
|
|
2427
|
+
this.hideCommandPalette();
|
|
2428
|
+
}
|
|
2429
|
+
},
|
|
2430
|
+
|
|
2431
|
+
_staticCmds: [
|
|
2432
|
+
{ label: '대시보드', icon: 'fa-home', action: () => window.app.navigateTo('/') },
|
|
2433
|
+
{ label: '보고서 목록', icon: 'fa-list', action: () => window.app.navigateTo('/catalog') },
|
|
2434
|
+
{ label: '검색', icon: 'fa-search', action: () => window.app.navigateTo('/search') },
|
|
2435
|
+
{ label: '그래프 시각화', icon: 'fa-spider', action: () => window.app.navigateTo('/graph') },
|
|
2436
|
+
{ label: '위키', icon: 'fa-book', action: () => window.app.navigateTo('/wiki') },
|
|
2437
|
+
{ label: '대기 항목', icon: 'fa-bell', action: () => window.app.navigateTo('/pending') },
|
|
2438
|
+
{ label: '실패 현황', icon: 'fa-exclamation-triangle', action: () => window.app.navigateTo('/failures') },
|
|
2439
|
+
{ label: '스케줄 관리', icon: 'fa-clock', action: () => window.app.navigateTo('/schedules') },
|
|
2440
|
+
{ label: '설정', icon: 'fa-cog', action: () => window.app.navigateTo('/settings') },
|
|
2441
|
+
{ label: '위키 재빌드', icon: 'fa-sync', action: async () => { const r = await fetch('/api/wiki/rebuild',{method:'POST'}); const d = await r.json(); alert(d.ok ? `완료 (${d.updated}개 갱신)` : '실패'); } },
|
|
2442
|
+
{ label: '전체 재시도', icon: 'fa-redo', action: () => window.app.retryAllFailures() },
|
|
2443
|
+
{ label: '백업', icon: 'fa-save', action: async () => { const r = await fetch('/api/backup',{method:'POST'}); const d = await r.json(); alert(d.ok ? '백업 완료' : '실패'); } },
|
|
2444
|
+
],
|
|
2445
|
+
|
|
2446
|
+
_renderCmdResults(q) {
|
|
2447
|
+
const el = document.getElementById('cmd-results');
|
|
2448
|
+
if (!q) {
|
|
2449
|
+
el.innerHTML = `<div class="cmd-group-label">명령어</div>` +
|
|
2450
|
+
this._staticCmds.map((cmd, i) =>
|
|
2451
|
+
`<div class="cmd-item${i===0?' selected':''}" onclick="window.app._execCmd(${i})">
|
|
2452
|
+
<span class="cmd-item-icon"><i class="fas ${this.escapeHtml(cmd.icon)}"></i></span>
|
|
2453
|
+
${this.escapeHtml(cmd.label)}
|
|
2454
|
+
</div>`).join('');
|
|
2455
|
+
this._cmdItems = this._staticCmds.map(c => c.action);
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
const matched = this._staticCmds.filter(c => c.label.includes(q));
|
|
2459
|
+
let html = '';
|
|
2460
|
+
if (matched.length) {
|
|
2461
|
+
html += `<div class="cmd-group-label">명령어</div>` +
|
|
2462
|
+
matched.map((cmd, i) =>
|
|
2463
|
+
`<div class="cmd-item${i===0?' selected':''}" onclick="window.app._execCmd(${i})">
|
|
2464
|
+
<span class="cmd-item-icon"><i class="fas ${this.escapeHtml(cmd.icon)}"></i></span>
|
|
2465
|
+
${this.escapeHtml(cmd.label)}
|
|
2466
|
+
</div>`).join('');
|
|
2467
|
+
}
|
|
2468
|
+
this._cmdItems = matched.map(c => c.action);
|
|
2469
|
+
if (!html) html = '<div class="cmd-empty">검색 중…</div>';
|
|
2470
|
+
el.innerHTML = html;
|
|
2471
|
+
},
|
|
2472
|
+
|
|
2473
|
+
async _fetchCmdResults(q) {
|
|
2474
|
+
try {
|
|
2475
|
+
const data = await fetch(`/api/search/suggest?q=${encodeURIComponent(q)}&limit=6`).then(r => r.json());
|
|
2476
|
+
const items = data.suggestions || [];
|
|
2477
|
+
if (!items.length) return;
|
|
2478
|
+
const el = document.getElementById('cmd-results');
|
|
2479
|
+
const existing = el.innerHTML;
|
|
2480
|
+
const staticCount = this._cmdItems.length;
|
|
2481
|
+
const reportHtml = `<div class="cmd-group-label">보고서 · 위키</div>` +
|
|
2482
|
+
items.map((item, i) => {
|
|
2483
|
+
const idx = staticCount + i;
|
|
2484
|
+
return `<div class="cmd-item" onclick="window.app._execCmd(${idx})">
|
|
2485
|
+
<span class="cmd-item-icon"><i class="fas ${item.type === 'report' ? 'fa-file-alt' : 'fa-book'}"></i></span>
|
|
2486
|
+
${this.escapeHtml(item.label)}
|
|
2487
|
+
<span class="cmd-item-desc">${item.type}</span>
|
|
2488
|
+
</div>`;
|
|
2489
|
+
}).join('');
|
|
2490
|
+
el.innerHTML = (existing.includes('cmd-empty') ? '' : existing) + reportHtml;
|
|
2491
|
+
this._cmdItems = [
|
|
2492
|
+
...this._cmdItems,
|
|
2493
|
+
...items.map(item => () => {
|
|
2494
|
+
window.app.hideCommandPalette();
|
|
2495
|
+
if (item.type === 'report') window.app.showReportDetail(item.id);
|
|
2496
|
+
else if (item.type === 'wiki') { window.app.navigateTo('/wiki'); window.app.wikiNavigate(item.id); }
|
|
2497
|
+
}),
|
|
2498
|
+
];
|
|
2499
|
+
} catch {}
|
|
2500
|
+
},
|
|
2501
|
+
|
|
2502
|
+
_execCmd(idx) {
|
|
2503
|
+
const fn = this._cmdItems[idx];
|
|
2504
|
+
if (fn) { this.hideCommandPalette(); fn(); }
|
|
2505
|
+
},
|
|
2506
|
+
|
|
2507
|
+
// ─── 보고서 상세 드로어 (#29) ───
|
|
2508
|
+
async showReportDetail(hash) {
|
|
2509
|
+
document.getElementById('drawer-overlay').classList.add('open');
|
|
2510
|
+
document.getElementById('report-drawer').classList.add('open');
|
|
2511
|
+
document.getElementById('drawer-title').textContent = '불러오는 중…';
|
|
2512
|
+
document.getElementById('drawer-body').innerHTML = '<div class="empty-state"><div class="loading"></div></div>';
|
|
2513
|
+
document.getElementById('drawer-actions').innerHTML = '';
|
|
2514
|
+
try {
|
|
2515
|
+
const data = await fetch(`/api/reports/${encodeURIComponent(hash)}/summary`).then(r => r.json());
|
|
2516
|
+
const report = await fetch(`/api/reports/${encodeURIComponent(hash)}`).then(r => r.json()).catch(() => ({}));
|
|
2517
|
+
document.getElementById('drawer-title').textContent = data.title || report.title || '보고서';
|
|
2518
|
+
const topics = (data.topics || []).map(t => {
|
|
2519
|
+
const name = typeof t === 'string' ? t : (t.name ?? '');
|
|
2520
|
+
return name ? `<span class="topic-chip">${this.escapeHtml(name)}</span>` : '';
|
|
2521
|
+
}).join('');
|
|
2522
|
+
const createdAt = data.createdAt ? new Date(data.createdAt).toLocaleDateString('ko-KR', { year:'numeric', month:'long', day:'numeric' }) : '-';
|
|
2523
|
+
document.getElementById('drawer-body').innerHTML = `
|
|
2524
|
+
<div class="detail-meta-row">
|
|
2525
|
+
${data.source ? `<span class="badge">${this.escapeHtml(data.source)}</span>` : ''}
|
|
2526
|
+
${data.status ? `<span class="badge ${data.status === 'INDEXED' ? 'success' : ''}">${data.status}</span>` : ''}
|
|
2527
|
+
${report.year ? `<span class="badge">${this.escapeHtml(report.year)}</span>` : ''}
|
|
2528
|
+
</div>
|
|
2529
|
+
${data.summary ? `<div class="detail-section"><div class="detail-section-label">요약</div><p style="font-size:var(--text-sm);line-height:1.7;color:var(--text-secondary);">${this.escapeHtml(data.summary)}</p></div>` : ''}
|
|
2530
|
+
${topics ? `<div class="detail-section"><div class="detail-section-label">토픽</div><div>${topics}</div></div>` : ''}
|
|
2531
|
+
${data.url ? `<div class="detail-section"><div class="detail-section-label">원본 URL</div><a href="${this.escapeHtml(data.url)}" target="_blank" rel="noopener" style="font-size:var(--text-sm);color:var(--accent);word-break:break-all;">${this.escapeHtml(data.url)}</a></div>` : ''}
|
|
2532
|
+
<div class="detail-section" style="margin-top:var(--space-4);">
|
|
2533
|
+
<div class="detail-section-label">수집 일시</div>
|
|
2534
|
+
<span style="font-size:var(--text-sm);color:var(--text-secondary);">${createdAt}</span>
|
|
2535
|
+
</div>`;
|
|
2536
|
+
document.getElementById('drawer-actions').innerHTML = `
|
|
2537
|
+
${data.url ? `<a href="${this.escapeHtml(data.url)}" target="_blank" rel="noopener" class="button"><i class="fas fa-external-link-alt"></i> 원본 열기</a>` : ''}
|
|
2538
|
+
<button class="button" onclick="window.app.downloadReportFile('${encodeURIComponent(hash)}')"><i class="fas fa-download"></i> 파일 다운로드</button>`;
|
|
2539
|
+
} catch (e) {
|
|
2540
|
+
document.getElementById('drawer-body').innerHTML = `<div class="empty-state">상세 정보를 불러올 수 없습니다</div>`;
|
|
2541
|
+
}
|
|
2542
|
+
},
|
|
2543
|
+
|
|
2544
|
+
async downloadReportFile(encodedHash) {
|
|
2545
|
+
const url = `/api/reports/${encodedHash}/file`;
|
|
2546
|
+
try {
|
|
2547
|
+
// 파일 존재 여부 먼저 확인 (GET, 실제 다운로드 전)
|
|
2548
|
+
const res = await fetch(url);
|
|
2549
|
+
if (!res.ok) {
|
|
2550
|
+
alert('원본 파일을 찾을 수 없습니다.\n(수집 시 원본이 보관되지 않았거나 삭제되었습니다)');
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
const blob = await res.blob();
|
|
2554
|
+
const disposition = res.headers.get('Content-Disposition') || '';
|
|
2555
|
+
const match = disposition.match(/filename\*?=(?:UTF-8'')?([^;]+)/i);
|
|
2556
|
+
const fileName = match ? decodeURIComponent(match[1].trim()) : 'download';
|
|
2557
|
+
const a = document.createElement('a');
|
|
2558
|
+
a.href = URL.createObjectURL(blob);
|
|
2559
|
+
a.download = fileName;
|
|
2560
|
+
document.body.appendChild(a);
|
|
2561
|
+
a.click();
|
|
2562
|
+
document.body.removeChild(a);
|
|
2563
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
|
|
2564
|
+
} catch (e) {
|
|
2565
|
+
alert('다운로드 오류: ' + e.message);
|
|
2566
|
+
}
|
|
2567
|
+
},
|
|
2568
|
+
|
|
2569
|
+
closeDrawer() {
|
|
2570
|
+
document.getElementById('drawer-overlay').classList.remove('open');
|
|
2571
|
+
document.getElementById('report-drawer').classList.remove('open');
|
|
2572
|
+
},
|
|
2573
|
+
|
|
2574
|
+
onLlmProviderChange() {
|
|
2575
|
+
const selected = document.querySelector('input[name="llm-provider"]:checked');
|
|
2576
|
+
const ollamaConfig = document.getElementById('ollama-config');
|
|
2577
|
+
if (ollamaConfig) ollamaConfig.style.display = selected?.value === 'ollama' ? 'block' : 'none';
|
|
2578
|
+
},
|
|
2579
|
+
|
|
2580
|
+
updateOllamaUrl() {
|
|
2581
|
+
const host = document.getElementById('settings-ollama-host')?.value.trim() || 'localhost';
|
|
2582
|
+
const port = document.getElementById('settings-ollama-port')?.value.trim() || '11434';
|
|
2583
|
+
const urlEl = document.getElementById('settings-ollama-url');
|
|
2584
|
+
if (urlEl) urlEl.value = `http://${host}:${port}/v1/chat/completions`;
|
|
2585
|
+
},
|
|
2586
|
+
|
|
2587
|
+
async saveLlmConfig() {
|
|
2588
|
+
try {
|
|
2589
|
+
const selected = document.querySelector('input[name="llm-provider"]:checked');
|
|
2590
|
+
if (!selected) { alert('LLM을 선택해주세요.'); return; }
|
|
2591
|
+
const settings = { 'llm.provider': selected.value };
|
|
2592
|
+
if (selected.value === 'ollama') {
|
|
2593
|
+
const model = document.getElementById('settings-ollama-model')?.value.trim();
|
|
2594
|
+
const host = document.getElementById('settings-ollama-host')?.value.trim() || 'localhost';
|
|
2595
|
+
const port = document.getElementById('settings-ollama-port')?.value.trim() || '11434';
|
|
2596
|
+
const url = `http://${host}:${port}/v1/chat/completions`;
|
|
2597
|
+
if (model) settings['llm.ollama.model'] = model;
|
|
2598
|
+
settings['llm.ollama.host'] = host;
|
|
2599
|
+
settings['llm.ollama.port'] = port;
|
|
2600
|
+
settings['llm.ollama.httpUrl'] = url;
|
|
2601
|
+
}
|
|
2602
|
+
const res = await fetch('/api/settings', {
|
|
2603
|
+
method: 'POST',
|
|
2604
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2605
|
+
body: JSON.stringify({ settings }),
|
|
2606
|
+
});
|
|
2607
|
+
if (!res.ok) throw new Error('서버 오류');
|
|
2608
|
+
alert(`LLM이 "${selected.value}"로 저장되었습니다.`);
|
|
2609
|
+
} catch (e) {
|
|
2610
|
+
alert('저장 실패: ' + e.message);
|
|
2611
|
+
}
|
|
2612
|
+
},
|
|
2613
|
+
};
|
|
2614
|
+
|
|
2615
|
+
// hash 기반 네비게이션 → SPA router(popstate)로 전환됨
|
|
2616
|
+
|
|
2617
|
+
// ─── Init ───
|
|
2618
|
+
app.init();
|