From 9bcba0a3176f10433a4e4567568d8d76c9eb1063 Mon Sep 17 00:00:00 2001 From: windpacer Date: Sun, 24 May 2026 18:47:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9B=B9UI=20Phase=204=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=E2=80=94=20CSS=20=EB=B6=84=EB=A6=AC,=20pane=20?= =?UTF-8?q?=EC=A4=91=EC=B2=A9=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20app.js=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 — CSS 분리: - style.css(2,230→670줄)에서 탭별 스타일을 css/.css 8개로 분할 (t2s:437, pid:236, pb:106, hist:100, evt:50, opcsvr:14, llmchat:501, kbadmin:109) - 크로스탭 공유 스타일(nm-*, hist-status, dt-picker 등)은 style.css 잔류 - index.html head에 11개 CSS link 태그 (1 style.css + 8 tab + 2 lib) app.js 제거: - index.html에서 - + + + + + + + + + + + + + + + + diff --git a/src/Web/wwwroot/js/app.js b/src/Web/wwwroot/js/app.js index 6837422..ddf4125 100644 --- a/src/Web/wwwroot/js/app.js +++ b/src/Web/wwwroot/js/app.js @@ -1,5148 +1,10 @@ -/* ── Text-to-SQL Export Variables ────────────────────────────────────────── */ -let _t2sLastResult = null; // Excel export용 — 마지막으로 렌더된 결과 보관 - -/* ── Tab navigation ────────────────────────────────────────── */ -document.querySelectorAll('.nav-item').forEach(item => { - item.addEventListener('click', () => { - const tab = item.dataset.tab; - document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); - document.querySelectorAll('.pane').forEach(p => p.classList.remove('active')); - item.classList.add('active'); - document.getElementById(`pane-${tab}`).classList.add('active'); - // nm-dash: 탭 진입 시 API 호출 없음 — 조회 버튼으로만 동작 - // pb: 탭 진입 시 API 호출 없음 — ▼ 옵션 불러오기 버튼으로만 동작 - // hist: 탭 진입 시 API 호출 없음 - if (tab === 'opcsvr') srvLoad(); - if (tab === 't2s') t2sInitMode(); - if (tab === 'fast') fastSessionsLoad(); - if (tab === 'docs') docsInit(); - }); -}); - -/* ── Helpers ────────────────────────────────────────────────── */ -function esc(s) { - return String(s ?? '') - .replace(/&/g,'&').replace(//g,'>'); -} - -function setGlobal(state, text) { - document.getElementById('g-dot').className = `dot ${state}`; - document.getElementById('g-txt').textContent = text.toUpperCase(); -} - -function log(id, lines) { - const el = document.getElementById(id); - if (!el) return; - el.classList.remove('hidden'); - el.innerHTML = lines.map(l => `
${esc(l.t)}
`).join(''); - el.scrollTop = el.scrollHeight; -} - -async function api(method, path, body) { - const opt = { method, headers: { 'Content-Type': 'application/json' } }; - if (body) opt.body = JSON.stringify(body); - const res = await fetch(path, opt); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); - return res.json(); -} - -/* ───────────────────────────────────────────────────────────── - 01 인증서 관리 -───────────────────────────────────────────────────────────── */ -async function certCreate() { - const clientHostName = document.getElementById('c-host').value.trim(); - const subjectAltNames = document.getElementById('c-san').value - .split(',').map(s => s.trim()).filter(Boolean); - const pfxPassword = document.getElementById('c-pw').value; - - setGlobal('busy', '인증서 생성 중'); - try { - const d = await api('POST', '/api/certificate/create', { - clientHostName, subjectAltNames, pfxPassword - }); - log('cert-log', [ - { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }, - ...(d.thumbPrint ? [{ c: 'inf', t: ' Thumbprint : ' + d.thumbPrint }] : []) - ]); - setGlobal(d.success ? 'ok' : 'err', d.success ? '인증서 완료' : '오류'); - if (d.success) { - document.getElementById('cert-dot').classList.add('on'); - await certStatus(); - } - } catch (e) { - log('cert-log', [{ c: 'err', t: '❌ ' + e.message }]); - setGlobal('err', '오류'); - } -} - -async function certStatus() { - const clientHostName = document.getElementById('c-host').value.trim() || 'dbsvr'; - try { - const d = await api('GET', `/api/certificate/status?clientHostName=${encodeURIComponent(clientHostName)}`); - const box = document.getElementById('cert-disp'); - if (d.exists) { - box.innerHTML = ` -
상태✅ 인증서 있음
-
Subject${esc(d.subjectName)}
-
만료일${d.notAfter ? new Date(d.notAfter).toLocaleDateString('ko-KR') : '-'}
-
Thumbprint${d.thumbPrint ? d.thumbPrint.slice(0,20)+'…' : '-'}
-
파일 경로${esc(d.filePath)}
- `; - document.getElementById('cert-dot').classList.add('on'); - setGlobal('ok', '인증서 확인됨'); - } else { - box.innerHTML = ` -
상태❌ 인증서 없음
-
경로${esc(d.filePath)}
- `; - setGlobal('', 'READY'); - } - } catch {} -} - -/* ───────────────────────────────────────────────────────────── - 02 서버 접속 테스트 -───────────────────────────────────────────────────────────── */ -function getServerCfg(p) { - return { - serverHostName: document.getElementById(`${p}-server`).value.trim(), - port: parseInt(document.getElementById(`${p}-port`).value) || 4840, - clientHostName: document.getElementById(`${p}-client`).value.trim(), - userName: document.getElementById(`${p}-user`).value.trim(), - password: document.getElementById(`${p}-pass`).value - }; -} - -async function connTest() { - setGlobal('busy', '접속 중'); - try { - const d = await api('POST', '/api/connection/test', getServerCfg('x')); - log('conn-log', [ - { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }, - ...(d.sessionId ? [{ c: 'inf', t: ' SessionID : ' + d.sessionId }] : []), - ...(d.policyUri ? [{ c: 'inf', t: ' Policy : ' + d.policyUri.split('/').pop() }] : []) - ]); - setGlobal(d.success ? 'ok' : 'err', d.success ? '연결 성공' : '연결 실패'); - } catch (e) { - log('conn-log', [{ c: 'err', t: '❌ ' + e.message }]); - setGlobal('err', '오류'); - } -} - -async function connRead() { - const nodeId = document.getElementById('x-node').value.trim(); - if (!nodeId) return; - setGlobal('busy', '태그 읽기'); - try { - const d = await api('POST', '/api/connection/read', { - serverConfig: getServerCfg('x'), nodeId - }); - const box = document.getElementById('tag-box'); - box.classList.remove('hidden'); - if (d.success) { - box.innerHTML = ` -
✅ 읽기 성공
-
NodeID : ${esc(d.nodeId)}
-
Value : ${esc(d.value ?? 'null')}
-
Status : ${esc(d.statusCode)}
-
Time : ${d.timestamp ? new Date(d.timestamp).toLocaleString('ko-KR') : '-'}
- `; - setGlobal('ok', '읽기 완료'); - } else { - box.innerHTML = `
❌ ${esc(d.error || '읽기 실패')}
`; - setGlobal('err', '읽기 실패'); - } - } catch (e) { - setGlobal('err', '오류'); - } -} - -async function llmLoadConfig() { - const statusEl = document.getElementById('llm-status'); - const input = document.getElementById('llm-model'); - statusEl.innerHTML = '불러오는 중...'; - try { - const d = await api('GET', '/api/llm/config'); - if (d.success) { - input.value = d.vllmModel || ''; - statusEl.innerHTML = `✓ 현재 모델: ${esc(d.vllmModel)}`; - } else { - statusEl.innerHTML = `✗ ${esc(d.error || '조회 실패')}`; - } - } catch (e) { - statusEl.innerHTML = `✗ ${esc(e.message)}`; - } -} - -async function llmSaveModelConfig() { - const statusEl = document.getElementById('llm-status'); - const model = document.getElementById('llm-model').value.trim(); - if (!model) { - statusEl.innerHTML = '모델명을 입력하세요'; - return; - } - statusEl.innerHTML = '저장 중...'; - try { - const d = await api('POST', '/api/llm/config', { vllm_model: model }); - if (d.success) { - statusEl.innerHTML = `✓ 모델 변경 완료: ${esc(d.vllmModel)} (다음 LLM 요청 시 반영)`; - } else { - statusEl.innerHTML = `✗ ${esc(d.error || '저장 실패')}`; - } - } catch (e) { - statusEl.innerHTML = `✗ ${esc(e.message)}`; - } -} - -async function connBrowse() { - const wrap = document.getElementById('browse-wrap'); - const logEl = document.getElementById('conn-log'); - setGlobal('busy', '노드 탐색'); - - // 로그 초기화 - logEl.classList.remove('hidden'); - logEl.innerHTML = '
[진행] 노드 탐색 시작...
'; - - try { - logEl.innerHTML += '
[진행] 서버: ' + getServerCfg('x').serverHostName + ':' + getServerCfg('x').port + '
'; - - const d = await api('POST', '/api/connection/browse', { - serverConfig: getServerCfg('x'), startNodeId: null - }); - - logEl.innerHTML += '
[진행] 응답 수신: success=' + d.success + ', nodes=' + (d.nodes ? d.nodes.length : 0) + '
'; - - if (d.success && d.nodes?.length) { - wrap.classList.remove('hidden'); - logEl.innerHTML += '
✅ [성공] 탐색 성공: ' + d.nodes.length + '개 노드
'; - - // 노드 이름 확인 로그 - const emptyNames = d.nodes.filter(n => !n.displayName || n.displayName.trim() === ''); - if (emptyNames.length > 0) { - logEl.innerHTML += '
⚠️ 노드 이름이 비어진 항목: ' + emptyNames.length + '개 (NodeId만 복사됨)
'; - } - - // 노드 목록 생성 (displayName이 없으면 NodeId를 노출 이름으로 사용) - const nodeListHtml = d.nodes.map(n => { - const displayName = n.displayName || '이름 없음'; - const nodeId = n.nodeId; - // 중복된 NodeId 건너뛰기 - const nodeHtml = ` -
- ${n.nodeClass === 'Object' ? '📂' : '📌'} - ${esc(displayName)} - [${n.nodeClass}] - 📄 ${esc(nodeId)} -
- `; - return nodeHtml; - }).join(''); - - wrap.innerHTML = - `
🎉 탐색 성공: ${d.nodes.length}개 노드 발견
` + - nodeListHtml; - setGlobal('ok', '탐색 완료'); - } else { - const errorMsg = d.error || '알 수 없는 에러'; - logEl.innerHTML += '
❌ [실패] 탐색 실패: ' + errorMsg + '
'; - wrap.classList.remove('hidden'); - wrap.innerHTML = `
❌ ${errorMsg}
`; - setGlobal('err', '탐색 실패'); - } - } catch (e) { - logEl.innerHTML += '
❌ [오류] 요청 실패: ' + e.message + '
'; - wrap.classList.remove('hidden'); - wrap.innerHTML = `
❌ ${e.message}
`; - setGlobal('err', '오류'); - } -} - -/* ───────────────────────────────────────────────────────────── - 03 데이터 크롤링 -───────────────────────────────────────────────────────────── */ -async function crawlStart() { - const nodeIds = document.getElementById('w-nodes').value - .trim().split('\n').map(s => s.trim()).filter(Boolean); - if (!nodeIds.length) { alert('노드 ID를 입력하세요.'); return; } - - const cfg = { - serverHostName: document.getElementById('w-server').value.trim(), - port: parseInt(document.getElementById('w-port').value) || 4840, - clientHostName: document.getElementById('w-client').value.trim(), - userName: document.getElementById('w-user').value.trim(), - password: document.getElementById('w-pass').value - }; - const intervalSeconds = parseInt(document.getElementById('w-interval').value) || 1; - const durationSeconds = parseInt(document.getElementById('w-duration').value) || 30; - - const btn = document.getElementById('crawl-btn'); - btn.disabled = true; - btn.textContent = '⏳ 수집 중...'; - - const prog = document.getElementById('crawl-prog'); - prog.classList.remove('hidden'); - document.getElementById('crawl-bar').style.width = '0%'; - document.getElementById('crawl-ptxt').textContent = '서버 연결 중...'; - document.getElementById('crawl-cnt').textContent = '0 건'; - setGlobal('busy', '크롤링 중'); - - // 백엔드는 동기식이므로 프런트에서 타이머로 UI 진행 표시 - let fake = 0; - const ticker = setInterval(() => { - fake = Math.min(fake + (100 / durationSeconds) * .5, 94); - document.getElementById('crawl-bar').style.width = fake + '%'; - document.getElementById('crawl-ptxt').textContent = `수집 중... ${Math.round(fake)}%`; - }, 500); - - try { - const d = await api('POST', '/api/crawl/start', { - serverConfig: cfg, nodeIds, intervalSeconds, durationSeconds - }); - clearInterval(ticker); - document.getElementById('crawl-bar').style.width = '100%'; - document.getElementById('crawl-ptxt').textContent = '수집 완료'; - document.getElementById('crawl-cnt').textContent = d.totalRecords + ' 건'; - - log('crawl-log', [ - { c: 'ok', t: `✅ 크롤링 완료 — ${d.totalRecords}개 레코드` }, - { c: 'inf', t: ` Session : ${d.sessionId}` }, - { c: 'inf', t: ` CSV : ${d.csvPath}` }, - { c: 'mut', t: '--- 미리보기 ---' }, - ...(d.preview || []).map(r => ({ - c: 'val', - t: ` [${new Date(r.collectedAt).toLocaleTimeString('ko-KR')}] ${r.nodeId} = ${r.value} (${r.statusCode})` - })) - ]); - setGlobal('ok', '크롤링 완료'); - } catch (e) { - clearInterval(ticker); - log('crawl-log', [{ c: 'err', t: '❌ ' + e.message }]); - setGlobal('err', '오류'); - } finally { - btn.disabled = false; - btn.innerHTML = '📡 크롤링 시작'; - } -} - -/* ───────────────────────────────────────────────────────────── - 03-B 노드맵 수집 -───────────────────────────────────────────────────────────── */ -async function nodeMapCrawl() { - const maxDepth = parseInt(document.getElementById('nm-depth').value) || 10; - const cfg = { - serverHostName: document.getElementById('w-server').value.trim(), - port: parseInt(document.getElementById('w-port').value) || 4840, - clientHostName: document.getElementById('w-client').value.trim(), - userName: document.getElementById('w-user').value.trim(), - password: document.getElementById('w-pass').value - }; - - const btn = document.getElementById('nm-btn'); - btn.disabled = true; - btn.textContent = '⏳ 탐색 중...'; - - const prog = document.getElementById('nm-prog'); - prog.classList.remove('hidden'); - document.getElementById('nm-bar').style.width = '0%'; - document.getElementById('nm-ptxt').textContent = '서버 연결 중...'; - document.getElementById('nm-cnt').textContent = ''; - setGlobal('busy', '노드맵 수집 중'); - - // 완료 시점을 알 수 없으므로 완만하게 90%까지 증가 - let fake = 0; - const ticker = setInterval(() => { - if (fake < 90) { - fake = Math.min(fake + 0.25, 90); - document.getElementById('nm-bar').style.width = fake + '%'; - document.getElementById('nm-ptxt').textContent = `노드 탐색 중... ${Math.round(fake)}%`; - } - }, 500); - - try { - const d = await api('POST', '/api/crawl/nodemap', { serverConfig: cfg, maxDepth }); - clearInterval(ticker); - document.getElementById('nm-bar').style.width = '100%'; - document.getElementById('nm-ptxt').textContent = d.success ? '탐색 완료' : '탐색 실패'; - document.getElementById('nm-cnt').textContent = d.success ? d.totalCount.toLocaleString() + '개 노드' : ''; - - log('nm-log', d.success ? [ - { c: 'ok', t: `✅ 노드맵 수집 완료 — ${d.totalCount.toLocaleString()}개 노드` }, - { c: 'inf', t: ` 저장 경로 : ${d.csvPath}` }, - { c: 'mut', t: ` 04 DB 저장 탭에서 raw_node_map 테이블에 적재할 수 있습니다.` } - ] : [ - { c: 'err', t: `❌ ${d.error || '탐색 실패'}` } - ]); - setGlobal(d.success ? 'ok' : 'err', d.success ? `노드 ${d.totalCount}개` : '실패'); - } catch (e) { - clearInterval(ticker); - log('nm-log', [{ c: 'err', t: '❌ ' + e.message }]); - setGlobal('err', '오류'); - } finally { - btn.disabled = false; - btn.textContent = '🗺 전체 노드맵 수집'; - } -} - -/* ───────────────────────────────────────────────────────────── - 04 DB 저장 -───────────────────────────────────────────────────────────── */ -async function dbLoadFiles() { - try { - const d = await api('GET', '/api/database/files'); - const list = document.getElementById('file-list'); - if (d.files?.length) { - list.innerHTML = d.files.map(f => ` -
- 📄${esc(f)} -
- `).join(''); - } else { - list.innerHTML = 'CSV 파일이 없습니다'; - } - } catch (e) { alert('목록 로드 실패: ' + e.message); } -} - -function selectFile(name, el) { - document.querySelectorAll('.fitem').forEach(i => i.classList.remove('sel')); - el.classList.add('sel'); - document.getElementById('sel-csv').value = name; -} - -async function dbImport() { - const fileName = document.getElementById('sel-csv').value.trim(); - if (!fileName) { alert('파일을 선택하세요.'); return; } - // experion_ 으로 시작하면 일반 크롤 CSV, 그 외는 노드맵 CSV (서버명_timestamp.csv) - const serverHostName = fileName.startsWith('experion_') ? '' : fileName.split('_')[0]; - const truncate = document.querySelector('input[name="import-mode"]:checked').value === 'truncate'; - if (truncate && !confirm('기존 데이터를 모두 삭제하고 새로 저장합니다.\n계속하시겠습니까?')) return; - setGlobal('busy', 'DB 저장 중'); - try { - const d = await api('POST', '/api/database/import', { fileName, serverHostName, truncate }); - const isNodeMap = !!serverHostName; - const lines = [ - { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message } - ]; - if (d.success && isNodeMap) { - lines.push({ c: 'inf', t: ` raw_node_map : ${d.count}건` }); - lines.push({ c: 'inf', t: ` node_map_master: ${d.masterCount}건` }); - } else if (d.success) { - lines.push({ c: 'inf', t: ` 저장된 레코드 : ${d.count}건` }); - } - log('db-log', lines); - setGlobal(d.success ? 'ok' : 'err', d.success ? 'DB 저장 완료' : '저장 실패'); - if (d.success) await dbQuery(); - } catch (e) { - log('db-log', [{ c: 'err', t: '❌ ' + e.message }]); - setGlobal('err', '오류'); - } -} - -async function dbQuery() { - const limit = parseInt(document.getElementById('db-limit').value) || 100; - setGlobal('busy', 'DB 조회'); - try { - const d = await api('GET', `/api/database/records?limit=${limit}`); - - const stats = document.getElementById('db-stats'); - stats.classList.remove('hidden'); - stats.innerHTML = ` -
${d.total.toLocaleString()}
전체 레코드
-
${(d.records||[]).length}
현재 조회
- `; - - const tbl = document.getElementById('db-table'); - tbl.classList.remove('hidden'); - if (d.records?.length) { - tbl.innerHTML = ` - - - - - - - - - - - - - ${d.records.map(r => ` - - - - - - - - - `).join('')} - -
IDNode IDValueStatus수집 시각Session
${r.id}${esc(r.nodeId)}${esc(r.value ?? 'null')}${esc(r.statusCode)}${new Date(r.collectedAt).toLocaleString('ko-KR')}${esc(r.sessionId ?? '-')}
- `; - } else { - tbl.innerHTML = '
저장된 레코드가 없습니다.
'; - } - setGlobal('ok', `${d.total}건`); - } catch (e) { setGlobal('err', '조회 실패'); } -} - -/* ───────────────────────────────────────────────────────────── - 05 노드맵 대시보드 -───────────────────────────────────────────────────────────── */ -let _nmOffset = 0; -let _nmTotal = 0; -let _nmLimit = 100; - -// 이름 드롭다운 옵션만 필요할 때 수동으로 불러오는 함수 (버튼 클릭 시) -async function nmLoadNames() { - try { - const n = await api('GET', '/api/nodemap/names'); - const nameOpts = '' + - (n.names||[]).map(nm => ``).join(''); - ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => { - const el = document.getElementById(id); - const cur = el.value; - el.innerHTML = nameOpts; - if (cur) el.value = cur; - }); - } catch (e) { console.error('nmLoadNames:', e); } -} - -async function nmQuery(offset) { - _nmOffset = offset ?? 0; - _nmLimit = parseInt(document.getElementById('nf-limit').value) || 100; - - const params = new URLSearchParams(); - const lvMin = document.getElementById('nf-lv-min').value.trim(); - const lvMax = document.getElementById('nf-lv-max').value.trim(); - const cls = document.getElementById('nf-class').value; - const nid = document.getElementById('nf-nid').value.trim(); - const dtype = document.getElementById('nf-dtype').value.trim(); - const selNames = ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'] - .map(id => document.getElementById(id).value) - .filter(Boolean); - - if (lvMin) params.set('minLevel', lvMin); - if (lvMax) params.set('maxLevel', lvMax); - if (cls) params.set('nodeClass', cls); - selNames.forEach(nm => params.append('names', nm)); - if (nid) params.set('nodeId', nid); - if (dtype) params.set('dataType', dtype); - params.set('limit', _nmLimit); - params.set('offset', _nmOffset); - - setGlobal('busy', '조회 중'); - try { - const d = await api('GET', `/api/nodemap/query?${params}`); - _nmTotal = d.total; - - // 결과 바 - const bar = document.getElementById('nm-result-bar'); - bar.classList.remove('hidden'); - const from = _nmOffset + 1; - const to = Math.min(_nmOffset + _nmLimit, _nmTotal); - document.getElementById('nm-result-info').textContent = - `총 ${_nmTotal.toLocaleString()}건 중 ${from.toLocaleString()}–${to.toLocaleString()}건`; - - const totalPages = Math.ceil(_nmTotal / _nmLimit) || 1; - const curPage = Math.floor(_nmOffset / _nmLimit) + 1; - document.getElementById('nm-pg-info').textContent = `${curPage} / ${totalPages} 페이지`; - document.getElementById('nm-pg-prev').disabled = _nmOffset === 0; - document.getElementById('nm-pg-next').disabled = to >= _nmTotal; - - // 테이블 렌더 - const tbl = document.getElementById('nm-table'); - tbl.classList.remove('hidden'); - if (d.items?.length) { - tbl.innerHTML = ` - - - - - - - - - ${d.items.map(r => ` - - - - - - - - - `).join('')} - -
IDLevelClassNameNode IDData Type
${r.id}${r.level}${esc(r.class)}${esc(r.name)}${esc(r.nodeId)}${esc(r.dataType)}
- `; - } else { - tbl.innerHTML = '
조건에 맞는 노드가 없습니다.
'; - } - setGlobal('ok', `${_nmTotal.toLocaleString()}건`); - } catch (e) { - setGlobal('err', '조회 실패'); - console.error('nmQuery:', e); - } -} - -function nmPrev() { - if (_nmOffset > 0) nmQuery(Math.max(0, _nmOffset - _nmLimit)); -} -function nmNext() { - if (_nmOffset + _nmLimit < _nmTotal) nmQuery(_nmOffset + _nmLimit); -} -function nmReset() { - document.getElementById('nf-lv-min').value = ''; - document.getElementById('nf-lv-max').value = ''; - document.getElementById('nf-class').value = ''; - document.getElementById('nf-nid').value = ''; - document.getElementById('nf-dtype').value = ''; - document.getElementById('nf-limit').value = '100'; - ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => { - document.getElementById(id).value = ''; - }); - // 초기화 후 자동 조회 없음 - document.getElementById('nm-result-bar').classList.add('hidden'); - document.getElementById('nm-table').classList.add('hidden'); -} - -/* ───────────────────────────────────────────────────────────── - 06 포인트빌더 -───────────────────────────────────────────────────────────── */ -const PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom']; -let pbPreviewData = []; - -function pbCollectGroupData(groupKey) { - const patternInput = document.querySelector(`input[data-group="${groupKey}"][data-field="tagPatterns"]`); - const tagPatterns = patternInput?.value - ? patternInput.value.split(',').map(s => s.trim()).filter(Boolean) - : []; - - const checkedAttrs = Array.from( - document.querySelectorAll(`input[data-group="${groupKey}"][data-field="attributes"]:checked`) - ).map(cb => cb.value); - - const customInputs = document.querySelectorAll(`input[data-group="${groupKey}"][data-field="customAttrs"]`); - customInputs.forEach(inp => { - const v = inp.value.trim(); - if (v) checkedAttrs.push(v); - }); - - const dataTypeEl = document.querySelector(`select[data-group="${groupKey}"][data-field="dataType"]`); - const dataType = dataTypeEl?.value || null; - - return { tagPatterns, attributes: checkedAttrs, dataType }; -} - -async function pbBuild() { - const groups = {}; - for (const gk of PB_GROUPS) { - const gd = pbCollectGroupData(gk); - if (gd.tagPatterns.length > 0) { - groups[gk] = gd; - } - } - - const activeKeys = Object.keys(groups); - if (activeKeys.length === 0) { - const logEl = document.getElementById('pb-build-log'); - logEl.classList.remove('hidden'); - logEl.innerHTML = '
⚠️ 태그명 패턴을 최소 1개 입력하세요.
'; - return; - } - - setGlobal('busy', '포인트 빌드 중'); - try { - const d = await api('POST', '/api/pointbuilder/build', groups); - const logEl = document.getElementById('pb-build-log'); - logEl.classList.remove('hidden'); - logEl.innerHTML = `
${d.success ? '✅' : '❌'} ${esc(d.message)}
`; - setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트` : '빌드 실패'); - if (d.success) await pbRefresh(); - } catch (e) { - const logEl = document.getElementById('pb-build-log'); - logEl.classList.remove('hidden'); - logEl.innerHTML = `
❌ ${esc(e.message)}
`; - setGlobal('err', '오류'); - } -} - -async function pbPreview() { - const groups = {}; - for (const gk of PB_GROUPS) { - const gd = pbCollectGroupData(gk); - if (gd.tagPatterns.length > 0) { - groups[gk] = gd; - } - } - - const activeKeys = Object.keys(groups); - if (activeKeys.length === 0) { - const logEl = document.getElementById('pb-build-log'); - logEl.classList.remove('hidden'); - logEl.innerHTML = '
⚠️ 태그명 패턴을 최소 1개 입력하세요.
'; - return; - } - - setGlobal('busy', '미리보기 조회 중'); - try { - const d = await api('POST', '/api/pointbuilder/preview', groups); - pbPreviewData = (d.items || []).map((item, idx) => ({ ...item, selected: true, idx })); - document.getElementById('pb-preview-count').textContent = `(${d.count}개)`; - document.getElementById('pb-preview').classList.remove('hidden'); - pbRenderPreview(pbPreviewData); - setGlobal('ok', `미리보기: ${d.count}개 포인트`); - } catch (e) { - setGlobal('err', '미리보기 실패'); - } -} - -function pbRenderPreview(data) { - const el = document.getElementById('pb-preview-table'); - const filtered = pbGetFilteredPreview(); - const pts = filtered.length > 0 ? filtered : data; - - if (pts.length === 0) { - el.innerHTML = '
조건에 맞는 포인트가 없습니다.
'; - pbUpdatePreviewCount(); - return; - } - - el.innerHTML = ` - - - - - - - - - - - - - ${pts.map((p, i) => ` - - - - - - - - - `).join('')} - -
IDTagNameNodeTypeDataTypeGroup
${i + 1}${esc((p?.tagName)?.toUpperCase() || '')}${esc(p?.name || '')}${esc(p?.dataType || '')}${esc(p?.group || '')}
- `; - pbUpdatePreviewCount(); -} - -function pbPreviewToggleItem(idx) { - pbPreviewData[idx].selected = !pbPreviewData[idx].selected; - pbRenderPreview(pbPreviewData); -} - -function pbPreviewToggleAll(checked) { - pbPreviewData.forEach((item) => { - const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase(); - if (searchVal) { - const filtered = pbGetFilteredPreview(); - if (filtered.includes(item)) { - item.selected = checked; - } - } else { - item.selected = checked; - } - }); - pbRenderPreview(pbPreviewData); -} - -function pbPreviewSelectAll() { - pbPreviewData.forEach(p => p.selected = true); - pbRenderPreview(pbPreviewData); -} - -function pbPreviewDeselectAll() { - pbPreviewData.forEach(p => p.selected = false); - pbRenderPreview(pbPreviewData); -} - -function pbPreviewInvert() { - pbPreviewData.forEach(p => p.selected = !p.selected); - pbRenderPreview(pbPreviewData); -} - -function pbGetFilteredPreview() { - const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase(); - if (!searchVal) return []; - return pbPreviewData.filter(p => - (p.tagName || '').toLowerCase().includes(searchVal) || - (p.nodeId || '').toLowerCase().includes(searchVal) || - (p.name || '').toLowerCase().includes(searchVal) - ); -} - -function pbPreviewFilter() { - pbRenderPreview(pbPreviewData); -} - -function pbUpdatePreviewCount() { - const selected = pbPreviewData.filter(p => p.selected).length; - const total = pbPreviewData.length; - document.getElementById('pb-preview-selected').textContent = `선택: ${selected}/${total}`; -} - -function pbCancelPreview() { - document.getElementById('pb-preview').classList.add('hidden'); - pbPreviewData = []; -} - -async function pbApplySelected() { - const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId); - if (selected.length === 0) { - setGlobal('err', '적용할 포인트를 선택하세요.'); - return; - } - - setGlobal('busy', `${selected.length}개 포인트 적용 중`); - try { - const d = await api('POST', '/api/pointbuilder/apply', { selectedNodeIds: selected }); - setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 적용 완료` : '적용 실패'); - if (d.success) { - pbCancelPreview(); - await pbRefresh(); - } - } catch (e) { - setGlobal('err', '적용 오류'); - } -} - -async function pbAppendSelected() { - const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId); - if (selected.length === 0) { - setGlobal('err', '추가할 포인트를 선택하세요.'); - return; - } - - setGlobal('busy', `${selected.length}개 포인트 추가 중`); - try { - const d = await api('POST', '/api/pointbuilder/append', { selectedNodeIds: selected }); - setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 추가 완료` : '추가 실패'); - if (d.success) { - pbCancelPreview(); - await pbRefresh(); - } - } catch (e) { - setGlobal('err', '추가 오류'); - } -} - -async function pbRefresh() { - try { - const d = await api('GET', '/api/pointbuilder/points'); - document.getElementById('pb-count').textContent = `(${d.total}개)`; - pbRender(d.items || []); - rtStatus(); - } catch (e) { console.error('pbRefresh:', e); } -} - -function pbRender(points) { - const tbl = document.getElementById('pb-table'); - const pts = Array.isArray(points) ? points : []; - if (pts.length === 0) { - tbl.innerHTML = '
포인트가 없습니다. 위에서 테이블을 작성하세요.
'; - return; - } - tbl.innerHTML = ` - - - - - - - - - ${pts.map(p => ` - - - - - - - - `).join('')} - -
IDTagNameLiveValueTimestamp이력 / 삭제
${esc(p?.id || '')}${esc((p?.tagName)?.toUpperCase() || '')}${p?.liveValue != null ? esc(String(fmtVal(parseEnumPv(p.liveValue)))) : ''}${p?.liveValue != null ? fmtTs(p.timestamp) : '—'} - - -
- `; -} - -async function pbAddManual() { - const nodeId = document.getElementById('pb-manual-nid').value.trim(); - if (!nodeId) { alert('Node ID를 입력하세요.'); return; } - try { - const d = await api('POST', '/api/pointbuilder/add', { nodeId }); - log('pb-manual-log', [{ c: d.success ? 'ok' : 'err', t: d.success - ? `✅ 추가됨: ${d.point?.tagName} (${d.point?.nodeId})` - : '❌ 추가 실패' }]); - if (d.success) { - document.getElementById('pb-manual-nid').value = ''; - await pbRefresh(); - } - } catch (e) { - log('pb-manual-log', [{ c: 'err', t: '❌ ' + e.message }]); - } -} - -async function pbDelete(id, tagName) { - const label = tagName ? `${tagName.toUpperCase()} (#${id})` : `#${id}`; - // 행의 "이력" 체크박스 = 이력 함께 삭제 opt-in (기본 미체크 = 이력 보존) - const purgeHistory = !!document.getElementById('pb-hist-' + id)?.checked; - - const msg = purgeHistory - ? `포인트 ${label} 삭제 + 이력 영구 삭제\n\n· realtime 포인트 삭제\n· 잔여 포인트 없으면 메타데이터(area/sub_area) 정리\n· ⚠️ 이력(history_table)까지 영구 삭제 — 복구 불가\n\n계속하시겠습니까?` - : `포인트 ${label}를 삭제하시겠습니까?\n\n· realtime 포인트 삭제\n· 잔여 포인트 없으면 메타데이터(area/sub_area) 정리\n· 이력(history)은 보존 ('이력' 체크 시 함께 삭제)`; - if (!confirm(msg)) return; - - try { - const d = await api('DELETE', `/api/pointbuilder/${id}?purgeHistory=${purgeHistory}`); - if (d.success) { - const parts = []; - if (d.metadataPurged) parts.push('메타데이터 정리됨'); - if (d.historyRowsDeleted > 0) parts.push(`이력 ${d.historyRowsDeleted.toLocaleString()}행 삭제`); - else if (purgeHistory) parts.push('이력 없음'); - else parts.push('이력 보존'); - alert(`삭제 완료: ${label}\n(${parts.join(' · ')})`); - await pbRefresh(); - } else { - alert('삭제 실패: ' + d.message); - } - } catch (e) { alert('삭제 오류: ' + e.message); } -} - -/* ── 실시간 구독 제어 ────────────────────────────────────────── */ -async function rtStart() { - const body = { - serverHostName: document.getElementById('pb-rt-ip').value.trim(), - port: parseInt(document.getElementById('pb-rt-port').value) || 4840, - clientHostName: document.getElementById('pb-rt-client').value.trim(), - userName: document.getElementById('pb-rt-user').value.trim(), - password: document.getElementById('pb-rt-pw').value - }; - setGlobal('busy', '구독 시작 중'); - try { - const d = await api('POST', '/api/realtime/start', body); - log('pb-rt-status', [{ c: 'ok', t: '▶ ' + d.message }]); - setGlobal('ok', '구독 중'); - } catch (e) { - log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]); - setGlobal('err', '오류'); - } -} - -async function rtStop() { - setGlobal('busy', '구독 중지 중'); - try { - const d = await api('POST', '/api/realtime/stop'); - log('pb-rt-status', [{ c: 'inf', t: '■ ' + d.message }]); - setGlobal('ok', '중지됨'); - } catch (e) { log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]); } -} - -async function rtStatus() { - try { - const d = await api('GET', '/api/realtime/status'); - const logEl = document.getElementById('pb-rt-status'); - logEl.classList.remove('hidden'); - logEl.innerHTML = `
- ${d.running ? '▶' : '■'} ${esc(d.message)} (구독 ${d.subscribedCount}개) -
`; - } catch (e) { /* 무시 */ } -} - -/* ── 메타데이터 관리 ─────────────────────────────────────────── */ - -async function metaReload() { - const body = { - serverHostName: document.getElementById('pb-rt-ip').value.trim(), - port: parseInt(document.getElementById('pb-rt-port').value) || 4840, - clientHostName: document.getElementById('pb-rt-client').value.trim(), - userName: document.getElementById('pb-rt-user').value.trim(), - password: document.getElementById('pb-rt-pw').value - }; - const logEl = document.getElementById('meta-log'); - logEl.classList.remove('hidden'); - logEl.innerHTML = '
⏳ 메타데이터 갱신 중...
'; - try { - const d = await api('POST', '/api/tags/metadata/reload', body); - logEl.innerHTML = `
${d.success ? '✅' : '❌'} ${esc(d.message)}
`; - } catch (e) { - logEl.innerHTML = `
❌ ${esc(e.message)}
`; - } -} - -async function metaView() { - const viewEl = document.getElementById('meta-view'); - viewEl.classList.remove('hidden'); - viewEl.innerHTML = '
⏳ 조회 중...
'; - try { - const d = await api('GET', '/api/tags/metadata'); - const items = d.items || []; - if (items.length === 0) { - viewEl.innerHTML = '
메타데이터가 없습니다.
'; - return; - } - viewEl.innerHTML = ` - - - - ${items.map(m => ` - - - - - - - `).join('')} - -
BaseTagAttributeValueLoadedAt
${esc(m.baseTag)}${esc(m.attribute)}${esc(m.value || '-')}${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}
- `; - } catch (e) { - viewEl.innerHTML = `
❌ ${esc(e.message)}
`; - } -} - -/* ── Sub-Area (세부 Area) 관리 ───────────────────────────────── */ -// area별 선택 가능한 sub_area 옵션 (마지막은 공용 = 두 코드) -const SUBAREA_OPTIONS = { - P6: ['P6-1', 'P6-2', 'P6-1,P6-2'], - P9: ['P9-1', 'P9-2', 'P9-1,P9-2'], - P10: ['P10-1', 'P10-2', 'P10-1,P10-2'], - P1: ['P1-1', 'P1-2', 'P1-1,P1-2'], - P2: ['P2-1', 'P2-2', 'P2-1,P2-2'], -}; - -function subAreaLabel(code) { - if (!code) return '(미분류)'; - return code.includes(',') ? `${code} (공용)` : code; -} - -async function subAreaLoad() { - const area = document.getElementById('subarea-area-select').value; - const viewEl = document.getElementById('subarea-view'); - viewEl.classList.remove('hidden'); - viewEl.innerHTML = '
⏳ 조회 중...
'; - try { - const d = await api('GET', `/api/tags/sub-area?area=${encodeURIComponent(area)}&page=1&pageSize=500`); - const tags = d.tags || []; - if (tags.length === 0) { - viewEl.innerHTML = '
태그가 없습니다.
'; - return; - } - const opts = SUBAREA_OPTIONS[area] || []; - viewEl.innerHTML = ` -
총 ${d.total}개 · 미분류는 (미분류)로 두면 NULL 유지
- - - - ${tags.map(t => { - const cur = t.subArea || ''; - const optionHtml = ['', ...opts].map(o => - `` - ).join(''); - // 현재값이 옵션에 없으면(수동 지정 등) 추가 - const extra = (cur && !opts.includes(cur)) - ? `` : ''; - return ` - - - - - `; - }).join('')} - -
BaseTagDescriptionSub-Area
${esc(t.baseTag)}${esc(t.description || '-')} - -
`; - } catch (e) { - viewEl.innerHTML = `
❌ ${esc(e.message)}
`; - } -} - -async function subAreaUpdate(baseTag, subArea) { - const logEl = document.getElementById('subarea-log'); - logEl.classList.remove('hidden'); - try { - const d = await api('PUT', '/api/tags/sub-area', { baseTag, subArea: subArea || null }); - logEl.innerHTML = `
${d.success ? '✅' : '❌'} ${esc(baseTag)} → ${esc(subAreaLabel(subArea))}
`; - } catch (e) { - logEl.innerHTML = `
❌ ${esc(e.message)}
`; - } -} - -async function subAreaSeed(dryRun) { - const msg = dryRun - ? 'DryRun으로 분류 결과만 미리 봅니다(저장 안 함). 계속할까요?' - : '실제 seed를 실행합니다(기존 sub_area는 보존, 신규만 추가). 계속할까요?'; - if (!confirm(msg)) return; - const logEl = document.getElementById('subarea-log'); - logEl.classList.remove('hidden'); - logEl.innerHTML = `
⏳ Seed ${dryRun ? 'DryRun' : '실행'} 중...
`; - try { - const d = await api('POST', '/api/tags/sub-area/seed', { dryRun }); - const by = d.bySubArea || {}; - const byStr = Object.keys(by).sort().map(k => `${k}=${by[k]}`).join(', '); - logEl.innerHTML = ` -
- ${d.success ? '✅' : '❌'} ${dryRun ? '[DryRun] ' : ''}단일 ${d.assigned} · 공용 ${d.shared} · 미분류 ${d.unmatched} -
-
분류별: ${esc(byStr || '-')}
`; - subAreaLoad(); - } catch (e) { - logEl.innerHTML = `
❌ ${esc(e.message)}
`; - } -} - -/* ───────────────────────────────────────────────────────────── - 07 이력 조회 -───────────────────────────────────────────────────────────── */ -const HIST_TAG_IDS = ['hf-t1','hf-t2','hf-t3','hf-t4','hf-t5','hf-t6','hf-t7','hf-t8']; - -// 상태 표시기 업데이트 함수 -function histUpdateStatus(state, message) { - const el = document.getElementById('hist-load-status'); - if (!el) return; - - // 상태 클래스 제거 후 재설정 - el.classList.remove('loading', 'success', 'error'); - - if (state) { - el.classList.add(state); - } - - // 아이콘/텍스트 업데이트 - .status-dot span은 유지 - const icons = { loading: '⏳', success: '✅', error: '❌' }; - const icon = icons[state] || ''; - - // .status-dot span 찾기 - const dot = el.querySelector('.status-dot'); - - // 텍스트 노드 업데이트 (첫 번째 텍스트 노드) - const textNode = Array.from(el.childNodes).find(n => n.nodeType === Node.TEXT_NODE); - if (textNode) { - textNode.textContent = icon ? `${icon} ${message}` : message; - } -} - -// "▼ 옵션 불러오기" 버튼 클릭 시에만 호출 — 탭 진입 시 자동 호출 없음 -async function histLoad() { - const el = document.getElementById('hist-load-status'); - if (!el) return; - - // 로딩 상태 표시 - histUpdateStatus('loading', '조회 중...'); - - try { - const startTime = Date.now(); - const d = await api('GET', '/api/history/tagnames'); - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - - const tagNames = d.tagNames || []; - - if (tagNames.length === 0) { - histUpdateStatus('error', '조회 데이터 없음 (0개)'); - } else { - const opts = '' + - tagNames.map(t => ``).join(''); - - // 기존 선택값 보존 - const selectedValues = HIST_TAG_IDS.map(id => document.getElementById(id).value); - - // DOM 업데이트를 requestAnimationFrame으로 지연하여 레이아웃 충돌 방지 - requestAnimationFrame(() => { - HIST_TAG_IDS.forEach((id, index) => { - const sel = document.getElementById(id); - sel.innerHTML = opts; - if (selectedValues[index]) sel.value = selectedValues[index]; - }); - }); - - histUpdateStatus('success', `옵션 조회 완료 (${tagNames.length}개, ${elapsed}초)`); - } - } catch (e) { - console.error('histLoad:', e); - histUpdateStatus('error', `조회 실패: ${e.message}`); - } -} - -async function histQuery() { - const tags = HIST_TAG_IDS.map(id => document.getElementById(id).value).filter(Boolean); - const from = document.getElementById('hf-from').value; - const to = document.getElementById('hf-to').value; - const interval = document.getElementById('hf-interval').value || '5 minutes'; - const limit = parseInt(document.getElementById('hf-limit').value) || 500; - - if (!tags.length) { - histShowStatus('err', '❌', '태그를 최소 1개 선택하세요', ' 태그 선택란에서 태그를 선택해주세요.'); - return; - } - - // 간격이 기본값(60초)과 다른 경우 시간 간격 조회 API 사용 - if (interval === '1 minute') { - // 기본 간격(1분) 이하인 경우 기존 API 사용 - const params = new URLSearchParams(); - tags.forEach(t => params.append('tagNames', t)); - if (from) params.set('from', new Date(from).toISOString()); - if (to) params.set('to', new Date(to).toISOString()); - params.set('limit', limit); - - // 상태 표시: 조회 시작 - histShowStatus('busy', '⏳', '조회 중...', ` ${tags.length}개 태그, 제한: ${limit}행`); - - try { - const d = await api('GET', `/api/history/query?${params}`); - const rows = d.rows || []; - const tNames = d.tagNames || []; - - renderHistoryTable(rows, tNames, interval); - } catch (e) { - histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`); - setGlobal('err', '조회 실패'); - console.error('histQuery 오류:', e); - } - } else { - // 사용자 지정 간격인 경우 시간 간격 조회 API 사용 - const body = { - tagNames: tags, - from: from ? new Date(from).toISOString() : null, - to: to ? new Date(to).toISOString() : null, - interval: interval, - limit: limit - }; - - // 상태 표시: 조회 시작 - histShowStatus('busy', '⏳', '조회 중...', ` ${tags.length}개 태그, 간격: ${interval}, 제한: ${limit}행`); - - try { - const d = await api('POST', '/api/text-to-sql/query-history-interval', body); - - if (!d.success) { - throw new Error(d.error || '조회 실패'); - } - - const rows = d.rows || []; - const tNames = d.tagNames || []; - - renderHistoryTable(rows, tNames, interval, d.baseIntervalSeconds, d.queryInterval); - } catch (e) { - histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`); - setGlobal('err', '조회 실패'); - console.error('histQuery 오류:', e); - } - } -} - -/** - * 이력 조회 결과 테이블 렌더링 - */ -function renderHistoryTable(rows, tNames, interval, baseIntervalSeconds, queryInterval) { - const tbl = document.getElementById('hist-table'); - tbl.classList.remove('hidden'); - - const info = document.getElementById('hist-result-info'); - info.classList.remove('hidden'); - - let infoText = `총 ${rows.length.toLocaleString()}행 × ${tNames.length}개 태그`; - if (queryInterval) { - infoText += ` | 집계 간격: ${queryInterval}`; - } - info.textContent = infoText; - - if (!rows.length) { - histShowStatus('ok', '✅', '조회 완료 (0건)', ` 조건에 맞는 데이터가 없습니다. | 태그: ${tNames.join(', ') || '전체'}`); - tbl.innerHTML = '
조건에 맞는 이력이 없습니다.
'; - setGlobal('ok', '0건'); - return; - } - - // 시간 간격 조회인 경우 TimeBucket 열 사용 - const timeColumn = rows[0].timeBucket ? 'timeBucket' : 'recordedAt'; - - tbl.innerHTML = ` - - - - - ${tNames.map(t => ``).join('')} - - - - ${rows.map(r => ` - - - ${tNames.map(t => { - const raw = r.values?.[t] ?? null; - const display = raw != null ? esc(String(fmtVal(raw))) : ''; - return ``; - }).join('')} - - `).join('')} - -
${queryInterval ? '집계 시각' : '시각'}${esc(t.toUpperCase())}
${fmtTs(r[timeColumn])}${display}
- `; - - let detailText = ` 태그: ${tNames.join(', ')}`; - if (queryInterval) { - detailText += ` | 집계 간격: ${queryInterval}`; - } - detailText += ` | 시각 범위: ${document.getElementById('hf-from').value || '전체'} ~ ${document.getElementById('hf-to').value || '전체'}`; - - histShowStatus('ok', '✅', `조회 완료 (${rows.length}건)`, detailText); - setGlobal('ok', `${rows.length}건`); -} - -/** - * 이력 조회 상태 표시 창 업데이트 - * @param {string} state - 'pending' | 'busy' | 'ok' | 'err' - * @param {string} icon - 표시할 아이콘 - * @param {string} text - 메인 상태 텍스트 - * @param {string} detail - 상세 설명 - */ -function histShowStatus(state, icon, text, detail) { - const box = document.getElementById('hist-status-box'); - const iconEl = document.getElementById('hist-status-icon'); - const textEl = document.getElementById('hist-status-text'); - const detEl = document.getElementById('hist-status-detail'); - - box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err'); - box.classList.add(state); - iconEl.textContent = icon; - textEl.textContent = text; - detEl.textContent = detail || ''; -} - -function histReset() { - HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; }); - dtClearField('from'); - dtClearField('to'); - document.getElementById('hf-interval').value = '1 minute'; - document.getElementById('hf-limit').value = '500'; - document.getElementById('hist-result-info').classList.add('hidden'); - document.getElementById('hist-table').classList.add('hidden'); - - // 상태 표시 창 초기화 - histShowStatus('pending', '⏸', '대기 중', '조회 조건을 설정하고 조회 버튼을 눌러주세요.'); -} - -/* ───────────────────────────────────────────────────────────── - 12 이벤트 히스토리 -───────────────────────────────────────────────────────────── */ - -async function evtLoadTags() { - const statusEl = document.getElementById('evt-tag-status'); - statusEl.textContent = '⏳ 조회 중...'; - try { - const d = await api('GET', '/api/event-history/digital-tags'); - const tags = d.data || []; - const sel = document.getElementById('ef-tag'); - sel.innerHTML = '' + - tags.map(t => ``).join(''); - statusEl.textContent = `✅ ${tags.length}개`; - } catch (e) { - statusEl.textContent = `❌ ${e.message}`; - } -} - -async function evtQuery() { - const tag = document.getElementById('ef-tag').value; - const eventType = document.getElementById('ef-event-type').value; - const area = document.getElementById('ef-area').value.trim(); - const section = document.getElementById('ef-section').value.trim(); - const limit = document.getElementById('ef-limit').value || 500; - const fromRaw = document.getElementById('hf-evt-from').value; - const toRaw = document.getElementById('hf-evt-to').value; - - const params = new URLSearchParams(); - if (tag) params.set('tagName', tag); - if (eventType) params.set('eventType', eventType); - if (area) params.set('area', area); - if (section) params.set('section', section); - params.set('limit', limit); - if (fromRaw) params.set('from', new Date(fromRaw).toISOString()); - if (toRaw) params.set('to', new Date(toRaw).toISOString()); - - const infoEl = document.getElementById('evt-result-info'); - const tableEl = document.getElementById('evt-table'); - infoEl.textContent = '⏳ 조회 중...'; - infoEl.classList.remove('hidden'); - tableEl.classList.add('hidden'); - - try { - const d = await api('GET', `/api/event-history?${params}`); - if (!d.success) throw new Error(d.error || '조회 실패'); - infoEl.textContent = `총 ${d.count}건`; - tableEl.innerHTML = _evtBuildTable(d.data); - tableEl.classList.remove('hidden'); - } catch (e) { - infoEl.textContent = `❌ ${e.message}`; - } -} - -async function evtSummary() { - const area = document.getElementById('ef-area').value.trim(); - const section = document.getElementById('ef-section').value.trim(); - const fromRaw = document.getElementById('hf-evt-from').value; - const toRaw = document.getElementById('hf-evt-to').value; - - const params = new URLSearchParams(); - if (area) params.set('area', area); - if (section) params.set('section', section); - if (fromRaw) params.set('from', new Date(fromRaw).toISOString()); - if (toRaw) params.set('to', new Date(toRaw).toISOString()); - - const card = document.getElementById('evt-summary-card'); - const content = document.getElementById('evt-summary-content'); - content.textContent = '⏳ 집계 중...'; - card.classList.remove('hidden'); - - try { - const d = await api('GET', `/api/event-history/summary?${params}`); - if (!d.success) throw new Error(d.error || '조회 실패'); - content.innerHTML = _evtBuildSummary(d.data); - } catch (e) { - content.textContent = `❌ ${e.message}`; - } -} - -function _evtBadge(t) { - const cls = { TRIP:'trip', RUN:'run', ALARM:'alarm', NORMAL:'normal', CHANGE:'change' }[t] || 'change'; - return `${esc(t)}`; -} - -function _evtFmtTime(dt) { - if (!dt) return '—'; - return new Date(dt).toLocaleString('ko-KR', { - year:'numeric', month:'2-digit', day:'2-digit', - hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false - }); -} - -function _evtBuildTable(rows) { - if (!rows || !rows.length) - return '
데이터 없음
'; - const html = rows.map(r => ` - - ${_evtFmtTime(r.eventTime)} - ${esc(r.tagName)} - ${_evtBadge(r.eventType)} - ${esc(r.prevValue ?? '—')} - ${esc(r.currValue)} - ${r.area ? `${esc(r.area)}` : '—'} - ${r.section ? `${esc(r.section)}` : '—'} - ${r.durationSeconds != null ? r.durationSeconds + 's' : '—'} - `).join(''); - return ` - - - - - - - - - - - - ${html} -
시간태그명이벤트이전값현재값AreaSection지속(초)
`; -} - -function _evtBuildSummary(data) { - if (!data || !data.length) - return '
데이터 없음
'; - return `
${data.map(s => ` -
-
${esc(s.section)}
-
-
${_evtBadge('TRIP')} ${s.tripCount}
-
${_evtBadge('RUN')} ${s.runCount}
-
${_evtBadge('ALARM')} ${s.alarmCount}
-
${_evtBadge('CHANGE')} ${s.changeCount}
-
-
합계 ${s.totalEvents}
-
`).join('')}
`; -} - -function evtReset() { - document.getElementById('ef-tag').value = ''; - document.getElementById('ef-event-type').value = ''; - document.getElementById('ef-area').value = ''; - document.getElementById('ef-section').value = ''; - document.getElementById('ef-limit').value = '500'; - dtClearField('evt-from'); - dtClearField('evt-to'); - document.getElementById('evt-result-info').classList.add('hidden'); - document.getElementById('evt-table').classList.add('hidden'); - document.getElementById('evt-summary-card').classList.add('hidden'); - document.getElementById('evt-tag-status').textContent = ''; -} - -/* ───────────────────────────────────────────────────────────── - 07-2 하이퍼테이블 관리 - ───────────────────────────────────────────────────────────── */ - -/** - * 하이퍼테이블 상태 불러오기 - */ -async function htLoadStatus() { - try { - const d = await api('GET', '/api/experion/hypertable/status'); - - // 상태 표시 창 업데이트 - const box = document.getElementById('ht-status-box'); - const iconEl = document.getElementById('ht-status-icon'); - const textEl = document.getElementById('ht-status-text'); - const detEl = document.getElementById('ht-status-detail'); - - box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err'); - - // 정보 패널 업데이트 - const infoPanel = document.getElementById('ht-info-panel'); - document.getElementById('ht-info-table').textContent = d.tableName || '-'; - document.getElementById('ht-info-records').textContent = d.recordCount != null ? d.recordCount.toLocaleString() : '-'; - document.getElementById('ht-info-retention').textContent = d.hasRetentionPolicy ? '설정됨' : '미설정'; - document.getElementById('ht-info-compression').textContent = d.hasCompression ? '활성화됨' : '비활성화됨'; - - if (d.isHypertable) { - box.classList.add('ok'); - iconEl.textContent = '✅'; - textEl.textContent = '하이퍼테이블 활성화됨'; - detEl.textContent = d.statusMessage || 'history_table이 하이퍼테이블로 변환되었습니다.'; - infoPanel.classList.remove('hidden'); - } else { - box.classList.add('pending'); - iconEl.textContent = '⚠️'; - textEl.textContent = '일반 테이블'; - detEl.textContent = d.statusMessage || 'history_table이 아직 하이퍼테이블로 변환되지 않았습니다.'; - infoPanel.classList.add('hidden'); - } - } catch (e) { - console.error('htLoadStatus 오류:', e); - const box = document.getElementById('ht-status-box'); - const iconEl = document.getElementById('ht-status-icon'); - const textEl = document.getElementById('ht-status-text'); - const detEl = document.getElementById('ht-status-detail'); - - box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err'); - box.classList.add('err'); - iconEl.textContent = '❌'; - textEl.textContent = '상태 조회 실패'; - detEl.textContent = e.message || '알 수 없는 오류'; - document.getElementById('ht-info-panel').classList.add('hidden'); - } -} - -/** - * 하이퍼테이블 생성 - */ -async function htCreate() { - const tableName = document.getElementById('ht-table-name').value || 'history_table'; - const retentionEnabled = document.getElementById('ht-auto-retention').checked; - const compressionEnabled = document.getElementById('ht-auto-compression').checked; - const aggregateEnabled = document.getElementById('ht-auto-aggregate').checked; - - // 생성 전 확인 - const confirmMsg = retentionEnabled || compressionEnabled - ? `다음 옵션으로 하이퍼테이블을 생성합니다:\n\n` + - `- 테이블명: ${tableName}\n` + - `- 보관 기간: ${retentionEnabled ? document.getElementById('ht-retention-period').value : '설정 안함'}\n` + - `- 압축: ${compressionEnabled ? '활성화 (' + document.getElementById('ht-compression-period').value + ')' : '비활성화'}\n` + - `- 연속 집계: ${aggregateEnabled ? '생성' : '생성 안함'}\n\n` + - `계속 진행하시겠습니까?` - : `기본 설정으로 하이퍼테이블을 생성합니다:\n\n` + - `- 테이블명: ${tableName}\n` + - `- 보관 기간: 설정 안함\n` + - `- 압축: 설정 안함\n\n` + - `계속 진행하시겠습니까?`; - - if (!confirm(confirmMsg)) return; - - try { - // 상태 표시 창을 로딩 모드로 - const box = document.getElementById('ht-status-box'); - const iconEl = document.getElementById('ht-status-icon'); - const textEl = document.getElementById('ht-status-text'); - const detEl = document.getElementById('ht-status-detail'); - - box.classList.remove('hidden', 'pending', 'ok', 'err'); - box.classList.add('busy'); - iconEl.textContent = '⏳'; - textEl.textContent = '생성 중...'; - detEl.textContent = '하이퍼테이블을 생성하고 있습니다. 잠시 기다려주세요.'; - document.getElementById('ht-info-panel').classList.add('hidden'); - - const body = { - tableName: tableName, - timeColumn: 'recorded_at', - timeInterval: '1 day', - migrateData: true, - setRetentionPolicy: retentionEnabled, - retentionPeriod: retentionEnabled ? document.getElementById('ht-retention-period').value : undefined, - enableCompression: compressionEnabled, - compressionPeriod: compressionEnabled ? document.getElementById('ht-compression-period').value : undefined, - createContinuousAggregate: aggregateEnabled - }; - - // 불필요한 필드는 제거 - Object.keys(body).forEach(key => { - if (body[key] === undefined) delete body[key]; - }); - - await api('POST', '/api/experion/hypertable/create', body); - - // 상태 표시: 성공 - box.classList.remove('busy'); - box.classList.add('ok'); - iconEl.textContent = '✅'; - textEl.textContent = '생성 완료'; - detEl.textContent = '하이퍼테이블이 성공적으로 생성되었습니다. 상태 새로고침 버튼을 눌러주세요.'; - - // 상태 새로고침 - setTimeout(() => htLoadStatus(), 500); - } catch (e) { - console.error('htCreate 오류:', e); - const box = document.getElementById('ht-status-box'); - const iconEl = document.getElementById('ht-status-icon'); - const textEl = document.getElementById('ht-status-text'); - const detEl = document.getElementById('ht-status-detail'); - - box.classList.remove('busy', 'pending', 'ok', 'err'); - box.classList.add('err'); - iconEl.textContent = '❌'; - textEl.textContent = '생성 실패'; - detEl.textContent = e.message || '알 수 없는 오류'; - } -} - -/** - * 보관 기간 설정 토글 - */ -function htToggleRetention() { - const checked = document.getElementById('ht-auto-retention').checked; - const panel = document.getElementById('ht-retention-panel'); - panel.classList.toggle('ht-hidden', !checked); -} - -/** - * 압축 설정 토글 - */ -function htToggleCompression() { - const checked = document.getElementById('ht-auto-compression').checked; - const panel = document.getElementById('ht-compression-panel'); - panel.classList.toggle('ht-hidden', !checked); -} - -/* ── Custom DateTime Picker ──────────────────────────────────── */ -const _dtp = { target: null, year: 0, month: 0, - selYear: null, selMonth: null, selDay: null, - selHour: 0, selMin: 0 }; - -const _DTP_DAYS = ['일','월','화','수','목','금','토']; - -function dtOpen(target) { - _dtp.target = target; - const hidden = document.getElementById(`hf-${target}`).value; - const d = hidden ? new Date(hidden) : new Date(); - _dtp.year = d.getFullYear(); - _dtp.month = d.getMonth(); - if (hidden && !isNaN(d)) { - _dtp.selYear = d.getFullYear(); _dtp.selMonth = d.getMonth(); - _dtp.selDay = d.getDate(); - _dtp.selHour = d.getHours(); _dtp.selMin = d.getMinutes(); - } else { - _dtp.selYear = null; _dtp.selMonth = null; _dtp.selDay = null; - _dtp.selHour = 0; _dtp.selMin = 0; - } - document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0'); - document.getElementById('dt-min').value = String(_dtp.selMin).padStart(2,'0'); - dtRenderCal(); - - // 팝업 위치 계산 - const popup = document.getElementById('dt-popup'); - const display = document.getElementById(`dtp-${target}-display`); - const rect = display.getBoundingClientRect(); - popup.classList.remove('hidden'); - document.getElementById('dt-overlay').classList.remove('hidden'); - - // 화면 아래 공간 부족하면 위쪽으로 - const spaceBelow = window.innerHeight - rect.bottom; - if (spaceBelow < popup.offsetHeight + 8) { - popup.style.top = (rect.top - popup.offsetHeight - 4) + 'px'; - } else { - popup.style.top = (rect.bottom + 4) + 'px'; - } - popup.style.left = Math.min(rect.left, window.innerWidth - 296) + 'px'; -} - -function dtRenderCal() { - document.getElementById('dt-month-label').textContent = - `${_dtp.year}년 ${_dtp.month + 1}월`; - const first = new Date(_dtp.year, _dtp.month, 1).getDay(); - const daysInMon = new Date(_dtp.year, _dtp.month + 1, 0).getDate(); - const daysInPrev = new Date(_dtp.year, _dtp.month, 0).getDate(); - const today = new Date(); - let html = _DTP_DAYS.map(d => `
${d}
`).join(''); - - for (let i = first - 1; i >= 0; i--) - html += `
${daysInPrev - i}
`; - - for (let d = 1; d <= daysInMon; d++) { - let cls = 'dt-day'; - if (_dtp.year === today.getFullYear() && _dtp.month === today.getMonth() && d === today.getDate()) - cls += ' today'; - if (_dtp.selYear === _dtp.year && _dtp.selMonth === _dtp.month && _dtp.selDay === d) - cls += ' selected'; - html += `
${d}
`; - } - const trailing = (first + daysInMon) % 7; - for (let d = 1; d <= (trailing ? 7 - trailing : 0); d++) - html += `
${d}
`; - - document.getElementById('dt-cal-grid').innerHTML = html; -} - -function dtSelectDay(day) { - _dtp.selYear = _dtp.year; _dtp.selMonth = _dtp.month; _dtp.selDay = day; - dtRenderCal(); -} -function dtPrevMonth() { - if (--_dtp.month < 0) { _dtp.month = 11; _dtp.year--; } - dtRenderCal(); -} -function dtNextMonth() { - if (++_dtp.month > 11) { _dtp.month = 0; _dtp.year++; } - dtRenderCal(); -} -function dtAdjTime(part, delta) { - if (part === 'h') { - _dtp.selHour = ((_dtp.selHour + delta) + 24) % 24; - document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0'); - } else { - _dtp.selMin = ((_dtp.selMin + delta) + 60) % 60; - document.getElementById('dt-min').value = String(_dtp.selMin).padStart(2,'0'); - } -} -function dtClampTime(part, el) { - const max = part === 'h' ? 23 : 59; - let v = parseInt(el.value); - if (isNaN(v) || v < 0) v = 0; - if (v > max) v = max; - el.value = String(v).padStart(2,'0'); - if (part === 'h') _dtp.selHour = v; else _dtp.selMin = v; -} -function dtConfirm() { - if (_dtp.selDay === null) { alert('날짜를 선택하세요.'); return; } - _dtp.selHour = parseInt(document.getElementById('dt-hour').value) || 0; - _dtp.selMin = parseInt(document.getElementById('dt-min').value) || 0; - const p = n => String(n).padStart(2,'0'); - const val = `${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)}T${p(_dtp.selHour)}:${p(_dtp.selMin)}`; - document.getElementById(`hf-${_dtp.target}`).value = val; - document.getElementById(`dtp-${_dtp.target}-display`).textContent = - `${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)} ${p(_dtp.selHour)}:${p(_dtp.selMin)}`; - dtClose(); -} -function dtClear() { dtClearField(_dtp.target); dtClose(); } -function dtClearField(target) { - if (!target) return; - document.getElementById(`hf-${target}`).value = ''; - document.getElementById(`dtp-${target}-display`).textContent = '— 선택 안 함 —'; -} -function dtCancel() { dtClose(); } -function dtClose() { - document.getElementById('dt-popup').classList.add('hidden'); - document.getElementById('dt-overlay').classList.add('hidden'); - _dtp.target = null; -} - -/* ── OPC UA 서버 ─────────────────────────────────────────────── */ - -let _srvPollTimer = null; - -async function srvLoad() { - try { - const s = await api('GET', '/api/opcserver/status'); - _srvRender(s); - } catch (e) { - srvLog([{ c:'err', t:`상태 조회 실패: ${e.message}` }]); - } -} - -async function srvStart() { - srvLog([{ c:'info', t:'OPC UA 서버 시작 중...' }]); - try { - const r = await api('POST', '/api/opcserver/start'); - srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]); - await srvLoad(); - _srvStartPoll(); - } catch (e) { - srvLog([{ c:'err', t:`시작 실패: ${e.message}` }]); - } -} - -async function srvStop() { - srvLog([{ c:'info', t:'OPC UA 서버 중지 중...' }]); - try { - const r = await api('POST', '/api/opcserver/stop'); - srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]); - _srvStopPoll(); - await srvLoad(); - } catch (e) { - srvLog([{ c:'err', t:`중지 실패: ${e.message}` }]); - } -} - -async function srvRebuild() { - srvLog([{ c:'info', t:'주소 공간 재구성 중...' }]); - try { - const r = await api('POST', '/api/opcserver/rebuild'); - srvLog([{ c: r.success ? 'ok' : 'err', t: `재구성 완료 — ${r.nodeCount}개 노드` }]); - await srvLoad(); - } catch (e) { - srvLog([{ c:'err', t:`재구성 실패: ${e.message}` }]); - } -} - -function _srvRender(s) { - const dot = document.getElementById('srv-dot'); - const txt = document.getElementById('srv-status-txt'); - const meta = document.getElementById('srv-meta'); - const ndot = document.getElementById('opcsvr-dot'); - - if (s.running) { - dot.className = 'dot grn'; - txt.textContent = '● 실행 중'; - if (ndot) ndot.className = 'nb grn'; - } else { - dot.className = 'dot'; - txt.textContent = '○ 중지됨'; - if (ndot) ndot.className = 'nb'; - } - - const started = s.startedAt ? new Date(s.startedAt).toLocaleString('ko-KR') : '—'; - meta.innerHTML = - `엔드포인트: ${esc(s.endpointUrl || '—')}` + - `접속 클라이언트: ${s.connectedClientCount}` + - `노드 수: ${s.nodeCount}` + - `시작 시각: ${started}`; -} - -function srvLog(lines) { - log('srv-log', lines); -} - -function _srvStartPoll() { - _srvStopPoll(); - _srvPollTimer = setInterval(srvLoad, 5000); -} - -function _srvStopPoll() { - if (_srvPollTimer) { clearInterval(_srvPollTimer); _srvPollTimer = null; } -} - -/* ── Text-to-SQL Dashboard ───────────────────────────────────── */ - -// MCP 모드 설정 (legacy | mcp) -let t2sMode = 'mcp'; - -/** - * t2sInitMode - 현재 t2sMode에 맞게 UI 초기화 (탭 진입 시 호출) - */ -function t2sInitMode() { - const parseBtn = document.getElementById('t2s-parse-btn'); - const executeBtn = document.getElementById('t2s-execute-btn'); - const analyzeBtn = document.getElementById('t2s-analyze-btn'); - const logBox = document.getElementById('t2s-log'); - if (t2sMode === 'mcp') { - if (parseBtn) parseBtn.classList.add('hidden'); - if (analyzeBtn) analyzeBtn.classList.add('hidden'); - if (logBox) logBox.classList.add('hidden'); - } else { - if (parseBtn) parseBtn.classList.remove('hidden'); - if (executeBtn) executeBtn.classList.remove('hidden'); - if (analyzeBtn) analyzeBtn.classList.remove('hidden'); - if (logBox) logBox.classList.remove('hidden'); - } -} - -/** - * toggleMcpMode - MCP 모드 토글 - */ -function toggleMcpMode() { - t2sMode = t2sMode === 'legacy' ? 'mcp' : 'legacy'; - - // 버튼 표시/숨김 처리 - const parseBtn = document.getElementById('t2s-parse-btn'); - const executeBtn = document.getElementById('t2s-execute-btn'); - const analyzeBtn = document.getElementById('t2s-analyze-btn'); - const chatContainer = document.getElementById('t2s-chat-container'); - const logBox = document.getElementById('t2s-log'); - - if (t2sMode === 'mcp') { - // MCP 모드: 변환(Parse) · 분석 버튼 숨김, 실행 버튼은 유지 - if (parseBtn) parseBtn.classList.add('hidden'); - if (analyzeBtn) analyzeBtn.classList.add('hidden'); - if (logBox) logBox.classList.add('hidden'); - setGlobal('ok', 'MCP 모드'); - } else { - // Legacy 모드: 모든 기능 표시 - if (parseBtn) parseBtn.classList.remove('hidden'); - if (executeBtn) executeBtn.classList.remove('hidden'); - if (analyzeBtn) analyzeBtn.classList.remove('hidden'); - if (logBox) logBox.classList.remove('hidden'); - setGlobal('ok', 'Legacy 모드'); - } -} - -/** - * t2sParse - 자연어 쿼리를 SQL로 변환 - * MCP 모드: LLM이 SQL 생성 + 즉시 실행 - * Legacy 모드: C# 파서로 SQL 생성 - */ -async function t2sParse() { - const input = document.getElementById('t2s-query').value.trim(); - if (!input) { - alert('자연어 쿼리를 입력해주세요.'); - return; - } - - const sqlTextarea = document.getElementById('t2s-sql'); - const resultContainer = document.getElementById('t2s-results'); - sqlTextarea.disabled = true; - - if (t2sMode === 'mcp') { - sqlTextarea.value = 'LLM이 SQL 생성 중...'; - resultContainer.innerHTML = '
MCP 조회 중...
'; - try { - const res = await api('POST', '/api/text-to-sql/query-nl', { query: input }); - console.log('[t2sParse] MCP 응답:', res); - if (res.success) { - const d = (typeof res.data === 'object' && res.data !== null) ? res.data : {}; - console.log('[t2sParse] 데이터 파싱:', d, 'type:', typeof d); - console.log('[t2sParse] d.data:', d.data, 'd.columns:', d.columns, 'd.count:', d.count); - - // d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인 - const dataObj = d.data || (Array.isArray(d) ? d : null); - console.log('[t2sParse] dataObj:', dataObj); - - if (d.success === false) { - sqlTextarea.value = `오류: ${d.error || 'SQL 생성 실패'}`; - resultContainer.innerHTML = `
MCP 오류: ${esc(d.error || 'SQL 생성 실패')}
`; - } else { - sqlTextarea.value = d.sql || '(SQL 없음)'; - // d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인 - const rows = (dataObj && Array.isArray(dataObj.rows)) ? dataObj.rows : (Array.isArray(dataObj) ? dataObj : []); - const columns = (dataObj && Array.isArray(dataObj.columns)) ? dataObj.columns : []; - const totalCount = (dataObj && typeof dataObj.count === 'number') ? dataObj.count : (Array.isArray(dataObj) ? dataObj.length : 0); - console.log('[t2sParse] 최종 rows.length:', rows.length, 'columns:', columns); - - if (rows.length === 0) { - resultContainer.innerHTML = '
조회 결과가 없습니다. (SQL은 생성됨)
'; - } else { - t2sRenderTable({ rows, columns, totalCount }); - } - } - } else { - sqlTextarea.value = `오류: ${res.error || '알 수 없는 오류'}`; - resultContainer.innerHTML = ''; - } - } catch (err) { - sqlTextarea.value = `연결 오류: ${err.message}`; - resultContainer.innerHTML = ''; - } - } else { - sqlTextarea.value = '변환 중...'; - try { - const res = await api('POST', '/api/text-to-sql/parse', { query: input }); - if (res.success) { - sqlTextarea.value = res.sql || 'SQL 생성 실패'; - } else { - sqlTextarea.value = `오류: ${res.error || '알 수 없는 오류'}`; - } - } catch (err) { - sqlTextarea.value = `연결 오류: ${err.message}`; - } - } - - sqlTextarea.disabled = false; -} - -/** - * t2sPivot - tagname 컬럼이 있으면 wide format으로 변환 (query_with_nl 서버 로직과 동일) - */ -function t2sPivot(columns, rows) { - console.log('[t2sPivot] 입력: columns:', columns, 'rows.length:', rows.length); - if (!columns.includes('tagname') || !rows.length) return { columns, rows }; - const timeCol = columns.find(c => !['tagname', 'value', 'livevalue', 'avg_val'].includes(c)); - const valCol = ['value', 'avg_val'].find(c => columns.includes(c)) || columns[columns.length - 1]; - if (!timeCol) return { columns, rows }; - const tagNames = [...new Set(rows.map(r => r['tagname']))].sort(); - const pivoted = {}; - for (const row of rows) { - const key = String(row[timeCol]); - if (!pivoted[key]) pivoted[key] = { [timeCol]: row[timeCol] }; - pivoted[key][row['tagname']] = row[valCol]; - } - const result = { columns: [timeCol, ...tagNames], rows: Object.values(pivoted) }; - console.log('[t2sPivot] 결과: columns:', result.columns, 'rows.length:', result.rows.length); - return result; -} - -/** - * t2sExecute - SQL 실행 - * MCP 모드에서는 /api/text-to-sql/execute-mcp 엔드포인트를 사용하며 pivot 적용 - */ -async function t2sExecute() { - const sql = document.getElementById('t2s-sql').value.trim(); - if (!sql) { - alert('SQL을 입력해주세요.'); - return; - } - - const limitInput = document.getElementById('t2s-limit'); - const limit = limitInput.value ? parseInt(limitInput.value) : 1000; - - const resultContainer = document.getElementById('t2s-results'); - resultContainer.innerHTML = '
실행 중...
'; - - try { - if (t2sMode === 'mcp') { - const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql, limit }); - console.log('[t2sExecute] MCP execute 응답:', res); - if (res.success) { - const d = res.data || {}; - console.log('[t2sExecute] d.data:', d.data, 'd.columns:', d.columns); - const { columns, rows } = t2sPivot(d.columns || [], d.data || []); - console.log('[t2sExecute] pivot 후 rows.length:', rows.length); - t2sRenderTable({ rows, columns, totalCount: rows.length }); - } else { - resultContainer.innerHTML = `
오류: ${res.error || '알 수 없는 오류'}
`; - } - } else { - const res = await api('POST', '/api/text-to-sql/execute', { sql, limit }); - if (res.success) { - t2sRenderTable(res); - } else { - resultContainer.innerHTML = `
오류: ${res.error || '알 수 없는 오류'}
`; - } - } - } catch (err) { - resultContainer.innerHTML = `
연결 오류: ${err.message}
`; - } -} - -/** - * t2sRenderTable - 쿼리 결과를 테이블로 렌더링 - */ -function t2sRenderTable(result) { - const container = document.getElementById('t2s-results'); - - console.log('[t2sRenderTable] 입력 결과:', result); - - // 백엔드 응답: columns, rows, totalCount (소문자) - const rows = result.rows || []; - const columns = result.columns || []; - const totalCount = result.totalCount || 0; - - console.log('[t2sRenderTable] rows.length:', rows.length, 'columns:', columns); - - // ── 추가: 결과 저장 (export용) ── - _t2sLastResult = rows.length > 0 ? { columns, rows } : null; - - if (!rows || rows.length === 0) { - console.log('[t2sRenderTable] 빈 결과 - 결과 없음 표시'); - container.innerHTML = '
결과가 없습니다.
'; - return; - } - - // 컬럼이 비어있으면 첫 행에서 추출 - const colNames = columns.length > 0 ? columns : Object.keys(rows[0]); - - let html = '
' + totalCount + '개 결과
'; - html += ''; - - // Header - html += ''; - colNames.forEach(col => { - html += ``; - }); - html += ''; - - // Body - html += ''; - rows.forEach(row => { - html += ''; - colNames.forEach(col => { - const val = row[col]; - const isTimeCol = /recorded_at|timestamp|bucket|time/i.test(col); - const display = val !== null && val !== undefined - ? esc(String(isTimeCol ? fmtTs(val) : fmtVal(val))) - : 'NULL'; - html += ``; - - }); - html += ''; - }); - html += ''; - - html += '
${esc(col)}
${display}
'; - container.innerHTML = html; -} - -/** - * t2sExportExcel — 마지막 쿼리 결과를 .xlsx로 다운로드 - */ -function t2sExportExcel() { - if (!_t2sLastResult) return; - - if (typeof XLSX === 'undefined') { - alert('Excel 라이브러리 로드 실패'); - return; - } - - const { columns, rows } = _t2sLastResult; - - // 1. 헤더 행 + 데이터 행 배열 구성 - const sheetData = [ - columns, // 첫 행 = 컬럼 헤더 - ...rows.map(row => columns.map(col => { - const v = row[col]; - if (v == null) return ''; - const isTimeCol = /recorded_at|timestamp|bucket|time/i.test(col); - if (isTimeCol) return fmtTs(v); - const n = Number(v); - return Number.isFinite(n) ? n : String(v); - })) - ]; - - // 2. 워크시트 생성 - const ws = XLSX.utils.aoa_to_sheet(sheetData); - - // 3. 컬럼 너비 자동 조정 (최대 30자) - ws['!cols'] = columns.map((col, i) => { - const maxLen = Math.max( - col.length, - ...rows.map(r => String(r[col] ?? '').length) - ); - return { wch: Math.min(maxLen + 2, 30) }; - }); - - // 4. 워크북 생성 및 다운로드 - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, 'QueryResult'); - - const now = new Date(); - const ts = now.toISOString().replace(/[:.]/g, '-').substring(0, 19); - XLSX.writeFile(wb, `query_result_${ts}.xlsx`); -} - -/** - * t2sAnalyze - 태그 분석 (시계열 분석) (Legacy 모드용) - */ -async function t2sAnalyze() { - const tagNames = document.getElementById('t2s-tags').value.trim(); - if (!tagNames) { - alert('분석할 태그 이름을 입력해주세요.'); - return; - } - - const interval = document.getElementById('t2s-interval').value; - const limit = document.getElementById('t2s-limit-analyze').value || '100'; - - const dateFrom = document.getElementById('t2s-date-from').value; - const dateTo = document.getElementById('t2s-date-to').value; - - const resultContainer = document.getElementById('t2s-analysis-results'); - resultContainer.innerHTML = '
분석 중...
'; - - try { - const res = await api('POST', '/api/text-to-sql/analyze', { - tagNames: tagNames.split(',').map(t => t.trim()).filter(t => t), - interval: interval, - from: dateFrom || undefined, - to: dateTo || undefined - }); - - if (res.success) { - t2sRenderAnalysis(res); - } else { - resultContainer.innerHTML = `
오류: ${res.error || '알 수 없는 오류'}
`; - } - } catch (err) { - resultContainer.innerHTML = `
연결 오류: ${err.message}
`; - } -} - -/** - * t2sRenderAnalysis - 분석 결과를 렌더링 - */ -function t2sRenderAnalysis(result) { - const container = document.getElementById('t2s-analysis-results'); - - if (!result.tags || result.tags.length === 0) { - container.innerHTML = '
분석 결과가 없습니다.
'; - return; - } - - let html = '
' + result.tags.length + '개 태그 분석
'; - html += '
'; - - result.tags.forEach(tag => { - html += '
'; - html += `

${esc(tag.tagName.toUpperCase())}

`; - html += '
'; - html += `
평균:${tag.mean?.toFixed(2) || 'N/A'}
`; - html += `
최대:${tag.max?.toFixed(2) || 'N/A'}
`; - html += `
최소:${tag.min?.toFixed(2) || 'N/A'}
`; - html += `
표준편차:${tag.stdDev?.toFixed(2) || 'N/A'}
`; - html += '
'; - html += '
'; - }); - - html += '
'; - container.innerHTML = html; -} - -/** - * t2sSetQuery - 제안 쿼리 설정 - */ -function t2sSetQuery(query) { - document.getElementById('t2s-query').value = query; -} - -/* ── 채팅 기능 ────────────────────────────────────────────────── */ - -/** - * t2sChatSend - 채팅 메시지 전송 - */ -async function t2sChatSend() { - const input = document.getElementById('t2s-chat-input'); - const message = input.value.trim(); - if (!message) return; - - // 입력 필드 비활성화 - input.disabled = true; - document.getElementById('t2s-chat-send-btn').disabled = true; - - // 사용자 메시지 추가 - t2sAddChatMessage('user', message); - input.value = ''; - - try { - if (t2sMode === 'mcp') { - // MCP 모드: 자연어 → LLM SQL 생성 → 실행 - t2sAddChatMessage('system', 'LLM이 SQL 생성 중...'); - - const executeRes = await api('POST', '/api/text-to-sql/query-nl', { query: message }); - - // 로딩 메시지 제거 - const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]'); - loadMsgs.forEach(el => el.remove()); - - if (!executeRes.success) { - t2sAddChatMessage('system', `실패: ${executeRes.error || '알 수 없는 오류'}`); - } else { - const d = executeRes.data || {}; - if (d.sql) { - document.getElementById('t2s-sql').value = d.sql; - t2sAddChatMessage('system', `✅ SQL 생성:
${esc(d.sql)}
`); - } - if (d.success === false) { - t2sAddChatMessage('system', `실행 오류: ${d.error || '알 수 없는 오류'}`); - } else { - t2sRenderTable({ rows: d.data || [], columns: d.columns || [], totalCount: d.count || 0 }); - t2sAddChatMessage('system', `✅ ${d.count || 0}개 결과 조회 완료`); - } - } - } else { - // Legacy 모드: 2-hop 구조 (Parse → Execute) - const loadingId = 't2s-chat-loading-' + Date.now(); - t2sAddChatMessage('system', '변환 중...', loadingId); - - const parseRes = await api('POST', '/api/text-to-sql/parse', { query: message }); - - // 로딩 메시지 제거 - const loadingEl = document.getElementById(loadingId); - if (loadingEl) loadingEl.remove(); - - if (!parseRes.success || !parseRes.sql) { - t2sAddChatMessage('system', `SQL 변환 실패: ${parseRes.error || '알 수 없는 오류'}`); - input.disabled = false; - document.getElementById('t2s-chat-send-btn').disabled = false; - input.focus(); - return; - } - - // SQL 텍스트박스에도 반영 - document.getElementById('t2s-sql').value = parseRes.sql; - - // 시스템 메시지: 변환된 SQL 표시 - t2sAddChatMessage('system', `✅ SQL 변환 완료:
${esc(parseRes.sql)}
`); - - // 2. SQL 자동 실행 - t2sAddChatMessage('system', '쿼리 실행 중...'); - - const limitInput = document.getElementById('t2s-limit'); - const limit = limitInput.value ? parseInt(limitInput.value) : 1000; - const executeRes = await api('POST', '/api/text-to-sql/execute', { sql: parseRes.sql, limit }); - - // 로딩 메시지 제거 - const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]'); - loadMsgs.forEach(el => el.remove()); - - if (!executeRes.success) { - t2sAddChatMessage('system', `쿼리 실행 실패: ${executeRes.error || '알 수 없는 오류'}`); - } else { - // 결과 테이블 업데이트 - t2sRenderTable(executeRes); - - // 결과 수 표시 - const totalCount = executeRes.totalCount || 0; - t2sAddChatMessage('system', `✅ ${totalCount}개 결과 조회 완료`); - } - } - } catch (err) { - // 로딩 메시지 제거 - const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]'); - loadMsgs.forEach(el => el.remove()); - - t2sAddChatMessage('system', `연결 오류: ${err.message}`); - } finally { - // 입력 필드 활성화 - input.disabled = false; - document.getElementById('t2s-chat-send-btn').disabled = false; - input.focus(); - } -} - -/** - * t2sAddChatMessage - 채팅 메시지 추가 - */ -function t2sAddChatMessage(type, content, id) { - const container = document.getElementById('t2s-chat-messages'); - const msgId = id || 't2s-msg-' + Date.now(); - - const div = document.createElement('div'); - div.className = `t2s-chat-msg ${type}`; - div.id = msgId; - - const bubble = document.createElement('div'); - bubble.className = 't2s-chat-bubble'; - bubble.innerHTML = content; - - div.appendChild(bubble); - container.appendChild(div); - - // 스크롤 맨 아래로 - const chatContainer = document.querySelector('.t2s-chat-container'); - chatContainer.scrollTop = chatContainer.scrollHeight; - - return msgId; -} - -/* ─── Text-to-SQL MCP 도구 관련 함수 ───────────────────────── */ - -/** - * loadMcpTools - MCP 도구 목록 로드 - */ -async function loadMcpTools() { - try { - const res = await api('GET', '/api/text-to-sql/tools'); - if (res.success && res.tools && res.tools.length > 0) { - renderToolsChips(res.tools); - return res.tools; - } - } catch (e) { - console.error('MCP 도구 로드 실패:', e); - } - return []; -} - -/** - * renderToolsChips - 도구 목록을 추천 쿼리 칩 형태로 렌더링 - */ -function renderToolsChips(tools) { - const container = document.getElementById('t2s-tools-container'); - if (!container) return; - - const chipColors = ['bg-a', 'bg-b', 'bg-c', 'bg-d']; - container.innerHTML = tools.map((tool, idx) => { - const colorClass = chipColors[idx % chipColors.length]; - return ``; - }).join(''); -} - -/** - * callTool - MCP 도구 직접 호출 (MCP 모드) - */ -async function callTool(toolName) { - const input = document.getElementById('t2s-query').value.trim(); - if (!input) { - alert(toolName === 'query_pv_history' ? '태그 이름을 입력해주세요.' : '쿼리를 입력해주세요.'); - return; - } - - const resultContainer = document.getElementById('t2s-results'); - resultContainer.innerHTML = '
도구 호출 중...
'; - - try { - const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql: input }); - if (res.success) { - t2sRenderTable(res); - } else { - resultContainer.innerHTML = `
오류: ${res.error || '알 수 없는 오류'}
`; - } - } catch (err) { - resultContainer.innerHTML = `
연결 오류: ${err.message}
`; - } -} - -/* ── API 대화 기능 (새 페이지) ────────────────────────────────── */ - -/** - * apiChatSend - API 대화 메시지 전송 - */ -async function apiChatSend() { - const input = document.getElementById('api-chat-input'); - const message = input.value.trim(); - if (!message) return; - - // 입력 필드 비활성화 - input.disabled = true; - document.getElementById('api-chat-send-btn').disabled = true; - - // 사용자 메시지 추가 - apiAddChatMessage('user', message); - input.value = ''; - - // 로딩 메시지 추가 - const loadingId = 'api-chat-loading-' + Date.now(); - apiAddChatMessage('system', '처리 중...', loadingId); - - try { - // 1. 자연어 쿼리를 SQL로 변환 - const parseRes = await api('POST', '/api/text-to-sql/parse', { query: message }); - - // 로딩 메시지 제거 - const loadingEl = document.getElementById(loadingId); - if (loadingEl) loadingEl.remove(); - - if (!parseRes.success || !parseRes.sql) { - apiAddChatMessage('system', `SQL 변환 실패: ${parseRes.error || '알 수 없는 오류'}`); - input.disabled = false; - document.getElementById('api-chat-send-btn').disabled = false; - input.focus(); - return; - } - - // SQL 표시 - apiAddChatMessage('assistant', `📝 변환된 SQL:
${esc(parseRes.sql)}
`); - - // 2. SQL 자동 실행 - apiAddChatMessage('assistant', '쿼리 실행 중...'); - - const executeRes = await api('POST', '/api/text-to-sql/execute', { sql: parseRes.sql, limit: 1000 }); - - // 로딩 메시지 제거 - const loadMsgs = document.querySelectorAll('[id^="api-chat-loading-"]'); - loadMsgs.forEach(el => el.remove()); - - if (!executeRes.success) { - apiAddChatMessage('system', `쿼리 실행 실패: ${executeRes.error || '알 수 없는 오류'}`); - } else { - // 결과를 응답 창에 표시 - apiRenderResponse(executeRes); - - // 결과 수 표시 - const totalCount = executeRes.totalCount || 0; - apiAddChatMessage('assistant', `✅ ${totalCount}개 결과 조회 완료`); - } - } catch (err) { - // 로딩 메시지 제거 - const loadMsgs = document.querySelectorAll('[id^="api-chat-loading-"]'); - loadMsgs.forEach(el => el.remove()); - - apiAddChatMessage('system', `연결 오류: ${err.message}`); - } finally { - // 입력 필드 활성화 - input.disabled = false; - document.getElementById('api-chat-send-btn').disabled = false; - input.focus(); - } -} - -/** - * apiAddChatMessage - API 채팅 메시지 추가 - */ -function apiAddChatMessage(type, content, id) { - const container = document.getElementById('api-chat-messages'); - const msgId = id || 'api-msg-' + Date.now(); - - const div = document.createElement('div'); - div.className = `api-chat-msg ${type}`; - div.id = msgId; - - const bubble = document.createElement('div'); - bubble.className = 'api-chat-bubble'; - bubble.innerHTML = content; - - div.appendChild(bubble); - container.appendChild(div); - - // 스크롤 맨 아래로 - const chatContainer = document.querySelector('.api-chat-messages'); - chatContainer.scrollTop = chatContainer.scrollHeight; - - return msgId; -} - -/** - * t2sChatClear - 채팅 초기화 - */ -function t2sChatClear() { - const container = document.getElementById('t2s-chat-messages'); - container.innerHTML = ` -
-
- 시스템:
- 자연어 질의를 입력하면 SQL로 변환하고 결과를 조회합니다.
- 예: "FICQ-6101.PV 최근 1시간 평균", "최대값 조회", "추세 분석" -
-
- `; -} - -/* ─── Text-to-SQL MCP 도구 관련 함수 ───────────────────────── */ - -/** - * apiChatClear - API 채팅 초기화 - */ -function apiChatClear() { - const container = document.getElementById('api-chat-messages'); - container.innerHTML = ` -
-
- 시스템:
- API와 대화할 수 있습니다. 자연어로 질의를 입력하면 SQL로 변환하고 결과를 조회합니다.
- 예: "FICQ-6101.PV 최근 1시간 평균", "최대값 조회", "추세 분석" -
-
- `; - - // 응답 창도 초기화 - document.getElementById('api-response-content').innerHTML = '응답이 여기에 표시됩니다'; -} - -/** - * apiRenderResponse - API 응답을 응답 창에 표시 - */ -function apiRenderResponse(data) { - const container = document.getElementById('api-response-content'); - - if (!data.rows || data.rows.length === 0) { - container.innerHTML = '조회된 결과가 없습니다'; - return; - } - - // 테이블 생성 - let html = ''; - - // 헤더 생성 - const columns = Object.keys(data.rows[0]); - columns.forEach(col => { - html += ``; - }); - html += ''; - - // 데이터 행 생성 - data.rows.forEach(row => { - html += ''; - columns.forEach(col => { - const value = row[col]; - html += ``; - }); - html += ''; - }); - - html += '
${esc(col)}
${value !== null && value !== undefined ? esc(String(value)) : ''}
'; - html += `

총 ${data.totalCount || 0}개 결과

`; - - container.innerHTML = html; -} - -/* ── 초기 실행 ───────────────────────────────────────────────── */ -certStatus(); - -/** - * 타임스탬프 문자열을 "YYYY-MM-DD HH:MM:SS.f" 형식으로 변환 (소수점 1자리, 시간대 제거). - * ISO 8601 문자열 또는 Date 객체 모두 허용. - */ -function fmtTs(v) { - if (v == null) return ''; - // UTC → KST (+9h) 변환 후 표시 - const iso = String(v).replace(' ', 'T').replace(/(\+00:00|Z)$/, '') + 'Z'; - const d = new Date(iso); - if (isNaN(d.getTime())) return String(v); - const kst = new Date(d.getTime() + 9 * 3600 * 1000); - const p = n => String(n).padStart(2, '0'); - const ms = kst.getUTCMilliseconds(); - const frac = '.' + String(ms).padStart(3, '0')[0]; // 소수점 1자리 - return `${kst.getUTCFullYear()}-${p(kst.getUTCMonth()+1)}-${p(kst.getUTCDate())} ` + - `${p(kst.getUTCHours())}:${p(kst.getUTCMinutes())}:${p(kst.getUTCSeconds())}${frac}`; -} - -/** - * 값이 유한 실수이면 소수점 2자리로 반환, 그 외(정수·문자열·null)는 그대로. - */ -function fmtVal(v) { - if (v == null) return v; - const n = Number(v); - if (!Number.isFinite(n)) return v; - if (Number.isInteger(n)) return v; // 정수는 그대로 - return n.toFixed(2); -} - -/** - * OPC UA EnumValueType pv 값 파싱 - * "{0 | L-STpv | }" → "L-STpv" - * "{0 | MID | }" → "MID" - * 일반 값은 그대로 반환 - */ -function parseEnumPv(v) { - if (v == null) return v; - const s = String(v); - const m = s.match(/^\{\s*\d+\s*\|\s*([^|]+?)\s*\|\s*\}$/); - return m ? m[1].trim() : s; -} - -// ═══════════════════════════════════════════════════════════════ -// fastRecord — 변수 / 모달 헬퍼 -// ═══════════════════════════════════════════════════════════════ -let fastCurrentSessionId = null; -let fastChart = null; -let fastLivePollTimer = null; -let fastChartTagNames = null; // 현재 차트에 그려진 태그 목록 (재생성 필요 여부 판단용) -let fastDbConnected = false; // DB 연결 상태 - -function fastModalClose() { - document.getElementById('modal-fast-new').style.display = 'none'; -} - -// ═══════════════════════════════════════════════════════════════ -// fastRecord — API 함수 -// ═══════════════════════════════════════════════════════════════ - -/** DB 연결 테스트 후 세션 목록 로드 */ -async function fastDbConnect() { - const statusEl = document.getElementById('fast-db-status'); - const btnEl = document.getElementById('btn-fast-db-connect'); - statusEl.textContent = 'DB 접속 중...'; - statusEl.style.color = 'var(--t2)'; - btnEl.disabled = true; - - try { - const res = await fetch('/api/fast/sessions'); - if (!res.ok) throw new Error('DB 응답 없음'); - const data = await res.json(); - - fastDbConnected = true; - statusEl.textContent = 'DB 연결됨'; - statusEl.style.color = '#4caf50'; - btnEl.style.display = 'none'; - - await fastSessionsLoad(); - } catch (e) { - console.error('[fast] DB 접속 실패:', e); - statusEl.textContent = 'DB 미연결'; - statusEl.style.color = 'var(--red,#e55)'; - btnEl.disabled = false; - alert('DB 연결에 실패했습니다. 다시 시도해주세요.'); - } -} - -async function fastSessionsLoad() { - const res = await fetch('/api/fast/sessions'); - if (!res.ok) { - // DB 연결 안 된 상태 — 세션 목록 비우기 - const list = document.getElementById('fast-session-list'); - list.innerHTML = 'DB 연결이 필요합니다. "DB 접속" 버튼을 눌러주세요.'; - return; - } - const data = await res.json(); - - fastDbConnected = true; - const statusEl = document.getElementById('fast-db-status'); - if (statusEl) { - statusEl.textContent = 'DB 연결됨'; - statusEl.style.color = '#4caf50'; - } - const btnEl = document.getElementById('btn-fast-db-connect'); - if (btnEl) btnEl.style.display = 'none'; - - const list = document.getElementById('fast-session-list'); - list.innerHTML = ''; - - if (!data.items || data.items.length === 0) { - list.innerHTML = '세션이 없습니다. + 신규를 눌러 시작하세요.'; - return; - } - - const statusColor = { - Running: '#4caf50', Completed: '#4363d8', - Cancelled: '#888', Failed: '#e55', - RowLimitReached: '#f58231', Pending: '#aaa' - }; - const statusLabel = { - Running: '실행중', Completed: '완료', Cancelled: '취소', - Failed: '실패', RowLimitReached: '행제한', Pending: '대기' - }; - - data.items.forEach(s => { - const isActive = s.id === fastCurrentSessionId; - const dot = statusColor[s.status] ?? '#aaa'; - const label = statusLabel[s.status] ?? s.status; - - const chip = document.createElement('div'); - chip.dataset.id = s.id; - chip.style.cssText = [ - 'display:flex;flex-direction:column;gap:3px', - 'padding:7px 11px;border-radius:var(--r)', - `background:${isActive ? 'rgba(229,85,85,.12)' : 'var(--s3)'}`, - `border:1px solid ${isActive ? 'var(--red,#e55)' : 'var(--bd)'}`, - 'cursor:pointer;min-width:120px;max-width:200px', - 'transition:border-color .15s,background .15s' - ].join(';'); - - chip.innerHTML = ` -
- - ${esc(s.name)} - ${s.pinned ? '📌' : ''} - -
-
${label} · ${s.tagCount}태그 · ${s.samplingMs}ms
-
${fastFormatDuration(s.durationSec)} · ${fastFormatDateTime(s.startedAt).slice(0,10)}
- `; - chip.querySelector('[data-del]').addEventListener('click', e => { - e.stopPropagation(); - fastDelete(s.id); - }); - chip.onclick = () => fastSelect(s.id); - list.appendChild(chip); - }); -} - -async function fastStart() { - const name = document.getElementById('fast-session-name').value.trim(); - if (!name) { alert('세션 이름을 입력하세요.'); return; } - - const select = document.getElementById('fast-tag-select'); - const tags = Array.from(select.selectedOptions).map(o => o.value); - if (tags.length === 0) { alert('태그를 최소 1개 이상 선택하세요.'); return; } - if (tags.length > 8) { alert('태그는 최대 8개까지 선택 가능합니다.'); return; } - - const samplingMs = parseInt(document.getElementById('fast-sampling-ms').value); - const durationSec = parseInt(document.getElementById('fast-duration-sec').value); - const retVal = document.getElementById('fast-retention-days').value.trim(); - const retentionDays = retVal ? parseInt(retVal) : null; - - const res = await fetch('/api/fast/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, samplingMs, durationSec, tagList: tags, retentionDays }) - }); - - if (!res.ok) { - const err = await res.json(); - alert('오류: ' + (err.error ?? '알 수 없는 오류')); - return; - } - - const data = await res.json(); - fastModalClose(); - await fastSessionsLoad(); - fastSelect(data.id); -} - -async function fastStop(id) { - if (!confirm('세션을 중지하시겠습니까?')) return; - const res = await fetch(`/api/fast/${id}/stop`, { method: 'POST' }); - if (!res.ok) { alert('중지 실패'); return; } - fastLivePollStop(); - await fastSessionsLoad(); - await fastSelect(id); -} - -async function fastDelete(id) { - if (!confirm('세션과 수집 데이터를 삭제하시겠습니까?')) return; - const res = await fetch(`/api/fast/${id}`, { method: 'DELETE' }); - if (!res.ok) { alert('삭제 실패'); return; } - fastLivePollStop(); - fastCurrentSessionId = null; - fastClearChart(); - document.getElementById('fast-session-title').textContent = '세션 상세'; - ['btn-fast-stop','btn-fast-export-xlsx','btn-fast-export-csv','btn-fast-delete','btn-fast-pin'] - .forEach(btnId => document.getElementById(btnId).style.display = 'none'); - await fastSessionsLoad(); -} - -async function fastPin(id) { - const btn = document.getElementById('btn-fast-pin'); - const pinned = btn.textContent.trim() === '고정'; - const res = await fetch(`/api/fast/${id}/pin`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pinned }) - }); - if (!res.ok) { alert('고정 변경 실패'); return; } - await fastSessionsLoad(); - await fastSelect(id); -} - -async function fastSelect(id) { - fastCurrentSessionId = id; - - // 칩 active 스타일 즉시 갱신 - document.querySelectorAll('#fast-session-list > div').forEach(chip => { - const isActive = parseInt(chip.dataset.id) === id; - chip.style.background = isActive ? 'rgba(229,85,85,.12)' : 'var(--s3)'; - chip.style.borderColor = isActive ? 'var(--red,#e55)' : 'var(--bd)'; - }); - - const res = await fetch(`/api/fast/${id}`); - if (!res.ok) { alert('세션 조회 실패'); return; } - const session = await res.json(); - - document.getElementById('fast-session-title').textContent = `${session.name} (${session.status})`; - - const isRunning = session.status === 'Running'; - const isFinished = !isRunning; - - document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline-block' : 'none'; - document.getElementById('btn-fast-export-xlsx').style.display = isFinished ? 'inline-block' : 'none'; - document.getElementById('btn-fast-export-csv').style.display = isFinished ? 'inline-block' : 'none'; - document.getElementById('btn-fast-delete').style.display = 'inline-block'; - document.getElementById('btn-fast-pin').style.display = 'inline-block'; - document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정'; - - await fastRenderChart(); - await fastUpdateProgress(session); - - if (isRunning) fastLivePollStart(); - else fastLivePollStop(); -} - -// ═══════════════════════════════════════════════════════════════ -// fastRecord — 차트 -// ═══════════════════════════════════════════════════════════════ - -async function fastRenderChart() { - if (!fastCurrentSessionId) return; - - const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`); - if (!res.ok) return; - const data = await res.json(); - - const container = document.getElementById('fast-chart-container'); - - if (!data.items || data.items.length === 0) { - container.innerHTML = '
수집된 데이터가 없습니다.
'; - return; - } - - // Long 포맷 → PIVOT (recorded_at 기준 그룹화) - const grouped = {}; - for (const r of data.items) { - if (!grouped[r.recordedAt]) grouped[r.recordedAt] = {}; - grouped[r.recordedAt][r.tagName] = parseFloat(r.value) || null; - } - - const times = Object.keys(grouped).sort(); - const timesNum = times.map(t => new Date(t).getTime() / 1000); // uPlot: Unix seconds - - // uPlot data: [[x...], [y1...], [y2...], ...] - const uData = [timesNum, ...data.tagNames.map(tag => times.map(t => grouped[t][tag] ?? null))]; - - // 동일 태그 구성이면 setData()로 인플레이스 업데이트 → zoom/pan 상태 유지 - const tagsKey = data.tagNames.join('\0'); - if (fastChart && fastChartTagNames === tagsKey) { - fastChart.setData(uData, false); // false = 스케일 유지 (zoom 보존) - return; - } - - // 최초 생성 또는 태그 구성 변경 시 차트 재생성 - fastChartTagNames = tagsKey; - fastClearChart(); - - const opts = { - title: 'fastRecord 트렌드', - width: container.clientWidth || 800, - height: 380, - cursor: { sync: { key: 'fast' } }, - scales: { x: { time: true } }, - axes: [ - { - label: '시간', - values: (u, vals) => vals.map(v => new Date(v * 1000).toLocaleTimeString('ko-KR')) - }, - { label: '값' } - ], - series: [ - {}, - ...data.tagNames.map(tag => ({ - label: tag, - stroke: fastTagColor(tag), - width: 2 - })) - ] - }; - - fastChart = new uPlot(opts, uData, container); -} - -function fastClearChart() { - fastChartTagNames = null; - if (fastChart) { - fastChart.destroy(); - fastChart = null; - } - document.getElementById('fast-chart-container').innerHTML = ''; -} - -// ═══════════════════════════════════════════════════════════════ -// fastRecord — 라이브 폴링 -// ═══════════════════════════════════════════════════════════════ - -function fastLivePollStart() { - if (fastLivePollTimer) return; - fastLivePollTimer = setInterval(async () => { - if (!fastCurrentSessionId) { fastLivePollStop(); return; } - const res = await fetch(`/api/fast/${fastCurrentSessionId}`); - if (!res.ok) return; - const session = await res.json(); - await fastUpdateProgress(session); - await fastRenderChart(); - if (session.status !== 'Running') { - fastLivePollStop(); - await fastSelect(fastCurrentSessionId); - } - }, 2000); -} - -function fastLivePollStop() { - if (fastLivePollTimer) { - clearInterval(fastLivePollTimer); - fastLivePollTimer = null; - } -} - -async function fastUpdateProgress(session) { - const elapsed = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000); - const progress = Math.min((elapsed / session.durationSec) * 100, 100); - - document.getElementById('fast-progress-bar').style.width = `${progress}%`; - - const expectedRows = Math.floor(elapsed / (session.samplingMs / 1000)) * session.tagList?.length ?? 0; - document.getElementById('fast-progress-text').textContent = - `${session.rowCount.toLocaleString()} / ~${expectedRows.toLocaleString()} (${progress.toFixed(1)}%)`; - document.getElementById('fast-elapsed-time').textContent = - `경과: ${fastFormatDuration(Math.min(elapsed, session.durationSec))} / ${fastFormatDuration(session.durationSec)}`; -} - -// ═══════════════════════════════════════════════════════════════ -// fastRecord — 유틸 -// ═══════════════════════════════════════════════════════════════ - -function fastFormatDuration(seconds) { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - if (h > 0) return `${h}h ${m}m`; - if (m > 0) return `${m}m ${s}s`; - return `${s}s`; -} - -function fastFormatDateTime(dt) { - return new Date(dt).toLocaleString('ko-KR'); -} - -function fastTagColor(tag) { - const palette = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4', - '#42d4f4','#f032e6','#bfef45','#469990','#fabed4']; - let sum = 0; - for (let i = 0; i < tag.length; i++) sum += tag.charCodeAt(i); - return palette[sum % palette.length]; -} - -// ═══════════════════════════════════════════════════════════════ -// fastRecord — 이벤트 리스너 -// ═══════════════════════════════════════════════════════════════ - -// DB 접속 버튼 -document.getElementById('btn-fast-db-connect')?.addEventListener('click', () => { - fastDbConnect(); -}); - -// + 신규 버튼 — DB 연결 확인 후 모달 열기 -document.getElementById('btn-fast-new')?.addEventListener('click', async () => { - if (!fastDbConnected) { - alert('데이터베이스 접속을 먼저 완료해주세요. "DB 접속" 버튼을 눌러주세요.'); - return; - } - - const select = document.getElementById('fast-tag-select'); - select.innerHTML = ''; - document.getElementById('fast-session-name').value = ''; - document.getElementById('fast-retention-days').value = ''; - document.getElementById('modal-fast-new').style.display = 'flex'; - - try { - const res = await fetch('/api/pointbuilder/points'); - if (res.ok) { - const data = await res.json(); - select.innerHTML = ''; - (data.items || []).forEach(p => { - const opt = document.createElement('option'); - opt.value = p.tagName || p.TagName; - opt.textContent = p.tagName || p.TagName; - select.appendChild(opt); - }); - } else { - select.innerHTML = ''; - } - } catch (e) { - console.error('[fast] 태그 목록 로드 실패:', e); - select.innerHTML = ''; - } -}); - -// btn-fast-start: onclick="fastStart()" 으로 HTML에서 직접 처리 - -document.getElementById('btn-fast-stop')?.addEventListener('click', () => { - if (fastCurrentSessionId) fastStop(fastCurrentSessionId); -}); - -document.getElementById('btn-fast-delete')?.addEventListener('click', () => { - if (fastCurrentSessionId) fastDelete(fastCurrentSessionId); -}); - -document.getElementById('btn-fast-pin')?.addEventListener('click', () => { - if (fastCurrentSessionId) fastPin(fastCurrentSessionId); -}); - -// Excel Export -document.getElementById('btn-fast-export-xlsx')?.addEventListener('click', async () => { - if (!fastCurrentSessionId) return; - const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`); - if (!res.ok) return; - const data = await res.json(); - - // Long → Wide (배열의 배열 형식으로 XLSX.utils.aoa_to_sheet에 전달) - const timeMap = {}; - for (const r of data.items) { - if (!timeMap[r.recordedAt]) timeMap[r.recordedAt] = {}; - timeMap[r.recordedAt][r.tagName] = r.value; - } - - const rows = [['recorded_at', ...data.tagNames]]; - for (const t of Object.keys(timeMap).sort()) { - rows.push([new Date(t).toLocaleString('ko-KR'), - ...data.tagNames.map(tag => timeMap[t][tag] ?? '')]); - } - - const ws = XLSX.utils.aoa_to_sheet(rows); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, 'fastRecord'); - XLSX.writeFile(wb, `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.xlsx`); -}); - -// CSV Export (서버 스트리밍) -document.getElementById('btn-fast-export-csv')?.addEventListener('click', async () => { - if (!fastCurrentSessionId) return; - const res = await fetch(`/api/fast/${fastCurrentSessionId}/csv`); - if (!res.ok) return; - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.csv`; - a.click(); - URL.revokeObjectURL(url); -}); - -// 탭 전환 시 세션 목록 갱신 -document.querySelectorAll('[href="#pane-fast"]').forEach(a => { - a.addEventListener('show.bs.tab', () => fastSessionsLoad()); -}); - -/* ───────────────────────────────────────────────────────────── - 11 P&ID 추출 -───────────────────────────────────────────────────────────── */ -let pidCurrentPage = 1; -let pidPageSize = 20; -let pidLastResult = null; // Excel export용 - -async function pidUpload() { - const fileInput = document.getElementById('pid-file-input'); - const file = fileInput.files[0]; - const statusEl = document.getElementById('pid-upload-status'); - - if (!file) { - if (statusEl) statusEl.textContent = '❌ 파일을 선택하세요.'; - return; - } - const ext = file.name.toLowerCase().split('.').pop(); - if (!['dxf', 'pdf'].includes(ext)) { - if (statusEl) statusEl.textContent = '❌ 지원 형식: .dxf, .pdf'; - return; - } - - if (statusEl) statusEl.textContent = `📤 전송 중... (${(file.size / 1024).toFixed(0)} KB)`; - - try { - const formData = new FormData(); - formData.append('file', file); - - const res = await fetch('/api/pid/upload', { method: 'POST', body: formData }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - const data = await res.json(); - if (statusEl) statusEl.textContent = `✅ 전송 완료: ${data.fileName} (${(data.fileSize / 1024).toFixed(0)} KB)`; - - await pidLoadServerFiles(data.fileName); - } catch (e) { - if (statusEl) statusEl.textContent = `❌ 전송 실패: ${e.message}`; - } -} - -async function pidLoadServerFiles(selectFileName) { - const sel = document.getElementById('pid-server-file'); - if (!sel) return; - - try { - const res = await fetch('/api/pid/server-files'); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - - // 응답이 JSON인지 확인 - const contentType = res.headers.get('content-type'); - if (!contentType || !contentType.includes('application/json')) { - const text = await res.text(); - // HTML이 반환되면 P&ID 컨트롤러가 비활성화된 것 - if (text.startsWith('-- 서버에 파일 없음 --'; - return; - } - sel.innerHTML = data.files.map(f => - `` - ).join(''); - - if (selectFileName) sel.value = selectFileName; - } catch (e) { - sel.innerHTML = ``; - } -} - -// P&ID 추출 진행 상태 — 탭 전환 시 상태 초기화 방지 -let pidExtracting = false; -let pidElapsedInterval = null; - -async function pidExtract() { - const fileName = document.getElementById('pid-server-file').value; - const statusEl = document.getElementById('pid-status'); - const logEl = document.getElementById('pid-log'); - const elapsedEl = document.getElementById('pid-elapsed'); - - if (!fileName) { - if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.'; - return; - } - - pidExtracting = true; - if (statusEl) statusEl.textContent = '추출 중...'; - if (logEl) { - logEl.style.display = 'block'; - logEl.innerHTML = ''; - } - - // 경과 시간 시계 시작 - const startTime = Date.now(); - if (elapsedEl) { - elapsedEl.style.display = 'inline'; - pidElapsedInterval = setInterval(() => { - const seconds = Math.floor((Date.now() - startTime) / 1000); - const m = String(Math.floor(seconds / 60)).padStart(2, '0'); - const s = String(seconds % 60).padStart(2, '0'); - elapsedEl.textContent = `${m}:${s}`; - }, 1000); - } - - try { - const res = await fetch('/api/pid/extract', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileName, useImageMode: false }) - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - - const data = await res.json(); - pidExtracting = false; - const skipMsg = data.skippedDuplicates > 0 ? ` (${data.skippedDuplicates}건 중복 제외)` : ''; - if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.totalCount}건${skipMsg}`; - log('pid-log', [ - { c: 'ok', t: `✅ 추출 완료: ${data.totalCount}건${skipMsg}` }, - { c: 'inf', t: ` 신뢰도 70%+: ${data.confidenceItems}건` }, - { c: 'inf', t: ` 신뢰도 50%미만: ${data.lowConfidenceItems}건` }, - ...(data.skippedDuplicates > 0 ? [{ c: 'warn', t: ` 중복 스킵: ${data.skippedDuplicates}건` }] : []) - ]); - - pidCurrentPage = 1; - await pidLoadTable(); - pidUpdateStats(); - } catch (e) { - pidExtracting = false; - if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`; - log('pid-log', [{ c: 'err', t: `❌ ${e.message}` }]); - } finally { - // 경과 시간 시계 종료 - if (pidElapsedInterval) { - clearInterval(pidElapsedInterval); - pidElapsedInterval = null; - } - if (elapsedEl) elapsedEl.style.display = 'none'; - } -} - -async function pidLoadTable(page = 1) { - pidCurrentPage = page; - const container = document.getElementById('pid-table-container'); - const tbody = document.getElementById('pid-table-body'); - - if (!container || !tbody) return; - - // tbody만 비우고 로딩 상태 표시 — container.innerHTML을 덮어쓰면 tbody가 DOM에서 떨어져 나가 - // 이후 tbody.innerHTML 할당이 화면에 반영되지 않음. - tbody.innerHTML = '로딩 중...'; - - try { - const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - - const data = await res.json(); - pidLastResult = data; - - if (!data.items || data.items.length === 0) { - tbody.innerHTML = '데이터가 없습니다'; - document.getElementById('pid-pagination').innerHTML = ''; - return; - } - - tbody.innerHTML = data.items.map(item => ` - - ${item.id} - ${esc(item.tagName)} - ${esc(item.equipmentName) || '-'} - ${esc(item.instrumentType) || '-'} - ${esc(item.lineNumber) || '-'} - ${esc(item.pidDrawingNo) || '-'} - ${(item.confidence * 100).toFixed(1)}% - - ${item.isActive ? '활성' : '비활성'} - - - ${item.experionTagId - ? `✅ ${esc(item.experionTagName || '')}` - : `` - } - - ${item.category - ? `${esc(item.category)}` - : '-'} - - - - - - `).join(''); - - pidRenderPagination(data.total, page); - } catch (e) { - tbody.innerHTML = `오류: ${e.message}`; - } -} - -function pidRenderPagination(total, currentPage) { - const pagination = document.getElementById('pid-pagination'); - if (!pagination) return; - - const totalPages = Math.ceil(total / pidPageSize); - if (totalPages <= 1) { - pagination.innerHTML = ''; - return; - } - - let html = ''; - const start = Math.max(1, currentPage - 3); - const end = Math.min(totalPages, currentPage + 3); - - if (currentPage > 1) { - html += ``; - } - - for (let i = start; i <= end; i++) { - html += ``; - } - - if (currentPage < totalPages) { - html += ``; - } - - pagination.innerHTML = html; -} - -async function pidDeleteRow(id) { - if (!confirm('정말 이 레코드를 삭제하시겠습니까?')) return; - - try { - const res = await fetch(`/api/pid/${id}`, { method: 'DELETE' }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || res.statusText); - } - await pidLoadTable(pidCurrentPage); - pidUpdateStats(); - } catch (e) { - alert(`삭제 실패: ${e.message}`); - } -} - -function pidUpdateStats() { - if (!pidLastResult || !pidLastResult.items) return; - - const total = pidLastResult.total || 0; - const highConf = pidLastResult.items.filter(i => i.confidence >= 0.7).length; - const mapped = pidLastResult.items.filter(i => i.experionTagId).length; - - const elTotal = document.getElementById('pid-stat-total'); - const elHigh = document.getElementById('pid-stat-high'); - const elMapped = document.getElementById('pid-stat-mapped'); - - if (elTotal) elTotal.textContent = total; - if (elHigh) elHigh.textContent = highConf; - if (elMapped) elMapped.textContent = mapped; -} - -function pidClearLog() { - const logEl = document.getElementById('pid-log'); - if (logEl) logEl.innerHTML = ''; -} - -async function pidAnalyzeConnections() { - const fileName = document.getElementById('pid-server-file').value; - const statusEl = document.getElementById('pid-status'); - const logEl = document.getElementById('pid-log'); - - if (!fileName) { - if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.'; - return; - } - - if (statusEl) statusEl.textContent = '연결 분석 중...'; - if (logEl) { - logEl.style.display = 'block'; - logEl.innerHTML = '
⏳ 연결 분석 시작...
'; - } - - try { - const res = await fetch('/api/pid/connections', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileName }) - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - const data = await res.json(); - if (statusEl) statusEl.textContent = `✅ 연결 분석 완료: ${data.connectionCount}건`; - log('pid-log', [ - { c: 'ok', t: `✅ 연결 분석 완료: ${data.connectionCount}개 from→to 연결` } - ]); - pidCurrentPage = 1; - await pidLoadTable(); - pidUpdateStats(); - } catch (e) { - if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`; - log('pid-log', [{ c: 'err', t: `❌ ${e.message}` }]); - } -} - -async function pidDeleteAll() { - if (!confirm('P&ID 추출 데이터를 전부 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.')) return; - - const statusEl = document.getElementById('pid-status'); - const logEl = document.getElementById('pid-log'); - - if (statusEl) statusEl.textContent = '전체 삭제 중...'; - if (logEl) { - logEl.style.display = 'block'; - logEl.innerHTML = '
⏳ 삭제 중...
'; - } - - try { - const res = await fetch('/api/pid/all', { method: 'DELETE' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - if (statusEl) statusEl.textContent = `🗑 ${data.message}`; - log('pid-log', [{ c: 'warn', t: `🗑 전체 삭제 완료: ${data.deletedCount}건` }]); - pidCurrentPage = 1; - await pidLoadTable(); - pidUpdateStats(); - } catch (e) { - if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`; - log('pid-log', [{ c: 'err', t: `❌ ${e.message}` }]); - } -} - -/* ── Prefix 분류 정의 ─────────────────────────────────────── */ -let pidPrefixPanelVisible = false; - -function pidTogglePrefixPanel() { - const panel = document.getElementById('pid-prefix-panel'); - const toggle = document.getElementById('pid-prefix-toggle'); - pidPrefixPanelVisible = !pidPrefixPanelVisible; - if (panel) panel.style.display = pidPrefixPanelVisible ? 'block' : 'none'; - if (toggle) toggle.textContent = pidPrefixPanelVisible ? '▲' : '▼'; - if (pidPrefixPanelVisible) pidRefreshPrefixRules(); -} - -const CATEGORY_META = { - instrument: { label: 'Instrument', badge: 'ok' }, - power_equipment: { label: 'Power Equipment', badge: 'warn' }, - storage_equipment: { label: 'Storage Equipment', badge: 'inf' }, - process_equipment: { label: 'Process Equipment', badge: '' }, - utility_equipment: { label: 'Utility Equipment', badge: 'warn' }, - pipings: { label: 'Pipings', badge: 'ok' } -}; -const CATEGORY_ORDER = ['instrument', 'power_equipment', 'storage_equipment', 'process_equipment', 'utility_equipment', 'pipings']; - -function pidCategoryBadge(cat) { - return CATEGORY_META[cat] ? CATEGORY_META[cat].badge : ''; -} - -async function pidRefreshPrefixRules() { - const container = document.getElementById('pid-prefix-groups'); - if (!container) return; - container.innerHTML = '
로딩 중...
'; - - try { - const res = await fetch('/api/pid/prefix-rules'); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - const items = data.items || []; - - if (items.length === 0) { - container.innerHTML = '
규칙이 없습니다. 아래에서 추가하세요.
'; - return; - } - - const grouped = {}; - for (const r of items) { - if (!grouped[r.category]) grouped[r.category] = []; - grouped[r.category].push(r); - } - - let html = ''; - for (const cat of CATEGORY_ORDER) { - const rules = grouped[cat]; - if (!rules) continue; - const meta = CATEGORY_META[cat] || { label: cat, badge: '' }; - rules.sort((a, b) => a.sortOrder - b.sortOrder); - - html += `
-
- ${meta.label} - ${rules.length} - - - - - - -
-
`; - - for (const r of rules) { - html += `
- - - - - - - -
`; - } - - html += `
`; - } - - container.innerHTML = html; - } catch (e) { - container.innerHTML = `
오류: ${e.message}
`; - } -} - -async function pidAddPrefixRule(category) { - const row = event.target.closest('.pid-cat-add'); - const prefix = row.querySelector('.pid-cat-prefix-input').value.trim(); - const desc = row.querySelector('.pid-cat-desc-input').value.trim(); - const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10; - - if (!prefix) { alert('Prefix를 입력하세요.'); return; } - - try { - const res = await fetch('/api/pid/prefix-rules', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prefix, category, description: desc, sortOrder: order }) - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - row.querySelector('.pid-cat-prefix-input').value = ''; - row.querySelector('.pid-cat-desc-input').value = ''; - await pidRefreshPrefixRules(); - } catch (e) { - alert(`추가 실패: ${e.message}`); - } -} - -async function pidUpdatePrefixRule(id, btn) { - const row = btn.closest('.pid-cat-row'); - const group = btn.closest('.pid-cat-group'); - const catIdx = [...group.parentElement.children].indexOf(group); - const cat = CATEGORY_ORDER[catIdx]; - const prefix = row.querySelector('.pid-cat-prefix-input').value.trim(); - const desc = row.querySelector('.pid-cat-desc-input').value.trim(); - const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10; - - if (!prefix) { alert('Prefix를 입력하세요.'); return; } - - try { - const res = await fetch(`/api/pid/prefix-rules/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prefix, category: cat, description: desc, sortOrder: order }) - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - await pidRefreshPrefixRules(); - } catch (e) { - alert(`수정 실패: ${e.message}`); - } -} - -async function pidDeletePrefixRule(id, prefix) { - if (!confirm(`"${prefix}" 규칙을 삭제하시겠습니까?`)) return; - try { - const res = await fetch(`/api/pid/prefix-rules/${id}`, { method: 'DELETE' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - await pidRefreshPrefixRules(); - } catch (e) { - alert(`삭제 실패: ${e.message}`); - } -} - -async function pidApplyCategories() { - if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return; - try { - const res = await fetch('/api/pid/apply-categories', { method: 'POST' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - alert(`${data.applied}건 category 적용 완료`); - await pidLoadTable(pidCurrentPage); - pidUpdateStats(); - } catch (e) { - alert(`재적용 실패: ${e.message}`); - } -} - -function pidOpenMapping(id) { - // 매핑 모달 열기 (추후 구현) - console.log('pidOpenMapping:', id); -} - -// Export buttons -document.getElementById('btn-pid-export-csv')?.addEventListener('click', async () => { - if (!pidLastResult) return; - const res = await fetch('/api/pid/export/csv'); - if (!res.ok) return; - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `pid-export-${new Date().toISOString().slice(0,10)}.csv`; - a.click(); - URL.revokeObjectURL(url); -}); - -document.getElementById('btn-pid-export-excel')?.addEventListener('click', async () => { - if (!pidLastResult) return; - const res = await fetch('/api/pid/export/excel'); - if (!res.ok) return; - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `pid-export-${new Date().toISOString().slice(0,10)}.xlsx`; - a.click(); - URL.revokeObjectURL(url); -}); - -// 탭 진입 시 초기화 -document.querySelectorAll('[data-tab="pid"]').forEach(item => { - item.addEventListener('click', async () => { - pidCurrentPage = 1; - pidLastResult = null; - document.getElementById('pid-file-input').value = ''; - if (!pidExtracting) { - document.getElementById('pid-status').textContent = '대기 중...'; - const elapsedEl = document.getElementById('pid-elapsed'); - if (elapsedEl) elapsedEl.style.display = 'none'; - if (pidElapsedInterval) { - clearInterval(pidElapsedInterval); - pidElapsedInterval = null; - } - // 추출 중이 아니면 테이블/통계 초기화 및 갱신 - document.getElementById('pid-table-body').innerHTML = ''; - document.getElementById('pid-pagination').innerHTML = ''; - document.getElementById('pid-stat-total').textContent = '0'; - document.getElementById('pid-stat-high').textContent = '0'; - document.getElementById('pid-stat-mapped').textContent = '0'; - await pidLoadTable(1); - } - // 추출 중이면 시계 유지, 테이블/통계 갱신 생략 - }); -}); - -/* ═══════════════════════════════════════════════════════ - 13 로컬 LLM 채팅 - ══════════════════════════════════════════════════════ */ - -// ── 상태 ─────────────────────────────────────────────── -let llmSessions = JSON.parse(localStorage.getItem('llmSessions') || '[]'); -let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || ''; -let llmAbortController = null; -let llmIsStreaming = false; -let llmType = localStorage.getItem('llmType') || 'ollama'; -let llmUseTools = localStorage.getItem('llmUseTools') === 'true'; -let llmAgentMode = localStorage.getItem('llmAgentMode') === 'true'; -let llmMcpTools = []; - -// ── Phase 5.5: welcome 화면 추천 질문 ─────────────────── -const LLM_STARTER_CHIPS = [ - '지금 활성 알람을 보여줘', - 'Unit A의 24시간 운전 상황을 요약해줘', - 'FIC-6113.PV 최근 1시간 추이', - '오늘 발생한 디지털 이벤트 정리', - 'P-6201 펌프의 정비 이력', - '이번 주 보고서를 작성해줘', - '냉각수 펌프 토출 압력 태그를 찾아줘' -]; - -function llmUseChip(btn) { - const input = document.getElementById('llm-input'); - if (!input) return; - input.value = btn.textContent; - input.focus(); - input.style.height = 'auto'; - input.style.height = Math.min(input.scrollHeight, 150) + 'px'; -} - -// ── 초기화 (탭 진입 시 API 호출 없음) ────────────────── -document.querySelectorAll('[data-tab="llmchat"]').forEach(item => { - item.addEventListener('click', () => { - llmRenderSessionList(); - llmLoadActiveSession(); - llmLoadModels(); - llmLoadConfigToUI(); - llmLoadMcpTools(); - }); -}); - -// ── 세션 관리 ────────────────────────────────────────── -function llmCreateSession() { - const id = 'llm_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); - const session = { - id, - title: '새 대화', - model: '', - systemPrompt: '', - messages: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }; - llmSessions.unshift(session); - llmActiveSessionId = id; - llmSaveSessions(); - llmRenderSessionList(); - llmRenderMessages(); - return session; -} - -function llmNewSession() { - llmCreateSession(); - document.getElementById('llm-input').focus(); -} - -function llmSwitchSession(id) { - llmActiveSessionId = id; - localStorage.setItem('llmActiveSessionId', id); - llmRenderSessionList(); - llmRenderMessages(); -} - -function llmDeleteSession(id, e) { - e.stopPropagation(); - if (!confirm('이 대화를 삭제하시겠습니까?')) return; - llmSessions = llmSessions.filter(s => s.id !== id); - llmSaveSessions(); - if (llmActiveSessionId === id) { - llmActiveSessionId = llmSessions.length > 0 ? llmSessions[0].id : ''; - localStorage.setItem('llmActiveSessionId', llmActiveSessionId); - } - llmRenderSessionList(); - llmRenderMessages(); -} - -function llmGetActiveSession() { - return llmSessions.find(s => s.id === llmActiveSessionId) || null; -} - -function llmSaveSessions() { - localStorage.setItem('llmSessions', JSON.stringify(llmSessions)); - localStorage.setItem('llmActiveSessionId', llmActiveSessionId); -} - -function llmSaveSessionMeta() { - const sess = llmGetActiveSession(); - if (!sess) return; - sess.model = document.getElementById('llm-model-select').value; - sess.systemPrompt = document.getElementById('llm-system-prompt').value.trim(); - sess.updatedAt = new Date().toISOString(); - llmSaveSessions(); - llmRenderSessionList(); -} - -// ── 세션 목록 렌더링 ─────────────────────────────────── -function llmRenderSessionList() { - const el = document.getElementById('llm-session-list'); - if (!el) return; - - if (llmSessions.length === 0) { - el.innerHTML = '
대화가 없습니다.
+ 버튼을 눌러 새 대화를 시작하세요.
'; - return; - } - - el.innerHTML = llmSessions.map(s => { - const isActive = s.id === llmActiveSessionId; - const title = esc(s.title || '제목 없음'); - return ` -
- ${title} - × -
- `; - }).join(''); -} - -// ── 메시지 렌더링 ────────────────────────────────────── -function llmRenderMessages() { - const el = document.getElementById('llm-messages'); - if (!el) return; - - const sess = llmGetActiveSession(); - if (!sess || sess.messages.length === 0) { - const chips = LLM_STARTER_CHIPS.map(q => - `` - ).join(''); - el.innerHTML = ` -
-
💬
-
새 대화를 시작하세요
-
모델을 선택하고 메시지를 입력하세요. 또는 아래 추천 질문을 클릭하세요.
-
${chips}
-
- `; - if (sess) { - const sel = document.getElementById('llm-model-select'); - if (sel && sess.model) sel.value = sess.model; - const ta = document.getElementById('llm-system-prompt'); - if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; - } - return; - } - - const sel = document.getElementById('llm-model-select'); - if (sel && sess.model) sel.value = sess.model; - const ta = document.getElementById('llm-system-prompt'); - if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; - - const summaryBanner = sess.summary - ? `
-
📋 이전 대화 요약 (${sess.summarizedUpTo || 0}개 메시지 압축)
-
${esc(sess.summary)}
-
` - : ''; - const upTo = sess.summarizedUpTo || 0; - el.innerHTML = summaryBanner + sess.messages.slice(upTo).map(m => { - const role = m.role === 'user' ? 'user' : 'assistant'; - const avatar = role === 'user' ? 'U' : 'AI'; - const content = llmFormatMessage(m.content); - const toolCards = (role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) - ? llmRenderToolCardsHtml(m.toolCalls) - : ''; - return ` -
-
${avatar}
-
${toolCards}${content}
-
- `; - }).join(''); - - el.scrollTop = el.scrollHeight; -} - -// ── 메시지 포맷팅 (placeholder 패턴으로 esc 순서 보장) ── -function llmFormatMessage(text) { - const blocks = []; - text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, __, code) => { - blocks.push(`
${esc(code.trim())}
`); - return `\x00B${blocks.length - 1}\x00`; - }); - text = text.replace(/`([^`]+)`/g, (_, code) => { - blocks.push(`${esc(code)}`); - return `\x00B${blocks.length - 1}\x00`; - }); - text = esc(text).replace(/\n/g, '
'); - text = text.replace(/\x00B(\d+)\x00/g, (_, i) => blocks[+i]); - return llmLinkKbCitations(text); -} - -/* ── Phase 5: KB 인용 → 다운로드 링크 치환 ─────────────── */ -let llmKbDocMap = {}; // title → docId (search_kb 결과로 누적) - -function llmRegisterKbHits(hits) { - if (!Array.isArray(hits)) return; - for (const h of hits) { - if (h && h.title && h.doc_id) llmKbDocMap[h.title] = h.doc_id; - } -} - -function llmLinkKbCitations(html) { - const titles = Object.keys(llmKbDocMap); - if (titles.length === 0) return html; - // 긴 제목부터 매칭 (부분문자열 겹침 회피) - titles.sort((a, b) => b.length - a.length); - for (const title of titles) { - const docId = llmKbDocMap[title]; - const safeTitle = esc(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const re = new RegExp(safeTitle, 'g'); - html = html.replace(re, - `${esc(title)} ⬇`); - } - return html; -} - -/* ── Phase 5: 툴 실행 카드 ─────────────────────────────── */ -function llmEnsureStreamingMsgEl() { - let msgEl = document.getElementById('llm-streaming-msg'); - if (msgEl) return msgEl; - const messagesEl = document.getElementById('llm-messages'); - msgEl = document.createElement('div'); - msgEl.className = 'llm-msg assistant'; - msgEl.id = 'llm-streaming-msg'; - msgEl.innerHTML = ` -
AI
-
- `; - messagesEl.appendChild(msgEl); - return msgEl; -} - -function llmEnsureToolCardContainer() { - const msgEl = llmEnsureStreamingMsgEl(); - let cont = msgEl.querySelector('.llm-tool-cards'); - if (!cont) { - cont = document.createElement('div'); - cont.className = 'llm-tool-cards'; - const bubble = msgEl.querySelector('.llm-msg-bubble'); - msgEl.insertBefore(cont, bubble); - } - return cont; -} - -function llmAppendToolCard(id, name, args) { - const cont = llmEnsureToolCardContainer(); - const argSummary = llmSummarizeArgs(args); - const card = document.createElement('div'); - card.className = 'llm-tool-card running'; - card.dataset.toolId = id; - card.innerHTML = ` -
- - ${esc(name)} - ${esc(argSummary)} - 실행 중… -
-
-
결과 대기 중…
-
- `; - cont.appendChild(card); - const messagesEl = document.getElementById('llm-messages'); - if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight; -} - -// 소프트 타임아웃 초과 — 1회 연장 진행 중 알림 -function llmMarkToolCardExtending(id, softSec, extSec) { - const cont = document.querySelector('.llm-tool-cards'); - if (!cont) return; - const card = cont.querySelector(`.llm-tool-card[data-tool-id="${CSS.escape(id)}"]`); - if (!card) return; - card.classList.remove('running'); - card.classList.add('extending'); - const st = card.querySelector('.llm-tool-status'); - if (st) st.textContent = `응답 지연 — 연장 중 (${softSec}s → ${extSec}s)`; -} - -function llmUpdateToolCard(id, name, ok, preview, length, payload) { - const cont = document.querySelector('.llm-tool-cards'); - if (!cont) return; - const card = cont.querySelector(`.llm-tool-card[data-tool-id="${CSS.escape(id)}"]`); - if (!card) return; - card.classList.remove('running', 'extending'); - card.classList.add(ok ? 'ok' : 'err'); - const st = card.querySelector('.llm-tool-status'); - if (st) st.textContent = ok ? `완료 · ${length}자` : '실패'; - const body = card.querySelector('.llm-tool-body'); - if (body) body.innerHTML = llmRenderToolPayload(name, ok, preview, payload); - - // search_kb 결과면 인용 매핑 등록 - if (ok && name === 'search_kb') { - try { - const parsed = JSON.parse(payload); - if (parsed.success && Array.isArray(parsed.hits)) llmRegisterKbHits(parsed.hits); - } catch {} - } -} - -// 영구 보존: sess.messages[*].toolCalls 배열 → 정적 HTML로 재렌더링 -function llmRenderToolCardsHtml(toolCalls) { - const cards = toolCalls.map(tc => { - const ok = tc.ok; - const cls = ok === null || ok === undefined ? 'running' : (ok ? 'ok' : 'err'); - const statusText = ok === null || ok === undefined - ? '실행 중…' - : (ok ? `완료${typeof tc.length === 'number' ? ' · ' + tc.length + '자' : ''}` : '실패'); - const argSummary = llmSummarizeArgs(tc.args); - const body = (ok === null || ok === undefined) - ? '
결과 대기 중…
' - : llmRenderToolPayload(tc.name, ok, tc.preview, tc.payload); - const toolId = tc.id ? `data-tool-id="${esc(tc.id)}"` : ''; - return ` -
-
- - ${esc(tc.name || '')} - ${esc(argSummary)} - ${esc(statusText)} -
-
${body}
-
- `; - }).join(''); - return `
${cards}
`; -} - -function llmSummarizeArgs(args) { - if (!args) return ''; - if (typeof args === 'string') { - return args.length > 100 ? args.slice(0, 100) + '…' : args; - } - try { - const s = JSON.stringify(args); - return s.length > 100 ? s.slice(0, 100) + '…' : s; - } catch { return ''; } -} - -function llmRenderToolPayload(name, ok, preview, payload) { - if (!ok) return `
${esc(preview || '오류')}
`; - // JSON 응답이면 표/시계열 자동 렌더 시도 - try { - const j = JSON.parse(payload || preview); - // search_kb 형태 - if (Array.isArray(j.hits)) return llmRenderKbHits(j.hits); - // run_sql/query_with_nl 형태: {success, columns:[], data:[{...}, ...]} - if (j.success && Array.isArray(j.columns) && Array.isArray(j.data)) { - const ts = llmDetectTimeSeries(j.data); - const sparkHtml = ts ? llmBuildSparklineHtml(j.data, ts) : ''; - return sparkHtml + llmRenderTable(j.columns, j.data); - } - // query_pv_history 형태: {success, data:[{tag_name, timestamp, value}, ...]} - if (j.success && Array.isArray(j.data) && j.data.length > 0 && typeof j.data[0] === 'object') { - const cols = Object.keys(j.data[0]); - const ts = llmDetectTimeSeries(j.data); - const sparkHtml = ts ? llmBuildSparklineHtml(j.data, ts) : ''; - return sparkHtml + llmRenderTable(cols, j.data); - } - } catch {} - return `
${esc((preview || '').slice(0, 800))}
`; -} - -// 시계열 감지: data가 {timestamp|recorded_at|ts} + {value|pv|val} 컬럼을 가지면 키 페어 반환 -function llmDetectTimeSeries(data) { - if (!Array.isArray(data) || data.length < 3) return null; - const sample = data[0]; - if (!sample || typeof sample !== 'object') return null; - const timeKeys = ['timestamp', 'recorded_at', 'ts', 'time', 'datetime']; - const valueKeys = ['value', 'pv', 'val', 'fieldvalue', 'sp', 'op']; - const timeKey = timeKeys.find(k => k in sample); - const valueKey = valueKeys.find(k => k in sample && !isNaN(parseFloat(sample[k]))); - if (!timeKey || !valueKey) return null; - // 유효 데이터 수 체크 (timestamp 파싱 가능 + numeric value) - let valid = 0; - for (const r of data) { - const t = new Date(r[timeKey]).getTime(); - const v = parseFloat(r[valueKey]); - if (!isNaN(t) && !isNaN(v)) valid++; - if (valid >= 3) return { timeKey, valueKey }; - } - return null; -} - -// uPlot은 비동기 DOM 부착 후 생성해야 하므로, container를 placeholder로 만들고 -// requestAnimationFrame에서 차트를 그린다. -function llmBuildSparklineHtml(data, keys) { - if (typeof uPlot === 'undefined') return ''; - const id = 'llm-spark-' + (++_llmSparkSeq); - // 데이터를 캐시에 보관 → 비동기 렌더 시 참조 - _llmSparkCache[id] = { data, keys }; - // 다음 프레임에 부착되도록 예약 - requestAnimationFrame(() => llmMountSparkline(id)); - return `
📈 시계열 추세 (${data.length}건)
`; -} - -let _llmSparkSeq = 0; -const _llmSparkCache = {}; - -function llmMountSparkline(id) { - const container = document.getElementById(id); - if (!container) return; - const entry = _llmSparkCache[id]; - if (!entry) return; - delete _llmSparkCache[id]; - if (typeof uPlot === 'undefined') return; - - const { data, keys } = entry; - const pairs = []; - for (const r of data) { - const t = new Date(r[keys.timeKey]).getTime() / 1000; - const v = parseFloat(r[keys.valueKey]); - if (!isNaN(t) && !isNaN(v)) pairs.push([t, v]); - } - if (pairs.length < 2) return; - pairs.sort((a, b) => a[0] - b[0]); - const xs = pairs.map(p => p[0]); - const ys = pairs.map(p => p[1]); - - const opts = { - width: Math.min(container.clientWidth || 360, 600), - height: 90, - cursor: { show: true, points: { show: true } }, - select: { show: false }, - legend: { show: false }, - scales: { x: { time: true } }, - axes: [ - { stroke: '#888', size: 22, grid: { show: false } }, - { stroke: '#888', size: 36, grid: { stroke: 'rgba(255,255,255,0.05)' } } - ], - series: [ - {}, - { - label: keys.valueKey, - stroke: 'rgb(96, 165, 250)', - width: 1.4, - points: { show: false } - } - ] - }; - try { - new uPlot(opts, [xs, ys], container); - } catch (e) { - container.innerHTML = `
차트 렌더 실패
`; - } -} - -function llmRenderTable(columns, data) { - if (!data || data.length === 0) return '
결과 0건
'; - const limit = Math.min(data.length, 50); - const ths = columns.map(c => `${esc(c)}`).join(''); - const rows = data.slice(0, limit).map(row => { - const tds = columns.map(c => { - const v = row[c]; - return `${v == null ? '' : esc(String(v))}`; - }).join(''); - return `${tds}`; - }).join(''); - const more = data.length > limit ? `
…나머지 ${data.length - limit}건 생략
` : ''; - return `
${ths}${rows}
${more}`; -} - -function llmRenderKbHits(hits) { - if (!hits || hits.length === 0) return '
검색 결과 0건
'; - llmRegisterKbHits(hits); - return '
' + hits.slice(0, 8).map(h => { - const score = (h.score || 0).toFixed(3); - const link = h.doc_id - ? `${esc(h.title || '(제목없음)')} ⬇` - : esc(h.title || '(제목없음)'); - const loc = h.locator ? ` · ${esc(h.locator)}` : ''; - const snippet = (h.text || '').slice(0, 200).replace(/\s+/g, ' '); - return `
-
${score} ${link}${loc}
-
${esc(snippet)}…
-
`; - }).join('') + '
'; -} - -// ── 모델 목록 로드 ───────────────────────────────────── -async function llmLoadModels() { - const prefix = llmType === 'vllm' ? '/api/ollama/vllm' : '/api/ollama'; - const label = llmType === 'vllm' ? 'vLLM' : 'Ollama'; - const sel = document.getElementById('llm-model-select'); - if (!sel) return; - - const currentVal = sel.value; - - try { - const d = await api('GET', `${prefix}/models`); - sel.innerHTML = ''; - - if (d.success && d.models) { - d.models.forEach(m => { - const opt = document.createElement('option'); - opt.value = m; - opt.textContent = m; - sel.appendChild(opt); - }); - if (currentVal && [...sel.options].some(o => o.value === currentVal)) { - sel.value = currentVal; - } - } - - const dot = document.getElementById('llm-conn-status'); - if (dot) { - dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; - dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패`; - } - } catch (e) { - if (currentVal) sel.value = currentVal; - const dot = document.getElementById('llm-conn-status'); - if (dot) { - dot.className = 'llm-conn-dot error'; - dot.title = `${label} 연결 실패: ` + e.message; - } - } -} - -function llmOnTypeChange() { - llmType = document.getElementById('llm-type-select').value; - localStorage.setItem('llmType', llmType); - llmLoadModels(); - llmLoadMcpTools(); -} - -async function llmLoadMcpTools() { - const useTools = document.getElementById('llm-use-tools'); - const toolsRow = document.getElementById('llm-tools-row'); - const agentRow = document.getElementById('llm-agent-row'); - const agentChk = document.getElementById('llm-agent-mode'); - if (llmType !== 'vllm') { - if (useTools) useTools.checked = false; - if (toolsRow) toolsRow.style.display = 'none'; - if (agentRow) agentRow.style.display = 'none'; - llmUseTools = false; - localStorage.setItem('llmUseTools', 'false'); - return; - } - if (toolsRow) toolsRow.style.display = ''; - // 에이전트 모드 토글은 MCP 도구가 활성일 때만 의미 있음 - if (agentRow) agentRow.style.display = (useTools && useTools.checked) ? '' : 'none'; - if (agentChk) agentChk.checked = !!llmAgentMode; - if (useTools) { - llmUseTools = useTools.checked; - localStorage.setItem('llmUseTools', llmUseTools); - } - if (!llmUseTools) { llmMcpTools = []; return; } - try { - const d = await api('GET', '/api/text-to-sql/tools'); - if (d.success && d.tools) { - llmMcpTools = d.tools.map(t => ({ - type: 'function', - function: { - name: t.Name || t.name, - description: t.Description || t.description || '', - parameters: t.InputSchema || t.inputSchema || { type: 'object', properties: {} } - } - })); - } else { - llmMcpTools = []; - } - } catch { - llmMcpTools = []; - } -} - -function llmToggleTools() { - llmUseTools = document.getElementById('llm-use-tools').checked; - localStorage.setItem('llmUseTools', llmUseTools); - const agentRow = document.getElementById('llm-agent-row'); - if (agentRow) agentRow.style.display = (llmType === 'vllm' && llmUseTools) ? '' : 'none'; -} - -function llmToggleAgentMode() { - const el = document.getElementById('llm-agent-mode'); - llmAgentMode = el && el.checked; - localStorage.setItem('llmAgentMode', llmAgentMode ? 'true' : 'false'); -} - -/* ── Phase 7.2: 대화 요약 ───────────────────────────────── - sess.messages 길이가 임계값을 넘으면 오래된 절반을 LLM에 요약 요청 → sess.summary 보관. - 요약된 만큼 sess.summarizedUpTo (메시지 인덱스, exclusive)를 진전시킨다. - 전송 시: messages는 sess.messages.slice(summarizedUpTo, -1), systemPrompt에 요약 prepend. */ -const LLM_MAX_HISTORY = 20; -const LLM_SUMMARY_KEEP = 10; // 요약 후 유지할 최근 메시지 수 - -async function llmEnsureSummary(sess, model) { - if (!sess) return; - const total = sess.messages.length; - const upTo = sess.summarizedUpTo || 0; - // 활성 메시지 수 (요약되지 않은) 가 임계를 넘으면 요약 - if (total - upTo <= LLM_MAX_HISTORY) return; - - // 요약 대상: [upTo, total - LLM_SUMMARY_KEEP) ← 마지막 placeholder는 호출 시점에 아직 없음 (caller 호출 순서 주의) - const targetEnd = Math.max(upTo + 1, total - LLM_SUMMARY_KEEP); - if (targetEnd <= upTo) return; - - const oldMsgs = sess.messages.slice(upTo, targetEnd) - .filter(m => m && m.content && (m.role === 'user' || m.role === 'assistant')) - .map(m => ({ role: m.role, content: m.content })); - if (oldMsgs.length < 2) return; - - // 기존 summary가 있다면 함께 넘겨 누적 요약 - const merged = sess.summary - ? [{ role: 'system', content: '[이전 요약]\n' + sess.summary }, ...oldMsgs] - : oldMsgs; - - try { - const r = await api('POST', '/api/ollama/summarize', { model, messages: merged }); - if (r && r.success && r.summary) { - sess.summary = r.summary; - sess.summarizedUpTo = targetEnd; - llmSaveSessions(); - } - } catch (e) { - // 요약 실패는 무시 (다음 턴에 재시도) - console.warn('summarize failed:', e); - } -} - -// ── 메시지 전송 (스트리밍) ───────────────────────────── -async function llmSend() { - const input = document.getElementById('llm-input'); - const text = input.value.trim(); - if (!text || llmIsStreaming) return; - - let sess = llmGetActiveSession(); - if (!sess) { - sess = llmCreateSession(); - } - - const model = document.getElementById('llm-model-select').value; - if (!model) { - alert('모델을 선택하세요.'); - return; - } - - const systemPrompt = document.getElementById('llm-system-prompt').value.trim(); - - // 첫 번째 메시지 시 세션 제목 자동 갱신 - if (sess.messages.length === 0 && sess.title === '새 대화') { - sess.title = text.slice(0, 30) + (text.length > 30 ? '…' : ''); - } - - sess.messages.push({ role: 'user', content: text }); - input.value = ''; - input.style.height = 'auto'; - llmSaveSessions(); - llmRenderMessages(); - llmRenderSessionList(); - - sess.model = model; - sess.systemPrompt = systemPrompt; - - // Phase 7.2: 임계 초과 시 오래된 메시지 요약 (assistant placeholder 추가 전에 실행) - await llmEnsureSummary(sess, model); - - const assistantMsg = { role: 'assistant', content: '', toolCalls: [] }; - sess.messages.push(assistantMsg); - - llmIsStreaming = true; - llmUpdateButtons(); - llmRenderMessages(); - - const messagesEl = document.getElementById('llm-messages'); - const typingDiv = document.createElement('div'); - typingDiv.className = 'llm-msg assistant'; - typingDiv.id = 'llm-streaming-msg'; - typingDiv.innerHTML = ` -
AI
-
-
-
- `; - messagesEl.appendChild(typingDiv); - messagesEl.scrollTop = messagesEl.scrollHeight; - - llmAbortController = new AbortController(); - - try { - const prefix = llmType === 'vllm' ? '/api/ollama/vllm' : '/api/ollama'; - const upTo = sess.summarizedUpTo || 0; - const activeMsgs = sess.messages.slice(upTo, -1); - let effectiveSystemPrompt = systemPrompt || ''; - if (sess.summary) { - const prefixBlock = `[이전 대화 요약 — 사용자에게 보이지 않음]\n${sess.summary}`; - effectiveSystemPrompt = effectiveSystemPrompt - ? `${prefixBlock}\n\n${effectiveSystemPrompt}` - : prefixBlock; - } - const requestBody = { - model, - messages: activeMsgs, - systemPrompt: effectiveSystemPrompt || undefined - }; - if (llmType === 'vllm' && llmUseTools && llmMcpTools.length > 0) { - requestBody.tools = llmMcpTools; - if (llmAgentMode) requestBody.agentMode = true; - } - const res = await fetch(`${prefix}/chat/stream`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - signal: llmAbortController.signal - }); - - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - let streamDone = false; - - while (true) { - if (streamDone) break; - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - const parts = buffer.split('\n\n'); - buffer = parts.pop() || ''; - - for (const part of parts) { - if (streamDone) break; - const lines = part.split('\n'); - let eventType = 'message'; - let eventData = ''; - for (const line of lines) { - if (line.startsWith('event: ')) { - eventType = line.slice(7).trim(); - } else if (line.startsWith('data: ')) { - eventData = line.slice(6); - } - } - - if (eventType === 'error') { - throw new Error(eventData || '스트리밍 오류'); - } - if (eventType === 'done') { - streamDone = true; - break; - } - - if (eventType === 'tool_start') { - try { - const t = JSON.parse(eventData); - llmAppendToolCard(t.id, t.name, t.args); - assistantMsg.toolCalls.push({ - id: t.id, name: t.name, args: t.args, - ok: null, preview: null, length: null, payload: null - }); - } catch {} - continue; - } - - if (eventType === 'tool_extending') { - try { - const t = JSON.parse(eventData); - llmMarkToolCardExtending(t.id, t.softTimeoutSec, t.extendedTimeoutSec); - const tc = assistantMsg.toolCalls.find(x => x.id === t.id); - if (tc) { - tc.extended = true; - tc.softTimeoutSec = t.softTimeoutSec; - tc.extendedTimeoutSec = t.extendedTimeoutSec; - } - } catch {} - continue; - } - - if (eventType === 'tool_result') { - try { - const t = JSON.parse(eventData); - llmUpdateToolCard(t.id, t.name, t.ok, t.preview, t.length, t.payload); - const tc = assistantMsg.toolCalls.find(x => x.id === t.id); - if (tc) { - tc.ok = t.ok; - tc.preview = t.preview; - tc.length = t.length; - tc.payload = t.payload; - } else { - assistantMsg.toolCalls.push({ - id: t.id, name: t.name, args: null, - ok: t.ok, preview: t.preview, length: t.length, payload: t.payload - }); - } - } catch {} - continue; - } - - // 일반 message 이벤트 - if (eventData && eventData !== '{}') { - try { - const json = JSON.parse(eventData); - if (json.message && json.message.content) { - assistantMsg.content += json.message.content; - llmUpdateStreamingMessage(assistantMsg.content); - } else if (json.response) { - assistantMsg.content += json.response; - llmUpdateStreamingMessage(assistantMsg.content); - } - } catch { - } - } - } - } - - sess.updatedAt = new Date().toISOString(); - llmSaveSessions(); - llmRenderMessages(); - llmRenderSessionList(); - - } catch (e) { - if (e.name === 'AbortError') { - if (assistantMsg.content || (assistantMsg.toolCalls && assistantMsg.toolCalls.length > 0)) { - sess.updatedAt = new Date().toISOString(); - llmSaveSessions(); - llmRenderMessages(); - } else { - sess.messages.pop(); - llmSaveSessions(); - llmRenderMessages(); - } - } else { - assistantMsg.content = `❌ 오류: ${e.message}`; - sess.messages.pop(); - sess.messages.push(assistantMsg); - sess.updatedAt = new Date().toISOString(); - llmSaveSessions(); - llmRenderMessages(); - } - } finally { - llmIsStreaming = false; - llmAbortController = null; - llmUpdateButtons(); - } -} - -function llmUpdateStreamingMessage(content) { - let msgEl = document.getElementById('llm-streaming-msg'); - if (!msgEl) { - const messagesEl = document.getElementById('llm-messages'); - msgEl = document.createElement('div'); - msgEl.className = 'llm-msg assistant'; - msgEl.id = 'llm-streaming-msg'; - msgEl.innerHTML = ` -
AI
-
- `; - messagesEl.appendChild(msgEl); - } - - const bubble = msgEl.querySelector('.llm-msg-bubble'); - if (bubble) { - bubble.innerHTML = llmFormatMessage(content); - } - - const messagesEl = document.getElementById('llm-messages'); - if (messagesEl) { - messagesEl.scrollTop = messagesEl.scrollHeight; - } -} - -function llmStop() { - if (llmAbortController) { - llmAbortController.abort(); - } -} - -function llmUpdateButtons() { - const sendBtn = document.getElementById('llm-send-btn'); - const stopBtn = document.getElementById('llm-stop-btn'); - if (sendBtn) sendBtn.disabled = llmIsStreaming; - if (stopBtn) stopBtn.style.display = llmIsStreaming ? 'inline-flex' : 'none'; -} - -// ── 입력 키 처리 ─────────────────────────────────────── -function llmInputKeydown(e) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - llmSend(); - } - const ta = e.target; - setTimeout(() => { - ta.style.height = 'auto'; - ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'; - }, 0); -} - -// ── 세션 초기화 ──────────────────────────────────────── -function llmClearSession() { - const sess = llmGetActiveSession(); - if (!sess) return; - if (!confirm('현재 대화의 메시지를 모두 지우시겠습니까?')) return; - sess.messages = []; - sess.updatedAt = new Date().toISOString(); - llmSaveSessions(); - llmRenderMessages(); - llmRenderSessionList(); -} - -// ── 설정 ─────────────────────────────────────────────── -function llmToggleSettings() { - const panel = document.getElementById('llm-settings-panel'); - if (panel) { - const opening = panel.classList.contains('hidden'); - panel.classList.toggle('hidden'); - if (opening) llmLoadConfigToUI(); - } -} - -async function llmSaveConfig() { - const host = document.getElementById('llm-host').value.trim() || 'localhost'; - const port = parseInt(document.getElementById('llm-port').value) || 11434; - try { - const d = await api('POST', '/api/ollama/config', { host, port }); - if (d.success) { - alert('설정 저장 완료. 변경 사항 적용을 위해 페이지를 새로고침하세요.'); - } else { - alert('설정 저장 실패: ' + (d.error || '알 수 없는 오류')); - } - } catch (e) { - alert('설정 저장 실패: ' + e.message); - } -} - -async function llmTestConnection() { - const prefix = llmType === 'vllm' ? '/api/ollama/vllm' : '/api/ollama'; - const label = llmType === 'vllm' ? 'vLLM' : 'Ollama'; - try { - const d = await api('GET', `${prefix}/ping`); - const dot = document.getElementById('llm-conn-status'); - if (dot) { - dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; - dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패: ${d.error || ''}`; - } - alert(d.success ? `${label} 연결 성공!` : `${label} 연결 실패: ${d.error || ''}`); - } catch (e) { - alert(`${label} 연결 테스트 실패: ` + e.message); - } -} - -function llmLoadConfigToUI() { - api('GET', '/api/ollama/config').then(d => { - if (d.success) { - const hostEl = document.getElementById('llm-host'); - const portEl = document.getElementById('llm-port'); - if (hostEl && d.host) hostEl.value = d.host; - if (portEl && d.port) portEl.value = d.port; - } - }).catch(() => {}); - - api('GET', '/api/llm/config').then(d => { - if (d.success && d.vllmModel) { - const sel = document.getElementById('llm-model-select'); - if (sel && !sel.value) sel.value = d.vllmModel; - } - }).catch(() => {}); -} - -function llmLoadActiveSession() { - if (llmActiveSessionId && !llmSessions.find(s => s.id === llmActiveSessionId)) { - llmActiveSessionId = ''; - localStorage.setItem('llmActiveSessionId', ''); - } -} - -// ── 전체 내보내기 ────────────────────────────────────── -function llmExportAll() { - if (llmSessions.length === 0) { - alert('내보낼 대화가 없습니다.'); - return; - } - const text = llmSessions.map(s => { - const header = `=== ${s.title} (${new Date(s.updatedAt).toLocaleString('ko-KR')}) ===`; - const msgs = s.messages.map(m => `[${m.role}] ${m.content}`).join('\n\n'); - return `${header}\n\n${msgs}`; - }).join('\n\n' + '='.repeat(50) + '\n\n'); - - const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `llm-chat-${new Date().toISOString().slice(0, 10)}.txt`; - a.click(); - URL.revokeObjectURL(url); -} - -/* ═══════════════════════════════════════════════════════ - 14 RAG 관리 (KB Admin) - ══════════════════════════════════════════════════════ */ - -let kbToken = sessionStorage.getItem('kbToken') || ''; -let kbExpiresAt = sessionStorage.getItem('kbExpiresAt') || ''; -let kbCollections = []; -let kbPollTimer = null; - -function kbHeaders(extra) { - const h = { ...(extra || {}) }; - if (kbToken) h['X-Kb-Token'] = kbToken; - return h; -} - -async function kbFetch(method, path, body, opt) { - const init = { method, headers: kbHeaders({ 'Content-Type': 'application/json' }), ...(opt || {}) }; - if (body !== undefined && body !== null) init.body = JSON.stringify(body); - const res = await fetch(path, init); - let data = null; - try { data = await res.json(); } catch { /* ignore */ } - return { ok: res.ok, status: res.status, data }; -} - -// ── 탭 클릭 핸들러 (API 호출 없음, 세션 검증만) ─────── -document.querySelectorAll('[data-tab="kbadmin"]').forEach(item => { - item.addEventListener('click', async () => { - if (kbToken) { - const r = await kbFetch('GET', '/api/kb/auth/status'); - if (r.ok && r.data && r.data.valid) { - kbShowMain(); - } else { - kbShowLogin('세션이 만료되었습니다. 다시 로그인하세요.'); - } - } else { - kbShowLogin(''); - } - }); -}); - -function kbShowLogin(msg) { - kbToken = ''; kbExpiresAt = ''; - sessionStorage.removeItem('kbToken'); sessionStorage.removeItem('kbExpiresAt'); - document.getElementById('kb-login-card').classList.remove('hidden'); - document.getElementById('kb-main').classList.add('hidden'); - const m = document.getElementById('kb-login-msg'); - if (m) m.textContent = msg || ''; - kbStopPoll(); -} - -function kbShowMain() { - document.getElementById('kb-login-card').classList.add('hidden'); - document.getElementById('kb-main').classList.remove('hidden'); - kbUpdateSessionInfo(); - kbLoadCollections().then(() => kbRefresh()); - kbStartPoll(); -} - -function kbUpdateSessionInfo() { - const el = document.getElementById('kb-session-info'); - if (!el) return; - if (kbExpiresAt) { - const t = new Date(kbExpiresAt); - el.textContent = `세션 만료: ${t.toLocaleTimeString('ko-KR')}`; - } else { - el.textContent = '세션: --:--'; - } -} - -async function kbLogin() { - const pw = document.getElementById('kb-pw').value; - const msg = document.getElementById('kb-login-msg'); - if (!pw) { msg.textContent = '비밀번호를 입력하세요.'; return; } - msg.textContent = '로그인 중...'; - const r = await fetch('/api/kb/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: pw }) - }); - const data = await r.json().catch(() => ({})); - if (!r.ok || !data.success) { - msg.textContent = '❌ ' + (data.error || '로그인 실패'); - return; - } - kbToken = data.token; - kbExpiresAt = data.expiresAt; - sessionStorage.setItem('kbToken', kbToken); - sessionStorage.setItem('kbExpiresAt', kbExpiresAt || ''); - document.getElementById('kb-pw').value = ''; - msg.textContent = ''; - kbShowMain(); -} - -async function kbLogout() { - if (kbToken) await kbFetch('POST', '/api/kb/auth/logout'); - kbShowLogin('로그아웃되었습니다.'); -} - -// ── 컬렉션 ───────────────────────────────────────────── -async function kbLoadCollections() { - const r = await kbFetch('GET', '/api/kb/collections'); - if (!r.ok || !r.data || !r.data.success) return; - kbCollections = r.data.items || []; - const fSel = document.getElementById('kb-f-coll'); - const uSel = document.getElementById('kb-up-coll'); - fSel.innerHTML = '' + - kbCollections.map(c => ``).join(''); - uSel.innerHTML = '' + - kbCollections.map(c => ``).join(''); -} - -// ── 목록 ─────────────────────────────────────────────── -async function kbRefresh() { - const coll = document.getElementById('kb-f-coll').value; - const status = document.getElementById('kb-f-status').value; - const q = document.getElementById('kb-f-q').value.trim(); - const qs = new URLSearchParams(); - if (coll) qs.set('collection', coll); - if (status) qs.set('status', status); - if (q) qs.set('q', q); - qs.set('pageSize', '200'); - - const r = await kbFetch('GET', '/api/kb/documents?' + qs.toString()); - if (!r.ok || !r.data || !r.data.success) { - document.getElementById('kb-doc-table').innerHTML = '
조회 실패
'; - return; - } - kbRenderDocs(r.data.items, r.data.total); -} - -function kbRenderDocs(items, total) { - const stats = document.getElementById('kb-doc-stats'); - stats.textContent = `총 ${total}건`; - const tbl = document.getElementById('kb-doc-table'); - if (!items || items.length === 0) { - tbl.innerHTML = '
문서 없음
'; - return; - } - const collMap = Object.fromEntries(kbCollections.map(c => [c.key, c.name])); - const rows = items.map(d => { - const tags = (d.tags || []).map(t => `${t}`).join(' '); - const dt = d.uploadedAt ? new Date(d.uploadedAt).toLocaleString('ko-KR') : ''; - const size = d.fileSize ? kbFmtSize(d.fileSize) : ''; - return ` - ${kbShortId(d.id)} - ${kbEscape(d.title)} - ${collMap[d.collection] || d.collection} - ${tags} - ${size} - ${d.status}${d.errorMessage ? `
${kbEscape(d.errorMessage.slice(0,60))}…
`:''} - ${d.chunkCount || 0} - ${dt} - - - ${(d.chunkCount || 0) > 0 ? `` : ''} - - - - - `; - }).join(''); - tbl.innerHTML = ` - - - ${rows}
ID제목컬렉션태그크기상태청크업로드액션
`; -} - -function kbShortId(id) { return (id || '').replace(/-/g, '').slice(0, 8); } -function kbEscape(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } -function kbFmtSize(n) { - if (n < 1024) return n + 'B'; - if (n < 1024 * 1024) return (n / 1024).toFixed(1) + 'K'; - if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + 'M'; - return (n / 1024 / 1024 / 1024).toFixed(2) + 'G'; -} - -// ── 업로드 모달 ──────────────────────────────────────── -function kbUploadOpen() { - document.getElementById('kb-up-msg').textContent = ''; - document.getElementById('kb-up-title').value = ''; - document.getElementById('kb-up-tags').value = ''; - document.getElementById('kb-up-file').value = ''; - document.getElementById('kb-upload-modal').classList.remove('hidden'); -} -function kbUploadClose() { - document.getElementById('kb-upload-modal').classList.add('hidden'); -} -async function kbUploadSubmit() { - const coll = document.getElementById('kb-up-coll').value; - const title = document.getElementById('kb-up-title').value.trim(); - const tags = document.getElementById('kb-up-tags').value.trim(); - const fileInput = document.getElementById('kb-up-file'); - const msg = document.getElementById('kb-up-msg'); - if (!coll) { msg.textContent = '❌ 컬렉션을 선택하세요.'; return; } - if (!fileInput.files || fileInput.files.length === 0) { msg.textContent = '❌ 파일을 선택하세요.'; return; } - const fd = new FormData(); - fd.append('file', fileInput.files[0]); - fd.append('collectionKey', coll); - if (title) fd.append('title', title); - if (tags) fd.append('tags', tags); - msg.textContent = '업로드 중...'; - const r = await fetch('/api/kb/upload', { method: 'POST', headers: kbHeaders(), body: fd }); - const data = await r.json().catch(() => ({})); - if (!r.ok || !data.success) { - msg.textContent = '❌ ' + (data.error || ('HTTP ' + r.status)); - return; - } - msg.textContent = '✅ 업로드 완료. 인덱싱 진행 중...'; - setTimeout(() => kbUploadClose(), 600); - kbRefresh(); -} - -// ── 청크 미리보기 ───────────────────────────────────── -async function kbShowChunks(id, title) { - const modal = document.getElementById('kb-chunk-modal'); - const titleEl = document.getElementById('kb-chunk-title'); - const body = document.getElementById('kb-chunk-body'); - titleEl.textContent = `🔍 청크 미리보기 — ${title || ''}`; - body.innerHTML = '
불러오는 중...
'; - modal.classList.remove('hidden'); - - const r = await kbFetch('GET', '/api/kb/documents/' + id + '/chunks?limit=200'); - if (!r.ok || !r.data || !r.data.success) { - body.innerHTML = '
조회 실패: ' + ((r.data && r.data.error) || r.status) + '
'; - return; - } - kbRenderChunks(r.data.chunks || []); -} - -function kbChunkCloseModal() { - document.getElementById('kb-chunk-modal').classList.add('hidden'); -} - -function kbRenderChunks(chunks) { - const body = document.getElementById('kb-chunk-body'); - if (!chunks.length) { - body.innerHTML = '
청크 없음
'; - return; - } - body.innerHTML = `
총 ${chunks.length}개 청크
` + - chunks.map((c, i) => { - const kind = c.chunk_kind || 'text'; - const loc = c.locator - || [c.sheet, c.page ? 'p' + c.page : '', c.heading_path].filter(Boolean).join(' · '); - const text = c.text || ''; - const preview = text.length > 240 ? text.slice(0, 240) + '…' : text; - return `
-
- ${kbEscape(kind)} - ${kbEscape(loc) || '#' + (i + 1)} - ${text.length}자 -
-
${kbEscape(preview)}
-
${kbEscape(text)}
-
`; - }).join(''); -} - -// ── 액션 ─────────────────────────────────────────────── -function kbDownload(id) { - window.open('/api/kb/download/' + id, '_blank'); -} - -async function kbReindex(id) { - if (!confirm('재인덱싱하시겠습니까? (Qdrant 기존 청크 삭제 후 다시 처리)')) return; - const r = await kbFetch('POST', '/api/kb/documents/' + id + '/reindex'); - if (!r.ok) alert('실패: ' + (r.data && r.data.error ? r.data.error : r.status)); - kbRefresh(); -} - -async function kbDisable(id) { - if (!confirm('이 문서를 비활성화하시겠습니까?')) return; - const r = await kbFetch('POST', '/api/kb/documents/' + id + '/disable'); - if (!r.ok) alert('실패'); - kbRefresh(); -} - -async function kbDelete(id, title) { - if (!confirm(`삭제하시겠습니까?\n${title}\n(Qdrant 청크와 원본 파일도 함께 삭제됩니다)`)) return; - const r = await kbFetch('DELETE', '/api/kb/documents/' + id); - if (!r.ok) alert('실패'); - kbRefresh(); -} - -async function kbBulkDisable() { - const title = prompt('일괄 비활성화할 제목을 정확히 입력하세요:'); - if (!title) return; - const r = await kbFetch('POST', '/api/kb/documents/bulk-disable', { title }); - if (r.ok && r.data && r.data.success) alert(`${r.data.affected}건 비활성화 완료`); - else alert('실패'); - kbRefresh(); -} - -async function kbPurgeDisabled() { - const ds = prompt('비활성화 후 며칠 지난 문서를 영구삭제할까요? (공백이면 모든 disabled 삭제)', '90'); - let body = {}; - if (ds && ds.trim()) { - const n = parseInt(ds, 10); - if (isNaN(n) || n < 0) { alert('숫자를 입력하세요.'); return; } - body.olderThanDays = n; - } - if (!confirm('정말 영구삭제하시겠습니까? (되돌릴 수 없습니다)')) return; - const r = await kbFetch('POST', '/api/kb/documents/purge-disabled', body); - if (r.ok && r.data && r.data.success) alert(`${r.data.deleted}건 영구삭제 완료`); - else alert('실패'); - kbRefresh(); -} - -// ── 비밀번호 변경 ────────────────────────────────────── -function kbChangePwOpen() { - document.getElementById('kb-pw-old').value = ''; - document.getElementById('kb-pw-new').value = ''; - document.getElementById('kb-pw-msg').textContent = ''; - document.getElementById('kb-pw-modal').classList.remove('hidden'); -} -function kbChangePwClose() { - document.getElementById('kb-pw-modal').classList.add('hidden'); -} -async function kbChangePwSubmit() { - const oldPw = document.getElementById('kb-pw-old').value; - const newPw = document.getElementById('kb-pw-new').value; - const msg = document.getElementById('kb-pw-msg'); - if (!oldPw || !newPw) { msg.textContent = '❌ 비밀번호를 입력하세요.'; return; } - if (newPw.length < 6) { msg.textContent = '❌ 새 비밀번호는 6자 이상.'; return; } - const r = await kbFetch('POST', '/api/kb/auth/change-password', { oldPassword: oldPw, newPassword: newPw }); - if (r.ok && r.data && r.data.success) { - msg.textContent = '✅ 변경 완료. 다시 로그인해 주세요.'; - setTimeout(() => { kbChangePwClose(); kbLogout(); }, 800); - } else { - msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : '변경 실패'); - } -} - -// ── 진행률 폴링 (활성 ingest가 있으면 1초마다 새로고침) ─ -function kbStartPoll() { - kbStopPoll(); - kbPollTimer = setInterval(async () => { - if (!kbToken) return; - if (document.getElementById('pane-kbadmin').classList.contains('active') === false) return; - const r = await kbFetch('GET', '/api/kb/documents?status=parsing&pageSize=1'); - const r2 = await kbFetch('GET', '/api/kb/documents?status=embedding&pageSize=1'); - const r3 = await kbFetch('GET', '/api/kb/documents?status=pending&pageSize=1'); - const active = [r, r2, r3].some(x => x.ok && x.data && x.data.total > 0); - if (active) kbRefresh(); - }, 1500); -} -function kbStopPoll() { - if (kbPollTimer) { clearInterval(kbPollTimer); kbPollTimer = null; } -} - -// ── Field Instrument Inference ────────────────────────────── -function kbInferDownload(path) { - const encoded = encodeURIComponent(path); - window.open('/api/kb/instruments/download?path=' + encoded, '_blank'); -} - -async function kbInferStart() { - const btn = document.getElementById('kb-infer-btn'); - const msg = document.getElementById('kb-infer-msg'); - const result = document.getElementById('kb-infer-result'); - const useLlm = document.getElementById('kb-infer-llm').checked; - - btn.disabled = true; - btn.textContent = '⏳ 생성 중...'; - msg.textContent = '계기 유추 및 Excel 생성 중 (수십 초 소요)'; - result.classList.add('hidden'); - - try { - const r = await kbFetch('POST', '/api/kb/instruments/infer', { - useLlm, - seedDocId: '' - }); - - if (!r.ok || !r.data || !r.data.success) { - msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : 'HTTP ' + r.status); - return; - } - - const d = r.data; - msg.textContent = '✅ 생성 완료. Excel 다운로드 후 수동으로 KB에 업로드하세요.'; - result.classList.remove('hidden'); - result.innerHTML = ` -
- 계기: ${d.instrumentCount}개 - 동력기기: ${d.powerEquipmentCount || 0}개 - 미매칭: ${d.unmatchedCount}개 -
-

${d.message || ''}

-
- -
- `; - } catch (e) { - msg.textContent = '❌ ' + e.message; - } finally { - btn.disabled = false; - btn.textContent = '⚡ 초안 생성'; - } -} - -/* ───────────────────────────────────────────────────────────── - 15 OPC UA Write -───────────────────────────────────────────────────────────── */ -async function wrWriteTag() { - const nodeId = document.getElementById('wr-nodeid').value.trim(); - const value = parseFloat(document.getElementById('wr-value').value); - if (!nodeId) return log('wr-log', [{ c: 'err', t: '❌ Node ID를 입력하세요.' }]); - if (isNaN(value)) return log('wr-log', [{ c: 'err', t: '❌ 유효한 숫자를 입력하세요.' }]); - - try { - const d = await api('POST', '/api/points/write', { nodeId, value }); - log('wr-log', [ - { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Write ' + esc(nodeId) + ' = ' + value + ' → ' + esc(d.status) }, - ...(d.error ? [{ c: 'err', t: ' Error: ' + esc(d.error) }] : []) - ]); - } catch (e) { - log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]); - } -} - -async function wrSetMode() { - const nodeId = document.getElementById('wr-mode-nodeid').value.trim(); - const mode = document.getElementById('wr-mode').value; - if (!nodeId) return log('wr-log', [{ c: 'err', t: '❌ Node ID를 입력하세요.' }]); - - try { - const d = await api('POST', '/api/points/mode', { nodeId, mode }); - log('wr-log', [ - { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Mode ' + esc(nodeId) + ' → ' + mode + ' (enum=' + d.enumValue + ') ' + esc(d.status) }, - ...(d.error ? [{ c: 'err', t: ' Error: ' + esc(d.error) }] : []) - ]); - } catch (e) { - log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]); - } -} - -async function wrControlOp() { - const tagName = document.getElementById('wr-ctrl-tag').value.trim(); - const opValue = parseFloat(document.getElementById('wr-ctrl-op').value); - const restoreAuto = document.getElementById('wr-ctrl-restore').checked; - if (!tagName) return log('wr-log', [{ c: 'err', t: '❌ 태그명을 입력하세요.' }]); - if (isNaN(opValue)) return log('wr-log', [{ c: 'err', t: '❌ 유효한 OP 값을 입력하세요.' }]); - - try { - const d = await api('POST', '/api/points/control', { tagName, opValue, restoreAuto }); - const lines = []; - if (d.steps) { - for (const s of d.steps) { - lines.push({ c: s.success ? 'ok' : 'err', t: (s.success ? '✅ ' : '❌ ') + s.step + ': ' + esc(s.nodeId) + ' → ' + esc(s.status) + (s.value !== undefined ? ' (value=' + s.value + ')' : '') }); - if (s.error) lines.push({ c: 'err', t: ' Error: ' + esc(s.error) }); - } - } - lines.unshift({ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + '통합 제어 ' + esc(tagName) + ' OP=' + opValue + (d.error ? ' — ' + esc(d.error) : '') }); - log('wr-log', lines); - } catch (e) { - log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]); - } -} - -async function wrReadTag() { - const nodeId = document.getElementById('wr-read-nodeid').value.trim(); - if (!nodeId) return; - - try { - const d = await api('POST', '/api/points/read', { nodeId }); - const box = document.getElementById('wr-read-result'); - if (d.success) { - box.innerHTML = ` -
Node ID${esc(d.nodeId)}
-
Value${esc(d.value)}
-
Status${esc(d.statusCode)}
-
Timestamp${d.timestamp ? new Date(d.timestamp).toLocaleString('ko-KR') : '-'}
- `; - } else { - box.innerHTML = `
Error${esc(d.error || 'Unknown')}
`; - } - } catch (e) { - document.getElementById('wr-read-result').innerHTML = `
Error${esc(e.message)}
`; - } -} +/* ════════════════════════════════════════════════════════════════ + app.js — ExperionCrawler Web UI 공용 초기화 + 더 이상 모놀리식이 아닙니다. 모든 탭별 로직은 js/.js 로 분산. + core.js: 공용 유틸 + 탭 라우터 + docs.js: 문서 탐색기 + cert.js / conn.js / crawl.js / db.js / nm-dash.js / pb.js / + hist.js / opcsvr.js / t2s.js / fast.js / pid.js / evt.js / + llmchat.js / kbadmin.js / write.js + 각 탭 파일에서 paneInit.을 직접 등록합니다. + ════════════════════════════════════════════════════════════════ */ diff --git a/src/Web/wwwroot/js/cert.js b/src/Web/wwwroot/js/cert.js new file mode 100644 index 0000000..81c0123 --- /dev/null +++ b/src/Web/wwwroot/js/cert.js @@ -0,0 +1,53 @@ +/* ───────────────────────────────────────────────────────────── + 01 인증서 관리 +───────────────────────────────────────────────────────────── */ +async function certCreate() { + const clientHostName = document.getElementById('c-host').value.trim(); + const subjectAltNames = document.getElementById('c-san').value + .split(',').map(s => s.trim()).filter(Boolean); + const pfxPassword = document.getElementById('c-pw').value; + + setGlobal('busy', '인증서 생성 중'); + try { + const d = await api('POST', '/api/certificate/create', { + clientHostName, subjectAltNames, pfxPassword + }); + log('cert-log', [ + { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }, + ...(d.thumbPrint ? [{ c: 'inf', t: ' Thumbprint : ' + d.thumbPrint }] : []) + ]); + setGlobal(d.success ? 'ok' : 'err', d.success ? '인증서 완료' : '오류'); + if (d.success) { + document.getElementById('cert-dot').classList.add('on'); + await certStatus(); + } + } catch (e) { + log('cert-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } +} + +async function certStatus() { + const clientHostName = document.getElementById('c-host').value.trim() || 'dbsvr'; + try { + const d = await api('GET', `/api/certificate/status?clientHostName=${encodeURIComponent(clientHostName)}`); + const box = document.getElementById('cert-disp'); + if (d.exists) { + box.innerHTML = ` +
상태✅ 인증서 있음
+
Subject${esc(d.subjectName)}
+
만료일${d.notAfter ? new Date(d.notAfter).toLocaleDateString('ko-KR') : '-'}
+
Thumbprint${d.thumbPrint ? d.thumbPrint.slice(0,20)+'…' : '-'}
+
파일 경로${esc(d.filePath)}
+ `; + document.getElementById('cert-dot').classList.add('on'); + setGlobal('ok', '인증서 확인됨'); + } else { + box.innerHTML = ` +
상태❌ 인증서 없음
+
경로${esc(d.filePath)}
+ `; + setGlobal('', 'READY'); + } + } catch {} +} diff --git a/src/Web/wwwroot/js/conn.js b/src/Web/wwwroot/js/conn.js new file mode 100644 index 0000000..7183088 --- /dev/null +++ b/src/Web/wwwroot/js/conn.js @@ -0,0 +1,156 @@ +/* ───────────────────────────────────────────────────────────── + 02 서버 접속 테스트 +───────────────────────────────────────────────────────────── */ +function getServerCfg(p) { + return { + serverHostName: document.getElementById(`${p}-server`).value.trim(), + port: parseInt(document.getElementById(`${p}-port`).value) || 4840, + clientHostName: document.getElementById(`${p}-client`).value.trim(), + userName: document.getElementById(`${p}-user`).value.trim(), + password: document.getElementById(`${p}-pass`).value + }; +} + +async function connTest() { + setGlobal('busy', '접속 중'); + try { + const d = await api('POST', '/api/connection/test', getServerCfg('x')); + log('conn-log', [ + { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }, + ...(d.sessionId ? [{ c: 'inf', t: ' SessionID : ' + d.sessionId }] : []), + ...(d.policyUri ? [{ c: 'inf', t: ' Policy : ' + d.policyUri.split('/').pop() }] : []) + ]); + setGlobal(d.success ? 'ok' : 'err', d.success ? '연결 성공' : '연결 실패'); + } catch (e) { + log('conn-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } +} + +async function connRead() { + const nodeId = document.getElementById('x-node').value.trim(); + if (!nodeId) return; + setGlobal('busy', '태그 읽기'); + try { + const d = await api('POST', '/api/connection/read', { + serverConfig: getServerCfg('x'), nodeId + }); + const box = document.getElementById('tag-box'); + box.classList.remove('hidden'); + if (d.success) { + box.innerHTML = ` +
✅ 읽기 성공
+
NodeID : ${esc(d.nodeId)}
+
Value : ${esc(d.value ?? 'null')}
+
Status : ${esc(d.statusCode)}
+
Time : ${d.timestamp ? new Date(d.timestamp).toLocaleString('ko-KR') : '-'}
+ `; + setGlobal('ok', '읽기 완료'); + } else { + box.innerHTML = `
❌ ${esc(d.error || '읽기 실패')}
`; + setGlobal('err', '읽기 실패'); + } + } catch (e) { + setGlobal('err', '오류'); + } +} + +async function llmLoadConfig() { + const statusEl = document.getElementById('llm-status'); + const input = document.getElementById('llm-model'); + statusEl.innerHTML = '불러오는 중...'; + try { + const d = await api('GET', '/api/llm/config'); + if (d.success) { + input.value = d.vllmModel || ''; + statusEl.innerHTML = `✓ 현재 모델: ${esc(d.vllmModel)}`; + } else { + statusEl.innerHTML = `✗ ${esc(d.error || '조회 실패')}`; + } + } catch (e) { + statusEl.innerHTML = `✗ ${esc(e.message)}`; + } +} + +async function llmSaveModelConfig() { + const statusEl = document.getElementById('llm-status'); + const model = document.getElementById('llm-model').value.trim(); + if (!model) { + statusEl.innerHTML = '모델명을 입력하세요'; + return; + } + statusEl.innerHTML = '저장 중...'; + try { + const d = await api('POST', '/api/llm/config', { vllm_model: model }); + if (d.success) { + statusEl.innerHTML = `✓ 모델 변경 완료: ${esc(d.vllmModel)} (다음 LLM 요청 시 반영)`; + } else { + statusEl.innerHTML = `✗ ${esc(d.error || '저장 실패')}`; + } + } catch (e) { + statusEl.innerHTML = `✗ ${esc(e.message)}`; + } +} + +async function connBrowse() { + const wrap = document.getElementById('browse-wrap'); + const logEl = document.getElementById('conn-log'); + setGlobal('busy', '노드 탐색'); + + // 로그 초기화 + logEl.classList.remove('hidden'); + logEl.innerHTML = '
[진행] 노드 탐색 시작...
'; + + try { + logEl.innerHTML += '
[진행] 서버: ' + getServerCfg('x').serverHostName + ':' + getServerCfg('x').port + '
'; + + const d = await api('POST', '/api/connection/browse', { + serverConfig: getServerCfg('x'), startNodeId: null + }); + + logEl.innerHTML += '
[진행] 응답 수신: success=' + d.success + ', nodes=' + (d.nodes ? d.nodes.length : 0) + '
'; + + if (d.success && d.nodes?.length) { + wrap.classList.remove('hidden'); + logEl.innerHTML += '
✅ [성공] 탐색 성공: ' + d.nodes.length + '개 노드
'; + + // 노드 이름 확인 로그 + const emptyNames = d.nodes.filter(n => !n.displayName || n.displayName.trim() === ''); + if (emptyNames.length > 0) { + logEl.innerHTML += '
⚠️ 노드 이름이 비어진 항목: ' + emptyNames.length + '개 (NodeId만 복사됨)
'; + } + + // 노드 목록 생성 (displayName이 없으면 NodeId를 노출 이름으로 사용) + const nodeListHtml = d.nodes.map(n => { + const displayName = n.displayName || '이름 없음'; + const nodeId = n.nodeId; + // 중복된 NodeId 건너뛰기 + const nodeHtml = ` +
+ ${n.nodeClass === 'Object' ? '📂' : '📌'} + ${esc(displayName)} + [${n.nodeClass}] + 📄 ${esc(nodeId)} +
+ `; + return nodeHtml; + }).join(''); + + wrap.innerHTML = + `
🎉 탐색 성공: ${d.nodes.length}개 노드 발견
` + + nodeListHtml; + setGlobal('ok', '탐색 완료'); + } else { + const errorMsg = d.error || '알 수 없는 에러'; + logEl.innerHTML += '
❌ [실패] 탐색 실패: ' + errorMsg + '
'; + wrap.classList.remove('hidden'); + wrap.innerHTML = `
❌ ${errorMsg}
`; + setGlobal('err', '탐색 실패'); + } + } catch (e) { + logEl.innerHTML += '
❌ [오류] 요청 실패: ' + e.message + '
'; + wrap.classList.remove('hidden'); + wrap.innerHTML = `
❌ ${e.message}
`; + setGlobal('err', '오류'); + } +} diff --git a/src/Web/wwwroot/js/core.js b/src/Web/wwwroot/js/core.js new file mode 100644 index 0000000..6cca56e --- /dev/null +++ b/src/Web/wwwroot/js/core.js @@ -0,0 +1,219 @@ +/* ════════════════════════════════════════════════════════════════ + core.js — ExperionCrawler Web UI Shared Core + - Global utilities: esc, setGlobal, log, api, fmt* + - Tab router: activateTab, paneInit map + - Shared types/dt helpers + ════════════════════════════════════════════════════════════════ */ + +/* ── Helpers ────────────────────────────────────────────────── */ +function esc(s) { + return String(s ?? '') + .replace(/&/g,'&').replace(//g,'>'); +} + +function setGlobal(state, text) { + document.getElementById('g-dot').className = `dot ${state}`; + document.getElementById('g-txt').textContent = text.toUpperCase(); +} + +function log(id, lines) { + const el = document.getElementById(id); + if (!el) return; + el.classList.remove('hidden'); + el.innerHTML = lines.map(l => `
${esc(l.t)}
`).join(''); + el.scrollTop = el.scrollHeight; +} + +async function api(method, path, body) { + const opt = { method, headers: { 'Content-Type': 'application/json' } }; + if (body) opt.body = JSON.stringify(body); + const res = await fetch(path, opt); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + return res.json(); +} + +/* ── Formatters ─────────────────────────────────────────────── */ +function fmtTs(v) { + if (v == null || v === '') return '-'; + const d = new Date(v); + if (isNaN(d.getTime())) return String(v); + const KST = new Date(d.getTime() + 9 * 3600000); + return KST.toISOString().replace('T',' ').replace('Z','').slice(0,21); +} + +function fmtVal(v) { + if (v == null) return '-'; + if (typeof v === 'number' && isFinite(v)) + return Number.isInteger(v) ? String(v) : v.toFixed(2); + return String(v); +} + +function parseEnumPv(v) { + if (!v || typeof v !== 'string') return v; + const m = v.match(/\{\d+\s*\|\s*([^|]+?)\s*\|\s*\}/); + return m ? m[1].trim() : v; +} + +/* ── Tab Router ─────────────────────────────────────────────── */ +const paneInit = {}; + +async function activateTab(tab) { + const el = document.getElementById(`pane-${tab}`); + if (!el) return; + + // lazy-load pane HTML if not loaded + if (el.dataset.loaded !== '1' && el.dataset.src) { + try { + const res = await fetch(el.dataset.src); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + el.innerHTML = await res.text(); + el.dataset.loaded = '1'; + } catch (e) { + el.innerHTML = `
+

⚠️ 파셜 로드 실패: ${el.dataset.src}

+

${esc(e.message)}

+
`; + el.dataset.loaded = 'err'; + } + } + + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + document.querySelectorAll('.pane').forEach(p => p.classList.remove('active')); + document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active'); + el.classList.add('active'); + + paneInit[tab]?.(); +} + +/* ── Date Time Picker (공용 — hist/evt 탭에서 사용) ────────── */ +const _dtp = { target: null, year: 0, month: 0, + selYear: null, selMonth: null, selDay: null, + selHour: 0, selMin: 0 }; + +const _DTP_DAYS = ['일','월','화','수','목','금','토']; + +function dtOpen(target) { + _dtp.target = target; + const hidden = document.getElementById(`hf-${target}`).value; + const d = hidden ? new Date(hidden) : new Date(); + _dtp.year = d.getFullYear(); + _dtp.month = d.getMonth(); + if (hidden && !isNaN(d)) { + _dtp.selYear = d.getFullYear(); _dtp.selMonth = d.getMonth(); + _dtp.selDay = d.getDate(); + _dtp.selHour = d.getHours(); _dtp.selMin = d.getMinutes(); + } else { + _dtp.selYear = null; _dtp.selMonth = null; _dtp.selDay = null; + _dtp.selHour = 0; _dtp.selMin = 0; + } + document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0'); + document.getElementById('dt-min').value = String(_dtp.selMin).padStart(2,'0'); + dtRenderCal(); + + const popup = document.getElementById('dt-popup'); + const display = document.getElementById(`dtp-${target}-display`); + const rect = display.getBoundingClientRect(); + popup.classList.remove('hidden'); + document.getElementById('dt-overlay').classList.remove('hidden'); + + const spaceBelow = window.innerHeight - rect.bottom; + if (spaceBelow < popup.offsetHeight + 8) { + popup.style.top = (rect.top - popup.offsetHeight - 4) + 'px'; + } else { + popup.style.top = (rect.bottom + 4) + 'px'; + } + popup.style.left = Math.min(rect.left, window.innerWidth - 296) + 'px'; +} + +function dtRenderCal() { + document.getElementById('dt-month-label').textContent = + `${_dtp.year}년 ${_dtp.month + 1}월`; + const first = new Date(_dtp.year, _dtp.month, 1).getDay(); + const daysInMon = new Date(_dtp.year, _dtp.month + 1, 0).getDate(); + const daysInPrev = new Date(_dtp.year, _dtp.month, 0).getDate(); + const today = new Date(); + let html = _DTP_DAYS.map(d => `
${d}
`).join(''); + + for (let i = first - 1; i >= 0; i--) + html += `
${daysInPrev - i}
`; + + for (let d = 1; d <= daysInMon; d++) { + let cls = 'dt-day'; + if (_dtp.year === today.getFullYear() && _dtp.month === today.getMonth() && d === today.getDate()) + cls += ' today'; + if (_dtp.selYear === _dtp.year && _dtp.selMonth === _dtp.month && _dtp.selDay === d) + cls += ' selected'; + html += `
${d}
`; + } + const trailing = (first + daysInMon) % 7; + for (let d = 1; d <= (trailing ? 7 - trailing : 0); d++) + html += `
${d}
`; + + document.getElementById('dt-cal-grid').innerHTML = html; +} + +function dtSelectDay(day) { + _dtp.selYear = _dtp.year; _dtp.selMonth = _dtp.month; _dtp.selDay = day; + dtRenderCal(); +} +function dtPrevMonth() { + if (--_dtp.month < 0) { _dtp.month = 11; _dtp.year--; } + dtRenderCal(); +} +function dtNextMonth() { + if (++_dtp.month > 11) { _dtp.month = 0; _dtp.year++; } + dtRenderCal(); +} +function dtAdjTime(part, delta) { + if (part === 'h') { + _dtp.selHour = ((_dtp.selHour + delta) + 24) % 24; + document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0'); + } else { + _dtp.selMin = ((_dtp.selMin + delta) + 60) % 60; + document.getElementById('dt-min').value = String(_dtp.selMin).padStart(2,'0'); + } +} +function dtClampTime(part, el) { + const max = part === 'h' ? 23 : 59; + let v = parseInt(el.value); + if (isNaN(v) || v < 0) v = 0; + if (v > max) v = max; + el.value = String(v).padStart(2,'0'); + if (part === 'h') _dtp.selHour = v; else _dtp.selMin = v; +} +function dtConfirm() { + if (_dtp.selDay === null) { alert('날짜를 선택하세요.'); return; } + _dtp.selHour = parseInt(document.getElementById('dt-hour').value) || 0; + _dtp.selMin = parseInt(document.getElementById('dt-min').value) || 0; + const p = n => String(n).padStart(2,'0'); + const val = `${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)}T${p(_dtp.selHour)}:${p(_dtp.selMin)}`; + document.getElementById(`hf-${_dtp.target}`).value = val; + document.getElementById(`dtp-${_dtp.target}-display`).textContent = + `${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)} ${p(_dtp.selHour)}:${p(_dtp.selMin)}`; + dtClose(); +} +function dtClear() { dtClearField(_dtp.target); dtClose(); } +function dtClearField(target) { + if (!target) return; + document.getElementById(`hf-${target}`).value = ''; + document.getElementById(`dtp-${target}-display`).textContent = '— 선택 안 함 —'; +} +function dtCancel() { dtClose(); } +function dtClose() { + document.getElementById('dt-popup').classList.add('hidden'); + document.getElementById('dt-overlay').classList.add('hidden'); + _dtp.target = null; +} + +/* ── DOMContentLoaded: bind tab nav, lazy-load on click ──────── */ +document.addEventListener('DOMContentLoaded', async () => { + document.querySelectorAll('.nav-item').forEach(item => { + item.addEventListener('click', () => activateTab(item.dataset.tab)); + }); + + // Load initial active tab (cert) + run its page-load init + await activateTab('cert'); + if (typeof certStatus === 'function') { + certStatus(); + } +}); diff --git a/src/Web/wwwroot/js/crawl.js b/src/Web/wwwroot/js/crawl.js new file mode 100644 index 0000000..bd9a875 --- /dev/null +++ b/src/Web/wwwroot/js/crawl.js @@ -0,0 +1,125 @@ +/* ───────────────────────────────────────────────────────────── + 03 데이터 크롤링 +───────────────────────────────────────────────────────────── */ +async function crawlStart() { + const nodeIds = document.getElementById('w-nodes').value + .trim().split('\n').map(s => s.trim()).filter(Boolean); + if (!nodeIds.length) { alert('노드 ID를 입력하세요.'); return; } + + const cfg = { + serverHostName: document.getElementById('w-server').value.trim(), + port: parseInt(document.getElementById('w-port').value) || 4840, + clientHostName: document.getElementById('w-client').value.trim(), + userName: document.getElementById('w-user').value.trim(), + password: document.getElementById('w-pass').value + }; + const intervalSeconds = parseInt(document.getElementById('w-interval').value) || 1; + const durationSeconds = parseInt(document.getElementById('w-duration').value) || 30; + + const btn = document.getElementById('crawl-btn'); + btn.disabled = true; + btn.textContent = '⏳ 수집 중...'; + + const prog = document.getElementById('crawl-prog'); + prog.classList.remove('hidden'); + document.getElementById('crawl-bar').style.width = '0%'; + document.getElementById('crawl-ptxt').textContent = '서버 연결 중...'; + document.getElementById('crawl-cnt').textContent = '0 건'; + setGlobal('busy', '크롤링 중'); + + // 백엔드는 동기식이므로 프런트에서 타이머로 UI 진행 표시 + let fake = 0; + const ticker = setInterval(() => { + fake = Math.min(fake + (100 / durationSeconds) * .5, 94); + document.getElementById('crawl-bar').style.width = fake + '%'; + document.getElementById('crawl-ptxt').textContent = `수집 중... ${Math.round(fake)}%`; + }, 500); + + try { + const d = await api('POST', '/api/crawl/start', { + serverConfig: cfg, nodeIds, intervalSeconds, durationSeconds + }); + clearInterval(ticker); + document.getElementById('crawl-bar').style.width = '100%'; + document.getElementById('crawl-ptxt').textContent = '수집 완료'; + document.getElementById('crawl-cnt').textContent = d.totalRecords + ' 건'; + + log('crawl-log', [ + { c: 'ok', t: `✅ 크롤링 완료 — ${d.totalRecords}개 레코드` }, + { c: 'inf', t: ` Session : ${d.sessionId}` }, + { c: 'inf', t: ` CSV : ${d.csvPath}` }, + { c: 'mut', t: '--- 미리보기 ---' }, + ...(d.preview || []).map(r => ({ + c: 'val', + t: ` [${new Date(r.collectedAt).toLocaleTimeString('ko-KR')}] ${r.nodeId} = ${r.value} (${r.statusCode})` + })) + ]); + setGlobal('ok', '크롤링 완료'); + } catch (e) { + clearInterval(ticker); + log('crawl-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } finally { + btn.disabled = false; + btn.innerHTML = '📡 크롤링 시작'; + } +} + +/* ───────────────────────────────────────────────────────────── + 03-B 노드맵 수집 +───────────────────────────────────────────────────────────── */ +async function nodeMapCrawl() { + const maxDepth = parseInt(document.getElementById('nm-depth').value) || 10; + const cfg = { + serverHostName: document.getElementById('w-server').value.trim(), + port: parseInt(document.getElementById('w-port').value) || 4840, + clientHostName: document.getElementById('w-client').value.trim(), + userName: document.getElementById('w-user').value.trim(), + password: document.getElementById('w-pass').value + }; + + const btn = document.getElementById('nm-btn'); + btn.disabled = true; + btn.textContent = '⏳ 탐색 중...'; + + const prog = document.getElementById('nm-prog'); + prog.classList.remove('hidden'); + document.getElementById('nm-bar').style.width = '0%'; + document.getElementById('nm-ptxt').textContent = '서버 연결 중...'; + document.getElementById('nm-cnt').textContent = ''; + setGlobal('busy', '노드맵 수집 중'); + + // 완료 시점을 알 수 없으므로 완만하게 90%까지 증가 + let fake = 0; + const ticker = setInterval(() => { + if (fake < 90) { + fake = Math.min(fake + 0.25, 90); + document.getElementById('nm-bar').style.width = fake + '%'; + document.getElementById('nm-ptxt').textContent = `노드 탐색 중... ${Math.round(fake)}%`; + } + }, 500); + + try { + const d = await api('POST', '/api/crawl/nodemap', { serverConfig: cfg, maxDepth }); + clearInterval(ticker); + document.getElementById('nm-bar').style.width = '100%'; + document.getElementById('nm-ptxt').textContent = d.success ? '탐색 완료' : '탐색 실패'; + document.getElementById('nm-cnt').textContent = d.success ? d.totalCount.toLocaleString() + '개 노드' : ''; + + log('nm-log', d.success ? [ + { c: 'ok', t: `✅ 노드맵 수집 완료 — ${d.totalCount.toLocaleString()}개 노드` }, + { c: 'inf', t: ` 저장 경로 : ${d.csvPath}` }, + { c: 'mut', t: ` 04 DB 저장 탭에서 raw_node_map 테이블에 적재할 수 있습니다.` } + ] : [ + { c: 'err', t: `❌ ${d.error || '탐색 실패'}` } + ]); + setGlobal(d.success ? 'ok' : 'err', d.success ? `노드 ${d.totalCount}개` : '실패'); + } catch (e) { + clearInterval(ticker); + log('nm-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } finally { + btn.disabled = false; + btn.textContent = '🗺 전체 노드맵 수집'; + } +} diff --git a/src/Web/wwwroot/js/db.js b/src/Web/wwwroot/js/db.js new file mode 100644 index 0000000..a0885ab --- /dev/null +++ b/src/Web/wwwroot/js/db.js @@ -0,0 +1,102 @@ +/* ───────────────────────────────────────────────────────────── + 04 DB 저장 +───────────────────────────────────────────────────────────── */ +async function dbLoadFiles() { + try { + const d = await api('GET', '/api/database/files'); + const list = document.getElementById('file-list'); + if (d.files?.length) { + list.innerHTML = d.files.map(f => ` +
+ 📄${esc(f)} +
+ `).join(''); + } else { + list.innerHTML = 'CSV 파일이 없습니다'; + } + } catch (e) { alert('목록 로드 실패: ' + e.message); } +} + +function selectFile(name, el) { + document.querySelectorAll('.fitem').forEach(i => i.classList.remove('sel')); + el.classList.add('sel'); + document.getElementById('sel-csv').value = name; +} + +async function dbImport() { + const fileName = document.getElementById('sel-csv').value.trim(); + if (!fileName) { alert('파일을 선택하세요.'); return; } + // experion_ 으로 시작하면 일반 크롤 CSV, 그 외는 노드맵 CSV (서버명_timestamp.csv) + const serverHostName = fileName.startsWith('experion_') ? '' : fileName.split('_')[0]; + const truncate = document.querySelector('input[name="import-mode"]:checked').value === 'truncate'; + if (truncate && !confirm('기존 데이터를 모두 삭제하고 새로 저장합니다.\n계속하시겠습니까?')) return; + setGlobal('busy', 'DB 저장 중'); + try { + const d = await api('POST', '/api/database/import', { fileName, serverHostName, truncate }); + const isNodeMap = !!serverHostName; + const lines = [ + { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message } + ]; + if (d.success && isNodeMap) { + lines.push({ c: 'inf', t: ` raw_node_map : ${d.count}건` }); + lines.push({ c: 'inf', t: ` node_map_master: ${d.masterCount}건` }); + } else if (d.success) { + lines.push({ c: 'inf', t: ` 저장된 레코드 : ${d.count}건` }); + } + log('db-log', lines); + setGlobal(d.success ? 'ok' : 'err', d.success ? 'DB 저장 완료' : '저장 실패'); + if (d.success) await dbQuery(); + } catch (e) { + log('db-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } +} + +async function dbQuery() { + const limit = parseInt(document.getElementById('db-limit').value) || 100; + setGlobal('busy', 'DB 조회'); + try { + const d = await api('GET', `/api/database/records?limit=${limit}`); + + const stats = document.getElementById('db-stats'); + stats.classList.remove('hidden'); + stats.innerHTML = ` +
${d.total.toLocaleString()}
전체 레코드
+
${(d.records||[]).length}
현재 조회
+ `; + + const tbl = document.getElementById('db-table'); + tbl.classList.remove('hidden'); + if (d.records?.length) { + tbl.innerHTML = ` + + + + + + + + + + + + + ${d.records.map(r => ` + + + + + + + + + `).join('')} + +
IDNode IDValueStatus수집 시각Session
${r.id}${esc(r.nodeId)}${esc(r.value ?? 'null')}${esc(r.statusCode)}${new Date(r.collectedAt).toLocaleString('ko-KR')}${esc(r.sessionId ?? '-')}
+ `; + } else { + tbl.innerHTML = '
저장된 레코드가 없습니다.
'; + } + setGlobal('ok', `${d.total}건`); + } catch (e) { setGlobal('err', '조회 실패'); } +} diff --git a/src/Web/wwwroot/js/docs.js b/src/Web/wwwroot/js/docs.js index a0f7107..668dc19 100644 --- a/src/Web/wwwroot/js/docs.js +++ b/src/Web/wwwroot/js/docs.js @@ -16,6 +16,7 @@ const docsState = { }; /* ── 진입 ──────────────────────────────────────────────────── */ +paneInit.docs = docsInit; async function docsInit() { await docsLoadConfig(); if (!docsState.inited) { diff --git a/src/Web/wwwroot/js/evt.js b/src/Web/wwwroot/js/evt.js new file mode 100644 index 0000000..c3ef923 --- /dev/null +++ b/src/Web/wwwroot/js/evt.js @@ -0,0 +1,152 @@ +/* ───────────────────────────────────────────────────────────── + 12 이벤트 히스토리 +───────────────────────────────────────────────────────────── */ + +async function evtLoadTags() { + const statusEl = document.getElementById('evt-tag-status'); + statusEl.textContent = '⏳ 조회 중...'; + try { + const d = await api('GET', '/api/event-history/digital-tags'); + const tags = d.data || []; + const sel = document.getElementById('ef-tag'); + sel.innerHTML = '' + + tags.map(t => ``).join(''); + statusEl.textContent = `✅ ${tags.length}개`; + } catch (e) { + statusEl.textContent = `❌ ${e.message}`; + } +} + +async function evtQuery() { + const tag = document.getElementById('ef-tag').value; + const eventType = document.getElementById('ef-event-type').value; + const area = document.getElementById('ef-area').value.trim(); + const section = document.getElementById('ef-section').value.trim(); + const limit = document.getElementById('ef-limit').value || 500; + const fromRaw = document.getElementById('hf-evt-from').value; + const toRaw = document.getElementById('hf-evt-to').value; + + const params = new URLSearchParams(); + if (tag) params.set('tagName', tag); + if (eventType) params.set('eventType', eventType); + if (area) params.set('area', area); + if (section) params.set('section', section); + params.set('limit', limit); + if (fromRaw) params.set('from', new Date(fromRaw).toISOString()); + if (toRaw) params.set('to', new Date(toRaw).toISOString()); + + const infoEl = document.getElementById('evt-result-info'); + const tableEl = document.getElementById('evt-table'); + infoEl.textContent = '⏳ 조회 중...'; + infoEl.classList.remove('hidden'); + tableEl.classList.add('hidden'); + + try { + const d = await api('GET', `/api/event-history?${params}`); + if (!d.success) throw new Error(d.error || '조회 실패'); + infoEl.textContent = `총 ${d.count}건`; + tableEl.innerHTML = _evtBuildTable(d.data); + tableEl.classList.remove('hidden'); + } catch (e) { + infoEl.textContent = `❌ ${e.message}`; + } +} + +async function evtSummary() { + const area = document.getElementById('ef-area').value.trim(); + const section = document.getElementById('ef-section').value.trim(); + const fromRaw = document.getElementById('hf-evt-from').value; + const toRaw = document.getElementById('hf-evt-to').value; + + const params = new URLSearchParams(); + if (area) params.set('area', area); + if (section) params.set('section', section); + if (fromRaw) params.set('from', new Date(fromRaw).toISOString()); + if (toRaw) params.set('to', new Date(toRaw).toISOString()); + + const card = document.getElementById('evt-summary-card'); + const content = document.getElementById('evt-summary-content'); + content.textContent = '⏳ 집계 중...'; + card.classList.remove('hidden'); + + try { + const d = await api('GET', `/api/event-history/summary?${params}`); + if (!d.success) throw new Error(d.error || '조회 실패'); + content.innerHTML = _evtBuildSummary(d.data); + } catch (e) { + content.textContent = `❌ ${e.message}`; + } +} + +function _evtBadge(t) { + const cls = { TRIP:'trip', RUN:'run', ALARM:'alarm', NORMAL:'normal', CHANGE:'change' }[t] || 'change'; + return `${esc(t)}`; +} + +function _evtFmtTime(dt) { + if (!dt) return '—'; + return new Date(dt).toLocaleString('ko-KR', { + year:'numeric', month:'2-digit', day:'2-digit', + hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false + }); +} + +function _evtBuildTable(rows) { + if (!rows || !rows.length) + return '
데이터 없음
'; + const html = rows.map(r => ` + + ${_evtFmtTime(r.eventTime)} + ${esc(r.tagName)} + ${_evtBadge(r.eventType)} + ${esc(r.prevValue ?? '—')} + ${esc(r.currValue)} + ${r.area ? `${esc(r.area)}` : '—'} + ${r.section ? `${esc(r.section)}` : '—'} + ${r.durationSeconds != null ? r.durationSeconds + 's' : '—'} + `).join(''); + return ` + + + + + + + + + + + + ${html} +
시간태그명이벤트이전값현재값AreaSection지속(초)
`; +} + +function _evtBuildSummary(data) { + if (!data || !data.length) + return '
데이터 없음
'; + return `
${data.map(s => ` +
+
${esc(s.section)}
+
+
${_evtBadge('TRIP')} ${s.tripCount}
+
${_evtBadge('RUN')} ${s.runCount}
+
${_evtBadge('ALARM')} ${s.alarmCount}
+
${_evtBadge('CHANGE')} ${s.changeCount}
+
+
합계 ${s.totalEvents}
+
`).join('')}
`; +} + +function evtReset() { + document.getElementById('ef-tag').value = ''; + document.getElementById('ef-event-type').value = ''; + document.getElementById('ef-area').value = ''; + document.getElementById('ef-section').value = ''; + document.getElementById('ef-limit').value = '500'; + dtClearField('evt-from'); + dtClearField('evt-to'); + document.getElementById('evt-result-info').classList.add('hidden'); + document.getElementById('evt-table').classList.add('hidden'); + document.getElementById('evt-summary-card').classList.add('hidden'); + document.getElementById('evt-tag-status').textContent = ''; +} diff --git a/src/Web/wwwroot/js/fast.js b/src/Web/wwwroot/js/fast.js new file mode 100644 index 0000000..93edd38 --- /dev/null +++ b/src/Web/wwwroot/js/fast.js @@ -0,0 +1,469 @@ +// ═══════════════════════════════════════════════════════════════ +// fastRecord — 변수 / 모달 헬퍼 +// ═══════════════════════════════════════════════════════════════ +let fastCurrentSessionId = null; +let fastChart = null; +let fastLivePollTimer = null; +let fastChartTagNames = null; // 현재 차트에 그려진 태그 목록 (재생성 필요 여부 판단용) +let fastDbConnected = false; // DB 연결 상태 + +function fastModalClose() { + document.getElementById('modal-fast-new').style.display = 'none'; +} + +// ═══════════════════════════════════════════════════════════════ +// fastRecord — API 함수 +// ═══════════════════════════════════════════════════════════════ + +/** DB 연결 테스트 후 세션 목록 로드 */ +async function fastDbConnect() { + const statusEl = document.getElementById('fast-db-status'); + const btnEl = document.getElementById('btn-fast-db-connect'); + statusEl.textContent = 'DB 접속 중...'; + statusEl.style.color = 'var(--t2)'; + btnEl.disabled = true; + + try { + const res = await fetch('/api/fast/sessions'); + if (!res.ok) throw new Error('DB 응답 없음'); + const data = await res.json(); + + fastDbConnected = true; + statusEl.textContent = 'DB 연결됨'; + statusEl.style.color = '#4caf50'; + btnEl.style.display = 'none'; + + await fastSessionsLoad(); + } catch (e) { + console.error('[fast] DB 접속 실패:', e); + statusEl.textContent = 'DB 미연결'; + statusEl.style.color = 'var(--red,#e55)'; + btnEl.disabled = false; + alert('DB 연결에 실패했습니다. 다시 시도해주세요.'); + } +} + +async function fastSessionsLoad() { + const res = await fetch('/api/fast/sessions'); + if (!res.ok) { + // DB 연결 안 된 상태 — 세션 목록 비우기 + const list = document.getElementById('fast-session-list'); + list.innerHTML = 'DB 연결이 필요합니다. "DB 접속" 버튼을 눌러주세요.'; + return; + } + const data = await res.json(); + + fastDbConnected = true; + const statusEl = document.getElementById('fast-db-status'); + if (statusEl) { + statusEl.textContent = 'DB 연결됨'; + statusEl.style.color = '#4caf50'; + } + const btnEl = document.getElementById('btn-fast-db-connect'); + if (btnEl) btnEl.style.display = 'none'; + + const list = document.getElementById('fast-session-list'); + list.innerHTML = ''; + + if (!data.items || data.items.length === 0) { + list.innerHTML = '세션이 없습니다. + 신규를 눌러 시작하세요.'; + return; + } + + const statusColor = { + Running: '#4caf50', Completed: '#4363d8', + Cancelled: '#888', Failed: '#e55', + RowLimitReached: '#f58231', Pending: '#aaa' + }; + const statusLabel = { + Running: '실행중', Completed: '완료', Cancelled: '취소', + Failed: '실패', RowLimitReached: '행제한', Pending: '대기' + }; + + data.items.forEach(s => { + const isActive = s.id === fastCurrentSessionId; + const dot = statusColor[s.status] ?? '#aaa'; + const label = statusLabel[s.status] ?? s.status; + + const chip = document.createElement('div'); + chip.dataset.id = s.id; + chip.style.cssText = [ + 'display:flex;flex-direction:column;gap:3px', + 'padding:7px 11px;border-radius:var(--r)', + `background:${isActive ? 'rgba(229,85,85,.12)' : 'var(--s3)'}`, + `border:1px solid ${isActive ? 'var(--red,#e55)' : 'var(--bd)'}`, + 'cursor:pointer;min-width:120px;max-width:200px', + 'transition:border-color .15s,background .15s' + ].join(';'); + + chip.innerHTML = ` +
+ + ${esc(s.name)} + ${s.pinned ? '📌' : ''} + +
+
${label} · ${s.tagCount}태그 · ${s.samplingMs}ms
+
${fastFormatDuration(s.durationSec)} · ${fastFormatDateTime(s.startedAt).slice(0,10)}
+ `; + chip.querySelector('[data-del]').addEventListener('click', e => { + e.stopPropagation(); + fastDelete(s.id); + }); + chip.onclick = () => fastSelect(s.id); + list.appendChild(chip); + }); +} + +async function fastStart() { + const name = document.getElementById('fast-session-name').value.trim(); + if (!name) { alert('세션 이름을 입력하세요.'); return; } + + const select = document.getElementById('fast-tag-select'); + const tags = Array.from(select.selectedOptions).map(o => o.value); + if (tags.length === 0) { alert('태그를 최소 1개 이상 선택하세요.'); return; } + if (tags.length > 8) { alert('태그는 최대 8개까지 선택 가능합니다.'); return; } + + const samplingMs = parseInt(document.getElementById('fast-sampling-ms').value); + const durationSec = parseInt(document.getElementById('fast-duration-sec').value); + const retVal = document.getElementById('fast-retention-days').value.trim(); + const retentionDays = retVal ? parseInt(retVal) : null; + + const res = await fetch('/api/fast/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, samplingMs, durationSec, tagList: tags, retentionDays }) + }); + + if (!res.ok) { + const err = await res.json(); + alert('오류: ' + (err.error ?? '알 수 없는 오류')); + return; + } + + const data = await res.json(); + fastModalClose(); + await fastSessionsLoad(); + fastSelect(data.id); +} + +async function fastStop(id) { + if (!confirm('세션을 중지하시겠습니까?')) return; + const res = await fetch(`/api/fast/${id}/stop`, { method: 'POST' }); + if (!res.ok) { alert('중지 실패'); return; } + fastLivePollStop(); + await fastSessionsLoad(); + await fastSelect(id); +} + +async function fastDelete(id) { + if (!confirm('세션과 수집 데이터를 삭제하시겠습니까?')) return; + const res = await fetch(`/api/fast/${id}`, { method: 'DELETE' }); + if (!res.ok) { alert('삭제 실패'); return; } + fastLivePollStop(); + fastCurrentSessionId = null; + fastClearChart(); + document.getElementById('fast-session-title').textContent = '세션 상세'; + ['btn-fast-stop','btn-fast-export-xlsx','btn-fast-export-csv','btn-fast-delete','btn-fast-pin'] + .forEach(btnId => document.getElementById(btnId).style.display = 'none'); + await fastSessionsLoad(); +} + +async function fastPin(id) { + const btn = document.getElementById('btn-fast-pin'); + const pinned = btn.textContent.trim() === '고정'; + const res = await fetch(`/api/fast/${id}/pin`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pinned }) + }); + if (!res.ok) { alert('고정 변경 실패'); return; } + await fastSessionsLoad(); + await fastSelect(id); +} + +async function fastSelect(id) { + fastCurrentSessionId = id; + + // 칩 active 스타일 즉시 갱신 + document.querySelectorAll('#fast-session-list > div').forEach(chip => { + const isActive = parseInt(chip.dataset.id) === id; + chip.style.background = isActive ? 'rgba(229,85,85,.12)' : 'var(--s3)'; + chip.style.borderColor = isActive ? 'var(--red,#e55)' : 'var(--bd)'; + }); + + const res = await fetch(`/api/fast/${id}`); + if (!res.ok) { alert('세션 조회 실패'); return; } + const session = await res.json(); + + document.getElementById('fast-session-title').textContent = `${session.name} (${session.status})`; + + const isRunning = session.status === 'Running'; + const isFinished = !isRunning; + + document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline-block' : 'none'; + document.getElementById('btn-fast-export-xlsx').style.display = isFinished ? 'inline-block' : 'none'; + document.getElementById('btn-fast-export-csv').style.display = isFinished ? 'inline-block' : 'none'; + document.getElementById('btn-fast-delete').style.display = 'inline-block'; + document.getElementById('btn-fast-pin').style.display = 'inline-block'; + document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정'; + + await fastRenderChart(); + await fastUpdateProgress(session); + + if (isRunning) fastLivePollStart(); + else fastLivePollStop(); +} + +// ═══════════════════════════════════════════════════════════════ +// fastRecord — 차트 +// ═══════════════════════════════════════════════════════════════ + +async function fastRenderChart() { + if (!fastCurrentSessionId) return; + + const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`); + if (!res.ok) return; + const data = await res.json(); + + const container = document.getElementById('fast-chart-container'); + + if (!data.items || data.items.length === 0) { + container.innerHTML = '
수집된 데이터가 없습니다.
'; + return; + } + + // Long 포맷 → PIVOT (recorded_at 기준 그룹화) + const grouped = {}; + for (const r of data.items) { + if (!grouped[r.recordedAt]) grouped[r.recordedAt] = {}; + grouped[r.recordedAt][r.tagName] = parseFloat(r.value) || null; + } + + const times = Object.keys(grouped).sort(); + const timesNum = times.map(t => new Date(t).getTime() / 1000); // uPlot: Unix seconds + + // uPlot data: [[x...], [y1...], [y2...], ...] + const uData = [timesNum, ...data.tagNames.map(tag => times.map(t => grouped[t][tag] ?? null))]; + + // 동일 태그 구성이면 setData()로 인플레이스 업데이트 → zoom/pan 상태 유지 + const tagsKey = data.tagNames.join('\0'); + if (fastChart && fastChartTagNames === tagsKey) { + fastChart.setData(uData, false); // false = 스케일 유지 (zoom 보존) + return; + } + + // 최초 생성 또는 태그 구성 변경 시 차트 재생성 + fastChartTagNames = tagsKey; + fastClearChart(); + + const opts = { + title: 'fastRecord 트렌드', + width: container.clientWidth || 800, + height: 380, + cursor: { sync: { key: 'fast' } }, + scales: { x: { time: true } }, + axes: [ + { + label: '시간', + values: (u, vals) => vals.map(v => new Date(v * 1000).toLocaleTimeString('ko-KR')) + }, + { label: '값' } + ], + series: [ + {}, + ...data.tagNames.map(tag => ({ + label: tag, + stroke: fastTagColor(tag), + width: 2 + })) + ] + }; + + fastChart = new uPlot(opts, uData, container); +} + +function fastClearChart() { + fastChartTagNames = null; + if (fastChart) { + fastChart.destroy(); + fastChart = null; + } + document.getElementById('fast-chart-container').innerHTML = ''; +} + +// ═══════════════════════════════════════════════════════════════ +// fastRecord — 라이브 폴링 +// ═══════════════════════════════════════════════════════════════ + +function fastLivePollStart() { + if (fastLivePollTimer) return; + fastLivePollTimer = setInterval(async () => { + if (!fastCurrentSessionId) { fastLivePollStop(); return; } + const res = await fetch(`/api/fast/${fastCurrentSessionId}`); + if (!res.ok) return; + const session = await res.json(); + await fastUpdateProgress(session); + await fastRenderChart(); + if (session.status !== 'Running') { + fastLivePollStop(); + await fastSelect(fastCurrentSessionId); + } + }, 2000); +} + +function fastLivePollStop() { + if (fastLivePollTimer) { + clearInterval(fastLivePollTimer); + fastLivePollTimer = null; + } +} + +async function fastUpdateProgress(session) { + const elapsed = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000); + const progress = Math.min((elapsed / session.durationSec) * 100, 100); + + document.getElementById('fast-progress-bar').style.width = `${progress}%`; + + const expectedRows = Math.floor(elapsed / (session.samplingMs / 1000)) * session.tagList?.length ?? 0; + document.getElementById('fast-progress-text').textContent = + `${session.rowCount.toLocaleString()} / ~${expectedRows.toLocaleString()} (${progress.toFixed(1)}%)`; + document.getElementById('fast-elapsed-time').textContent = + `경과: ${fastFormatDuration(Math.min(elapsed, session.durationSec))} / ${fastFormatDuration(session.durationSec)}`; +} + +// ═══════════════════════════════════════════════════════════════ +// fastRecord — 유틸 +// ═══════════════════════════════════════════════════════════════ + +function fastFormatDuration(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +function fastFormatDateTime(dt) { + return new Date(dt).toLocaleString('ko-KR'); +} + +function fastTagColor(tag) { + const palette = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4', + '#42d4f4','#f032e6','#bfef45','#469990','#fabed4']; + let sum = 0; + for (let i = 0; i < tag.length; i++) sum += tag.charCodeAt(i); + return palette[sum % palette.length]; +} + +// ═══════════════════════════════════════════════════════════════ +// fastRecord — 이벤트 리스너 +// ═══════════════════════════════════════════════════════════════ + +paneInit.fast = function() { + fastSessionsLoad(); + + const bind = (id, fn) => { + const el = document.getElementById(id); + if (el && !el.dataset.fastBound) { el.addEventListener('click', fn); el.dataset.fastBound = '1'; } + }; + + // DB 접속 버튼 + bind('btn-fast-db-connect', fastDbConnect); + + // + 신규 버튼 — DB 연결 확인 후 모달 열기 + bind('btn-fast-new', async () => { + if (!fastDbConnected) { + alert('데이터베이스 접속을 먼저 완료해주세요. "DB 접속" 버튼을 눌러주세요.'); + return; + } + + const select = document.getElementById('fast-tag-select'); + select.innerHTML = ''; + document.getElementById('fast-session-name').value = ''; + document.getElementById('fast-retention-days').value = ''; + document.getElementById('modal-fast-new').style.display = 'flex'; + + try { + const res = await fetch('/api/pointbuilder/points'); + if (res.ok) { + const data = await res.json(); + select.innerHTML = ''; + (data.items || []).forEach(p => { + const opt = document.createElement('option'); + opt.value = p.tagName || p.TagName; + opt.textContent = p.tagName || p.TagName; + select.appendChild(opt); + }); + } else { + select.innerHTML = ''; + } + } catch (e) { + console.error('[fast] 태그 목록 로드 실패:', e); + select.innerHTML = ''; + } + }); + + // btn-fast-start: onclick="fastStart()" 으로 HTML에서 직접 처리 + + bind('btn-fast-stop', () => { + if (fastCurrentSessionId) fastStop(fastCurrentSessionId); + }); + + bind('btn-fast-delete', () => { + if (fastCurrentSessionId) fastDelete(fastCurrentSessionId); + }); + + bind('btn-fast-pin', () => { + if (fastCurrentSessionId) fastPin(fastCurrentSessionId); + }); + + // Excel Export + bind('btn-fast-export-xlsx', async () => { + if (!fastCurrentSessionId) return; + const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`); + if (!res.ok) return; + const data = await res.json(); + + // Long → Wide (배열의 배열 형식으로 XLSX.utils.aoa_to_sheet에 전달) + const timeMap = {}; + for (const r of data.items) { + if (!timeMap[r.recordedAt]) timeMap[r.recordedAt] = {}; + timeMap[r.recordedAt][r.tagName] = r.value; + } + + const rows = [['recorded_at', ...data.tagNames]]; + for (const t of Object.keys(timeMap).sort()) { + rows.push([new Date(t).toLocaleString('ko-KR'), + ...data.tagNames.map(tag => timeMap[t][tag] ?? '')]); + } + + const ws = XLSX.utils.aoa_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'fastRecord'); + XLSX.writeFile(wb, `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.xlsx`); + }); + + // CSV Export (서버 스트리밍) + bind('btn-fast-export-csv', async () => { + if (!fastCurrentSessionId) return; + const res = await fetch(`/api/fast/${fastCurrentSessionId}/csv`); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }); + + // 탭 전환 시 세션 목록 갱신 + document.querySelectorAll('[href="#pane-fast"]').forEach(a => { + if (!a.dataset.fastTabBound) { + a.addEventListener('show.bs.tab', () => fastSessionsLoad()); + a.dataset.fastTabBound = '1'; + } + }); +}; diff --git a/src/Web/wwwroot/js/hist.js b/src/Web/wwwroot/js/hist.js new file mode 100644 index 0000000..fa7aa27 --- /dev/null +++ b/src/Web/wwwroot/js/hist.js @@ -0,0 +1,385 @@ +/* ───────────────────────────────────────────────────────────── + 07 이력 조회 +───────────────────────────────────────────────────────────── */ +const HIST_TAG_IDS = ['hf-t1','hf-t2','hf-t3','hf-t4','hf-t5','hf-t6','hf-t7','hf-t8']; + +// 상태 표시기 업데이트 함수 +function histUpdateStatus(state, message) { + const el = document.getElementById('hist-load-status'); + if (!el) return; + + // 상태 클래스 제거 후 재설정 + el.classList.remove('loading', 'success', 'error'); + + if (state) { + el.classList.add(state); + } + + // 아이콘/텍스트 업데이트 - .status-dot span은 유지 + const icons = { loading: '⏳', success: '✅', error: '❌' }; + const icon = icons[state] || ''; + + // .status-dot span 찾기 + const dot = el.querySelector('.status-dot'); + + // 텍스트 노드 업데이트 (첫 번째 텍스트 노드) + const textNode = Array.from(el.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + if (textNode) { + textNode.textContent = icon ? `${icon} ${message}` : message; + } +} + +// "▼ 옵션 불러오기" 버튼 클릭 시에만 호출 — 탭 진입 시 자동 호출 없음 +async function histLoad() { + const el = document.getElementById('hist-load-status'); + if (!el) return; + + // 로딩 상태 표시 + histUpdateStatus('loading', '조회 중...'); + + try { + const startTime = Date.now(); + const d = await api('GET', '/api/history/tagnames'); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + const tagNames = d.tagNames || []; + + if (tagNames.length === 0) { + histUpdateStatus('error', '조회 데이터 없음 (0개)'); + } else { + const opts = '' + + tagNames.map(t => ``).join(''); + + // 기존 선택값 보존 + const selectedValues = HIST_TAG_IDS.map(id => document.getElementById(id).value); + + // DOM 업데이트를 requestAnimationFrame으로 지연하여 레이아웃 충돌 방지 + requestAnimationFrame(() => { + HIST_TAG_IDS.forEach((id, index) => { + const sel = document.getElementById(id); + sel.innerHTML = opts; + if (selectedValues[index]) sel.value = selectedValues[index]; + }); + }); + + histUpdateStatus('success', `옵션 조회 완료 (${tagNames.length}개, ${elapsed}초)`); + } + } catch (e) { + console.error('histLoad:', e); + histUpdateStatus('error', `조회 실패: ${e.message}`); + } +} + +async function histQuery() { + const tags = HIST_TAG_IDS.map(id => document.getElementById(id).value).filter(Boolean); + const from = document.getElementById('hf-from').value; + const to = document.getElementById('hf-to').value; + const interval = document.getElementById('hf-interval').value || '5 minutes'; + const limit = parseInt(document.getElementById('hf-limit').value) || 500; + + if (!tags.length) { + histShowStatus('err', '❌', '태그를 최소 1개 선택하세요', ' 태그 선택란에서 태그를 선택해주세요.'); + return; + } + + // 간격이 기본값(60초)과 다른 경우 시간 간격 조회 API 사용 + if (interval === '1 minute') { + // 기본 간격(1분) 이하인 경우 기존 API 사용 + const params = new URLSearchParams(); + tags.forEach(t => params.append('tagNames', t)); + if (from) params.set('from', new Date(from).toISOString()); + if (to) params.set('to', new Date(to).toISOString()); + params.set('limit', limit); + + // 상태 표시: 조회 시작 + histShowStatus('busy', '⏳', '조회 중...', ` ${tags.length}개 태그, 제한: ${limit}행`); + + try { + const d = await api('GET', `/api/history/query?${params}`); + const rows = d.rows || []; + const tNames = d.tagNames || []; + + renderHistoryTable(rows, tNames, interval); + } catch (e) { + histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`); + setGlobal('err', '조회 실패'); + console.error('histQuery 오류:', e); + } + } else { + // 사용자 지정 간격인 경우 시간 간격 조회 API 사용 + const body = { + tagNames: tags, + from: from ? new Date(from).toISOString() : null, + to: to ? new Date(to).toISOString() : null, + interval: interval, + limit: limit + }; + + // 상태 표시: 조회 시작 + histShowStatus('busy', '⏳', '조회 중...', ` ${tags.length}개 태그, 간격: ${interval}, 제한: ${limit}행`); + + try { + const d = await api('POST', '/api/text-to-sql/query-history-interval', body); + + if (!d.success) { + throw new Error(d.error || '조회 실패'); + } + + const rows = d.rows || []; + const tNames = d.tagNames || []; + + renderHistoryTable(rows, tNames, interval, d.baseIntervalSeconds, d.queryInterval); + } catch (e) { + histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`); + setGlobal('err', '조회 실패'); + console.error('histQuery 오류:', e); + } + } +} + +/** + * 이력 조회 결과 테이블 렌더링 + */ +function renderHistoryTable(rows, tNames, interval, baseIntervalSeconds, queryInterval) { + const tbl = document.getElementById('hist-table'); + tbl.classList.remove('hidden'); + + const info = document.getElementById('hist-result-info'); + info.classList.remove('hidden'); + + let infoText = `총 ${rows.length.toLocaleString()}행 × ${tNames.length}개 태그`; + if (queryInterval) { + infoText += ` | 집계 간격: ${queryInterval}`; + } + info.textContent = infoText; + + if (!rows.length) { + histShowStatus('ok', '✅', '조회 완료 (0건)', ` 조건에 맞는 데이터가 없습니다. | 태그: ${tNames.join(', ') || '전체'}`); + tbl.innerHTML = '
조건에 맞는 이력이 없습니다.
'; + setGlobal('ok', '0건'); + return; + } + + // 시간 간격 조회인 경우 TimeBucket 열 사용 + const timeColumn = rows[0].timeBucket ? 'timeBucket' : 'recordedAt'; + + tbl.innerHTML = ` + + + + + ${tNames.map(t => ``).join('')} + + + + ${rows.map(r => ` + + + ${tNames.map(t => { + const raw = r.values?.[t] ?? null; + const display = raw != null ? esc(String(fmtVal(raw))) : ''; + return ``; + }).join('')} + + `).join('')} + +
${queryInterval ? '집계 시각' : '시각'}${esc(t.toUpperCase())}
${fmtTs(r[timeColumn])}${display}
+ `; + + let detailText = ` 태그: ${tNames.join(', ')}`; + if (queryInterval) { + detailText += ` | 집계 간격: ${queryInterval}`; + } + detailText += ` | 시각 범위: ${document.getElementById('hf-from').value || '전체'} ~ ${document.getElementById('hf-to').value || '전체'}`; + + histShowStatus('ok', '✅', `조회 완료 (${rows.length}건)`, detailText); + setGlobal('ok', `${rows.length}건`); +} + +/** + * 이력 조회 상태 표시 창 업데이트 + * @param {string} state - 'pending' | 'busy' | 'ok' | 'err' + * @param {string} icon - 표시할 아이콘 + * @param {string} text - 메인 상태 텍스트 + * @param {string} detail - 상세 설명 + */ +function histShowStatus(state, icon, text, detail) { + const box = document.getElementById('hist-status-box'); + const iconEl = document.getElementById('hist-status-icon'); + const textEl = document.getElementById('hist-status-text'); + const detEl = document.getElementById('hist-status-detail'); + + box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err'); + box.classList.add(state); + iconEl.textContent = icon; + textEl.textContent = text; + detEl.textContent = detail || ''; +} + +function histReset() { + HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; }); + dtClearField('from'); + dtClearField('to'); + document.getElementById('hf-interval').value = '1 minute'; + document.getElementById('hf-limit').value = '500'; + document.getElementById('hist-result-info').classList.add('hidden'); + document.getElementById('hist-table').classList.add('hidden'); + + // 상태 표시 창 초기화 + histShowStatus('pending', '⏸', '대기 중', '조회 조건을 설정하고 조회 버튼을 눌러주세요.'); +} + +/* ───────────────────────────────────────────────────────────── + 07-2 하이퍼테이블 관리 + ───────────────────────────────────────────────────────────── */ + +/** + * 하이퍼테이블 상태 불러오기 + */ +async function htLoadStatus() { + try { + const d = await api('GET', '/api/experion/hypertable/status'); + + // 상태 표시 창 업데이트 + const box = document.getElementById('ht-status-box'); + const iconEl = document.getElementById('ht-status-icon'); + const textEl = document.getElementById('ht-status-text'); + const detEl = document.getElementById('ht-status-detail'); + + box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err'); + + // 정보 패널 업데이트 + const infoPanel = document.getElementById('ht-info-panel'); + document.getElementById('ht-info-table').textContent = d.tableName || '-'; + document.getElementById('ht-info-records').textContent = d.recordCount != null ? d.recordCount.toLocaleString() : '-'; + document.getElementById('ht-info-retention').textContent = d.hasRetentionPolicy ? '설정됨' : '미설정'; + document.getElementById('ht-info-compression').textContent = d.hasCompression ? '활성화됨' : '비활성화됨'; + + if (d.isHypertable) { + box.classList.add('ok'); + iconEl.textContent = '✅'; + textEl.textContent = '하이퍼테이블 활성화됨'; + detEl.textContent = d.statusMessage || 'history_table이 하이퍼테이블로 변환되었습니다.'; + infoPanel.classList.remove('hidden'); + } else { + box.classList.add('pending'); + iconEl.textContent = '⚠️'; + textEl.textContent = '일반 테이블'; + detEl.textContent = d.statusMessage || 'history_table이 아직 하이퍼테이블로 변환되지 않았습니다.'; + infoPanel.classList.add('hidden'); + } + } catch (e) { + console.error('htLoadStatus 오류:', e); + const box = document.getElementById('ht-status-box'); + const iconEl = document.getElementById('ht-status-icon'); + const textEl = document.getElementById('ht-status-text'); + const detEl = document.getElementById('ht-status-detail'); + + box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err'); + box.classList.add('err'); + iconEl.textContent = '❌'; + textEl.textContent = '상태 조회 실패'; + detEl.textContent = e.message || '알 수 없는 오류'; + document.getElementById('ht-info-panel').classList.add('hidden'); + } +} + +/** + * 하이퍼테이블 생성 + */ +async function htCreate() { + const tableName = document.getElementById('ht-table-name').value || 'history_table'; + const retentionEnabled = document.getElementById('ht-auto-retention').checked; + const compressionEnabled = document.getElementById('ht-auto-compression').checked; + const aggregateEnabled = document.getElementById('ht-auto-aggregate').checked; + + // 생성 전 확인 + const confirmMsg = retentionEnabled || compressionEnabled + ? `다음 옵션으로 하이퍼테이블을 생성합니다:\n\n` + + `- 테이블명: ${tableName}\n` + + `- 보관 기간: ${retentionEnabled ? document.getElementById('ht-retention-period').value : '설정 안함'}\n` + + `- 압축: ${compressionEnabled ? '활성화 (' + document.getElementById('ht-compression-period').value + ')' : '비활성화'}\n` + + `- 연속 집계: ${aggregateEnabled ? '생성' : '생성 안함'}\n\n` + + `계속 진행하시겠습니까?` + : `기본 설정으로 하이퍼테이블을 생성합니다:\n\n` + + `- 테이블명: ${tableName}\n` + + `- 보관 기간: 설정 안함\n` + + `- 압축: 설정 안함\n\n` + + `계속 진행하시겠습니까?`; + + if (!confirm(confirmMsg)) return; + + try { + // 상태 표시 창을 로딩 모드로 + const box = document.getElementById('ht-status-box'); + const iconEl = document.getElementById('ht-status-icon'); + const textEl = document.getElementById('ht-status-text'); + const detEl = document.getElementById('ht-status-detail'); + + box.classList.remove('hidden', 'pending', 'ok', 'err'); + box.classList.add('busy'); + iconEl.textContent = '⏳'; + textEl.textContent = '생성 중...'; + detEl.textContent = '하이퍼테이블을 생성하고 있습니다. 잠시 기다려주세요.'; + document.getElementById('ht-info-panel').classList.add('hidden'); + + const body = { + tableName: tableName, + timeColumn: 'recorded_at', + timeInterval: '1 day', + migrateData: true, + setRetentionPolicy: retentionEnabled, + retentionPeriod: retentionEnabled ? document.getElementById('ht-retention-period').value : undefined, + enableCompression: compressionEnabled, + compressionPeriod: compressionEnabled ? document.getElementById('ht-compression-period').value : undefined, + createContinuousAggregate: aggregateEnabled + }; + + // 불필요한 필드는 제거 + Object.keys(body).forEach(key => { + if (body[key] === undefined) delete body[key]; + }); + + await api('POST', '/api/experion/hypertable/create', body); + + // 상태 표시: 성공 + box.classList.remove('busy'); + box.classList.add('ok'); + iconEl.textContent = '✅'; + textEl.textContent = '생성 완료'; + detEl.textContent = '하이퍼테이블이 성공적으로 생성되었습니다. 상태 새로고침 버튼을 눌러주세요.'; + + // 상태 새로고침 + setTimeout(() => htLoadStatus(), 500); + } catch (e) { + console.error('htCreate 오류:', e); + const box = document.getElementById('ht-status-box'); + const iconEl = document.getElementById('ht-status-icon'); + const textEl = document.getElementById('ht-status-text'); + const detEl = document.getElementById('ht-status-detail'); + + box.classList.remove('busy', 'pending', 'ok', 'err'); + box.classList.add('err'); + iconEl.textContent = '❌'; + textEl.textContent = '생성 실패'; + detEl.textContent = e.message || '알 수 없는 오류'; + } +} + +/** + * 보관 기간 설정 토글 + */ +function htToggleRetention() { + const checked = document.getElementById('ht-auto-retention').checked; + const panel = document.getElementById('ht-retention-panel'); + panel.classList.toggle('ht-hidden', !checked); +} + +/** + * 압축 설정 토글 + */ +function htToggleCompression() { + const checked = document.getElementById('ht-auto-compression').checked; + const panel = document.getElementById('ht-compression-panel'); + panel.classList.toggle('ht-hidden', !checked); +} diff --git a/src/Web/wwwroot/js/kbadmin.js b/src/Web/wwwroot/js/kbadmin.js new file mode 100644 index 0000000..d65735d --- /dev/null +++ b/src/Web/wwwroot/js/kbadmin.js @@ -0,0 +1,397 @@ +/* ═══════════════════════════════════════════════════════ + 14 RAG 관리 (KB Admin) + ══════════════════════════════════════════════════════ */ + +let kbToken = sessionStorage.getItem('kbToken') || ''; +let kbExpiresAt = sessionStorage.getItem('kbExpiresAt') || ''; +let kbCollections = []; +let kbPollTimer = null; + +function kbHeaders(extra) { + const h = { ...(extra || {}) }; + if (kbToken) h['X-Kb-Token'] = kbToken; + return h; +} + +async function kbFetch(method, path, body, opt) { + const init = { method, headers: kbHeaders({ 'Content-Type': 'application/json' }), ...(opt || {}) }; + if (body !== undefined && body !== null) init.body = JSON.stringify(body); + const res = await fetch(path, init); + let data = null; + try { data = await res.json(); } catch { /* ignore */ } + return { ok: res.ok, status: res.status, data }; +} + +// ── 탭 진입 시 세션 검증 ───────────────────────────── +paneInit.kbadmin = async function() { + if (kbToken) { + const r = await kbFetch('GET', '/api/kb/auth/status'); + if (r.ok && r.data && r.data.valid) { + kbShowMain(); + } else { + kbShowLogin('세션이 만료되었습니다. 다시 로그인하세요.'); + } + } else { + kbShowLogin(''); + } +}; + +function kbShowLogin(msg) { + kbToken = ''; kbExpiresAt = ''; + sessionStorage.removeItem('kbToken'); sessionStorage.removeItem('kbExpiresAt'); + document.getElementById('kb-login-card').classList.remove('hidden'); + document.getElementById('kb-main').classList.add('hidden'); + const m = document.getElementById('kb-login-msg'); + if (m) m.textContent = msg || ''; + kbStopPoll(); +} + +function kbShowMain() { + document.getElementById('kb-login-card').classList.add('hidden'); + document.getElementById('kb-main').classList.remove('hidden'); + kbUpdateSessionInfo(); + kbLoadCollections().then(() => kbRefresh()); + kbStartPoll(); +} + +function kbUpdateSessionInfo() { + const el = document.getElementById('kb-session-info'); + if (!el) return; + if (kbExpiresAt) { + const t = new Date(kbExpiresAt); + el.textContent = `세션 만료: ${t.toLocaleTimeString('ko-KR')}`; + } else { + el.textContent = '세션: --:--'; + } +} + +async function kbLogin() { + const pw = document.getElementById('kb-pw').value; + const msg = document.getElementById('kb-login-msg'); + if (!pw) { msg.textContent = '비밀번호를 입력하세요.'; return; } + msg.textContent = '로그인 중...'; + const r = await fetch('/api/kb/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }) + }); + const data = await r.json().catch(() => ({})); + if (!r.ok || !data.success) { + msg.textContent = '❌ ' + (data.error || '로그인 실패'); + return; + } + kbToken = data.token; + kbExpiresAt = data.expiresAt; + sessionStorage.setItem('kbToken', kbToken); + sessionStorage.setItem('kbExpiresAt', kbExpiresAt || ''); + document.getElementById('kb-pw').value = ''; + msg.textContent = ''; + kbShowMain(); +} + +async function kbLogout() { + if (kbToken) await kbFetch('POST', '/api/kb/auth/logout'); + kbShowLogin('로그아웃되었습니다.'); +} + +// ── 컬렉션 ───────────────────────────────────────────── +async function kbLoadCollections() { + const r = await kbFetch('GET', '/api/kb/collections'); + if (!r.ok || !r.data || !r.data.success) return; + kbCollections = r.data.items || []; + const fSel = document.getElementById('kb-f-coll'); + const uSel = document.getElementById('kb-up-coll'); + fSel.innerHTML = '' + + kbCollections.map(c => ``).join(''); + uSel.innerHTML = '' + + kbCollections.map(c => ``).join(''); +} + +// ── 목록 ─────────────────────────────────────────────── +async function kbRefresh() { + const coll = document.getElementById('kb-f-coll').value; + const status = document.getElementById('kb-f-status').value; + const q = document.getElementById('kb-f-q').value.trim(); + const qs = new URLSearchParams(); + if (coll) qs.set('collection', coll); + if (status) qs.set('status', status); + if (q) qs.set('q', q); + qs.set('pageSize', '200'); + + const r = await kbFetch('GET', '/api/kb/documents?' + qs.toString()); + if (!r.ok || !r.data || !r.data.success) { + document.getElementById('kb-doc-table').innerHTML = '
조회 실패
'; + return; + } + kbRenderDocs(r.data.items, r.data.total); +} + +function kbRenderDocs(items, total) { + const stats = document.getElementById('kb-doc-stats'); + stats.textContent = `총 ${total}건`; + const tbl = document.getElementById('kb-doc-table'); + if (!items || items.length === 0) { + tbl.innerHTML = '
문서 없음
'; + return; + } + const collMap = Object.fromEntries(kbCollections.map(c => [c.key, c.name])); + const rows = items.map(d => { + const tags = (d.tags || []).map(t => `${t}`).join(' '); + const dt = d.uploadedAt ? new Date(d.uploadedAt).toLocaleString('ko-KR') : ''; + const size = d.fileSize ? kbFmtSize(d.fileSize) : ''; + return ` + ${kbShortId(d.id)} + ${kbEscape(d.title)} + ${collMap[d.collection] || d.collection} + ${tags} + ${size} + ${d.status}${d.errorMessage ? `
${kbEscape(d.errorMessage.slice(0,60))}…
`:''} + ${d.chunkCount || 0} + ${dt} + + + ${(d.chunkCount || 0) > 0 ? `` : ''} + + + + + `; + }).join(''); + tbl.innerHTML = ` + + + ${rows}
ID제목컬렉션태그크기상태청크업로드액션
`; +} + +function kbShortId(id) { return (id || '').replace(/-/g, '').slice(0, 8); } +function kbEscape(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } +function kbFmtSize(n) { + if (n < 1024) return n + 'B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + 'K'; + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + 'M'; + return (n / 1024 / 1024 / 1024).toFixed(2) + 'G'; +} + +// ── 업로드 모달 ──────────────────────────────────────── +function kbUploadOpen() { + document.getElementById('kb-up-msg').textContent = ''; + document.getElementById('kb-up-title').value = ''; + document.getElementById('kb-up-tags').value = ''; + document.getElementById('kb-up-file').value = ''; + document.getElementById('kb-upload-modal').classList.remove('hidden'); +} +function kbUploadClose() { + document.getElementById('kb-upload-modal').classList.add('hidden'); +} +async function kbUploadSubmit() { + const coll = document.getElementById('kb-up-coll').value; + const title = document.getElementById('kb-up-title').value.trim(); + const tags = document.getElementById('kb-up-tags').value.trim(); + const fileInput = document.getElementById('kb-up-file'); + const msg = document.getElementById('kb-up-msg'); + if (!coll) { msg.textContent = '❌ 컬렉션을 선택하세요.'; return; } + if (!fileInput.files || fileInput.files.length === 0) { msg.textContent = '❌ 파일을 선택하세요.'; return; } + const fd = new FormData(); + fd.append('file', fileInput.files[0]); + fd.append('collectionKey', coll); + if (title) fd.append('title', title); + if (tags) fd.append('tags', tags); + msg.textContent = '업로드 중...'; + const r = await fetch('/api/kb/upload', { method: 'POST', headers: kbHeaders(), body: fd }); + const data = await r.json().catch(() => ({})); + if (!r.ok || !data.success) { + msg.textContent = '❌ ' + (data.error || ('HTTP ' + r.status)); + return; + } + msg.textContent = '✅ 업로드 완료. 인덱싱 진행 중...'; + setTimeout(() => kbUploadClose(), 600); + kbRefresh(); +} + +// ── 청크 미리보기 ───────────────────────────────────── +async function kbShowChunks(id, title) { + const modal = document.getElementById('kb-chunk-modal'); + const titleEl = document.getElementById('kb-chunk-title'); + const body = document.getElementById('kb-chunk-body'); + titleEl.textContent = `🔍 청크 미리보기 — ${title || ''}`; + body.innerHTML = '
불러오는 중...
'; + modal.classList.remove('hidden'); + + const r = await kbFetch('GET', '/api/kb/documents/' + id + '/chunks?limit=200'); + if (!r.ok || !r.data || !r.data.success) { + body.innerHTML = '
조회 실패: ' + ((r.data && r.data.error) || r.status) + '
'; + return; + } + kbRenderChunks(r.data.chunks || []); +} + +function kbChunkCloseModal() { + document.getElementById('kb-chunk-modal').classList.add('hidden'); +} + +function kbRenderChunks(chunks) { + const body = document.getElementById('kb-chunk-body'); + if (!chunks.length) { + body.innerHTML = '
청크 없음
'; + return; + } + body.innerHTML = `
총 ${chunks.length}개 청크
` + + chunks.map((c, i) => { + const kind = c.chunk_kind || 'text'; + const loc = c.locator + || [c.sheet, c.page ? 'p' + c.page : '', c.heading_path].filter(Boolean).join(' · '); + const text = c.text || ''; + const preview = text.length > 240 ? text.slice(0, 240) + '…' : text; + return `
+
+ ${kbEscape(kind)} + ${kbEscape(loc) || '#' + (i + 1)} + ${text.length}자 +
+
${kbEscape(preview)}
+
${kbEscape(text)}
+
`; + }).join(''); +} + +// ── 액션 ─────────────────────────────────────────────── +function kbDownload(id) { + window.open('/api/kb/download/' + id, '_blank'); +} + +async function kbReindex(id) { + if (!confirm('재인덱싱하시겠습니까? (Qdrant 기존 청크 삭제 후 다시 처리)')) return; + const r = await kbFetch('POST', '/api/kb/documents/' + id + '/reindex'); + if (!r.ok) alert('실패: ' + (r.data && r.data.error ? r.data.error : r.status)); + kbRefresh(); +} + +async function kbDisable(id) { + if (!confirm('이 문서를 비활성화하시겠습니까?')) return; + const r = await kbFetch('POST', '/api/kb/documents/' + id + '/disable'); + if (!r.ok) alert('실패'); + kbRefresh(); +} + +async function kbDelete(id, title) { + if (!confirm(`삭제하시겠습니까?\n${title}\n(Qdrant 청크와 원본 파일도 함께 삭제됩니다)`)) return; + const r = await kbFetch('DELETE', '/api/kb/documents/' + id); + if (!r.ok) alert('실패'); + kbRefresh(); +} + +async function kbBulkDisable() { + const title = prompt('일괄 비활성화할 제목을 정확히 입력하세요:'); + if (!title) return; + const r = await kbFetch('POST', '/api/kb/documents/bulk-disable', { title }); + if (r.ok && r.data && r.data.success) alert(`${r.data.affected}건 비활성화 완료`); + else alert('실패'); + kbRefresh(); +} + +async function kbPurgeDisabled() { + const ds = prompt('비활성화 후 며칠 지난 문서를 영구삭제할까요? (공백이면 모든 disabled 삭제)', '90'); + let body = {}; + if (ds && ds.trim()) { + const n = parseInt(ds, 10); + if (isNaN(n) || n < 0) { alert('숫자를 입력하세요.'); return; } + body.olderThanDays = n; + } + if (!confirm('정말 영구삭제하시겠습니까? (되돌릴 수 없습니다)')) return; + const r = await kbFetch('POST', '/api/kb/documents/purge-disabled', body); + if (r.ok && r.data && r.data.success) alert(`${r.data.deleted}건 영구삭제 완료`); + else alert('실패'); + kbRefresh(); +} + +// ── 비밀번호 변경 ────────────────────────────────────── +function kbChangePwOpen() { + document.getElementById('kb-pw-old').value = ''; + document.getElementById('kb-pw-new').value = ''; + document.getElementById('kb-pw-msg').textContent = ''; + document.getElementById('kb-pw-modal').classList.remove('hidden'); +} +function kbChangePwClose() { + document.getElementById('kb-pw-modal').classList.add('hidden'); +} +async function kbChangePwSubmit() { + const oldPw = document.getElementById('kb-pw-old').value; + const newPw = document.getElementById('kb-pw-new').value; + const msg = document.getElementById('kb-pw-msg'); + if (!oldPw || !newPw) { msg.textContent = '❌ 비밀번호를 입력하세요.'; return; } + if (newPw.length < 6) { msg.textContent = '❌ 새 비밀번호는 6자 이상.'; return; } + const r = await kbFetch('POST', '/api/kb/auth/change-password', { oldPassword: oldPw, newPassword: newPw }); + if (r.ok && r.data && r.data.success) { + msg.textContent = '✅ 변경 완료. 다시 로그인해 주세요.'; + setTimeout(() => { kbChangePwClose(); kbLogout(); }, 800); + } else { + msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : '변경 실패'); + } +} + +// ── 진행률 폴링 (활성 ingest가 있으면 1초마다 새로고침) ─ +function kbStartPoll() { + kbStopPoll(); + kbPollTimer = setInterval(async () => { + if (!kbToken) return; + if (document.getElementById('pane-kbadmin').classList.contains('active') === false) return; + const r = await kbFetch('GET', '/api/kb/documents?status=parsing&pageSize=1'); + const r2 = await kbFetch('GET', '/api/kb/documents?status=embedding&pageSize=1'); + const r3 = await kbFetch('GET', '/api/kb/documents?status=pending&pageSize=1'); + const active = [r, r2, r3].some(x => x.ok && x.data && x.data.total > 0); + if (active) kbRefresh(); + }, 1500); +} +function kbStopPoll() { + if (kbPollTimer) { clearInterval(kbPollTimer); kbPollTimer = null; } +} + +// ── Field Instrument Inference ────────────────────────────── +function kbInferDownload(path) { + const encoded = encodeURIComponent(path); + window.open('/api/kb/instruments/download?path=' + encoded, '_blank'); +} + +async function kbInferStart() { + const btn = document.getElementById('kb-infer-btn'); + const msg = document.getElementById('kb-infer-msg'); + const result = document.getElementById('kb-infer-result'); + const useLlm = document.getElementById('kb-infer-llm').checked; + + btn.disabled = true; + btn.textContent = '⏳ 생성 중...'; + msg.textContent = '계기 유추 및 Excel 생성 중 (수십 초 소요)'; + result.classList.add('hidden'); + + try { + const r = await kbFetch('POST', '/api/kb/instruments/infer', { + useLlm, + seedDocId: '' + }); + + if (!r.ok || !r.data || !r.data.success) { + msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : 'HTTP ' + r.status); + return; + } + + const d = r.data; + msg.textContent = '✅ 생성 완료. Excel 다운로드 후 수동으로 KB에 업로드하세요.'; + result.classList.remove('hidden'); + result.innerHTML = ` +
+ 계기: ${d.instrumentCount}개 + 동력기기: ${d.powerEquipmentCount || 0}개 + 미매칭: ${d.unmatchedCount}개 +
+

${d.message || ''}

+
+ +
+ `; + } catch (e) { + msg.textContent = '❌ ' + e.message; + } finally { + btn.disabled = false; + btn.textContent = '⚡ 초안 생성'; + } +} diff --git a/src/Web/wwwroot/js/llmchat.js b/src/Web/wwwroot/js/llmchat.js new file mode 100644 index 0000000..96f5bd3 --- /dev/null +++ b/src/Web/wwwroot/js/llmchat.js @@ -0,0 +1,1001 @@ +/* ═══════════════════════════════════════════════════════ + 13 로컬 LLM 채팅 + ══════════════════════════════════════════════════════ */ + +// ── 상태 ─────────────────────────────────────────────── +let llmSessions = JSON.parse(localStorage.getItem('llmSessions') || '[]'); +let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || ''; +let llmAbortController = null; +let llmIsStreaming = false; +let llmType = localStorage.getItem('llmType') || 'ollama'; +let llmUseTools = localStorage.getItem('llmUseTools') === 'true'; +let llmAgentMode = localStorage.getItem('llmAgentMode') === 'true'; +let llmMcpTools = []; + +// ── Phase 5.5: welcome 화면 추천 질문 ─────────────────── +const LLM_STARTER_CHIPS = [ + '지금 활성 알람을 보여줘', + 'Unit A의 24시간 운전 상황을 요약해줘', + 'FIC-6113.PV 최근 1시간 추이', + '오늘 발생한 디지털 이벤트 정리', + 'P-6201 펌프의 정비 이력', + '이번 주 보고서를 작성해줘', + '냉각수 펌프 토출 압력 태그를 찾아줘' +]; + +function llmUseChip(btn) { + const input = document.getElementById('llm-input'); + if (!input) return; + input.value = btn.textContent; + input.focus(); + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 150) + 'px'; +} + +// ── 초기화 (탭 진입 시) ────────────────────────────── +paneInit.llmchat = function() { + llmRenderSessionList(); + llmLoadActiveSession(); + llmLoadModels(); + llmLoadConfigToUI(); + llmLoadMcpTools(); +}; + +// ── 세션 관리 ────────────────────────────────────────── +function llmCreateSession() { + const id = 'llm_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + const session = { + id, + title: '새 대화', + model: '', + systemPrompt: '', + messages: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + llmSessions.unshift(session); + llmActiveSessionId = id; + llmSaveSessions(); + llmRenderSessionList(); + llmRenderMessages(); + return session; +} + +function llmNewSession() { + llmCreateSession(); + document.getElementById('llm-input').focus(); +} + +function llmSwitchSession(id) { + llmActiveSessionId = id; + localStorage.setItem('llmActiveSessionId', id); + llmRenderSessionList(); + llmRenderMessages(); +} + +function llmDeleteSession(id, e) { + e.stopPropagation(); + if (!confirm('이 대화를 삭제하시겠습니까?')) return; + llmSessions = llmSessions.filter(s => s.id !== id); + llmSaveSessions(); + if (llmActiveSessionId === id) { + llmActiveSessionId = llmSessions.length > 0 ? llmSessions[0].id : ''; + localStorage.setItem('llmActiveSessionId', llmActiveSessionId); + } + llmRenderSessionList(); + llmRenderMessages(); +} + +function llmGetActiveSession() { + return llmSessions.find(s => s.id === llmActiveSessionId) || null; +} + +function llmSaveSessions() { + localStorage.setItem('llmSessions', JSON.stringify(llmSessions)); + localStorage.setItem('llmActiveSessionId', llmActiveSessionId); +} + +function llmSaveSessionMeta() { + const sess = llmGetActiveSession(); + if (!sess) return; + sess.model = document.getElementById('llm-model-select').value; + sess.systemPrompt = document.getElementById('llm-system-prompt').value.trim(); + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderSessionList(); +} + +// ── 세션 목록 렌더링 ─────────────────────────────────── +function llmRenderSessionList() { + const el = document.getElementById('llm-session-list'); + if (!el) return; + + if (llmSessions.length === 0) { + el.innerHTML = '
대화가 없습니다.
+ 버튼을 눌러 새 대화를 시작하세요.
'; + return; + } + + el.innerHTML = llmSessions.map(s => { + const isActive = s.id === llmActiveSessionId; + const title = esc(s.title || '제목 없음'); + return ` +
+ ${title} + × +
+ `; + }).join(''); +} + +// ── 메시지 렌더링 ────────────────────────────────────── +function llmRenderMessages() { + const el = document.getElementById('llm-messages'); + if (!el) return; + + const sess = llmGetActiveSession(); + if (!sess || sess.messages.length === 0) { + const chips = LLM_STARTER_CHIPS.map(q => + `` + ).join(''); + el.innerHTML = ` +
+
💬
+
새 대화를 시작하세요
+
모델을 선택하고 메시지를 입력하세요. 또는 아래 추천 질문을 클릭하세요.
+
${chips}
+
+ `; + if (sess) { + const sel = document.getElementById('llm-model-select'); + if (sel && sess.model) sel.value = sess.model; + const ta = document.getElementById('llm-system-prompt'); + if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; + } + return; + } + + const sel = document.getElementById('llm-model-select'); + if (sel && sess.model) sel.value = sess.model; + const ta = document.getElementById('llm-system-prompt'); + if (ta && sess.systemPrompt) ta.value = sess.systemPrompt; + + const summaryBanner = sess.summary + ? `
+
📋 이전 대화 요약 (${sess.summarizedUpTo || 0}개 메시지 압축)
+
${esc(sess.summary)}
+
` + : ''; + const upTo = sess.summarizedUpTo || 0; + el.innerHTML = summaryBanner + sess.messages.slice(upTo).map(m => { + const role = m.role === 'user' ? 'user' : 'assistant'; + const avatar = role === 'user' ? 'U' : 'AI'; + const content = llmFormatMessage(m.content); + const toolCards = (role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) + ? llmRenderToolCardsHtml(m.toolCalls) + : ''; + return ` +
+
${avatar}
+
${toolCards}${content}
+
+ `; + }).join(''); + + el.scrollTop = el.scrollHeight; +} + +// ── 메시지 포맷팅 (placeholder 패턴으로 esc 순서 보장) ── +function llmFormatMessage(text) { + const blocks = []; + text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, __, code) => { + blocks.push(`
${esc(code.trim())}
`); + return `\x00B${blocks.length - 1}\x00`; + }); + text = text.replace(/`([^`]+)`/g, (_, code) => { + blocks.push(`${esc(code)}`); + return `\x00B${blocks.length - 1}\x00`; + }); + text = esc(text).replace(/\n/g, '
'); + text = text.replace(/\x00B(\d+)\x00/g, (_, i) => blocks[+i]); + return llmLinkKbCitations(text); +} + +/* ── Phase 5: KB 인용 → 다운로드 링크 치환 ─────────────── */ +let llmKbDocMap = {}; // title → docId (search_kb 결과로 누적) + +function llmRegisterKbHits(hits) { + if (!Array.isArray(hits)) return; + for (const h of hits) { + if (h && h.title && h.doc_id) llmKbDocMap[h.title] = h.doc_id; + } +} + +function llmLinkKbCitations(html) { + const titles = Object.keys(llmKbDocMap); + if (titles.length === 0) return html; + // 긴 제목부터 매칭 (부분문자열 겹침 회피) + titles.sort((a, b) => b.length - a.length); + for (const title of titles) { + const docId = llmKbDocMap[title]; + const safeTitle = esc(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(safeTitle, 'g'); + html = html.replace(re, + `${esc(title)} ⬇`); + } + return html; +} + +/* ── Phase 5: 툴 실행 카드 ─────────────────────────────── */ +function llmEnsureStreamingMsgEl() { + let msgEl = document.getElementById('llm-streaming-msg'); + if (msgEl) return msgEl; + const messagesEl = document.getElementById('llm-messages'); + msgEl = document.createElement('div'); + msgEl.className = 'llm-msg assistant'; + msgEl.id = 'llm-streaming-msg'; + msgEl.innerHTML = ` +
AI
+
+ `; + messagesEl.appendChild(msgEl); + return msgEl; +} + +function llmEnsureToolCardContainer() { + const msgEl = llmEnsureStreamingMsgEl(); + let cont = msgEl.querySelector('.llm-tool-cards'); + if (!cont) { + cont = document.createElement('div'); + cont.className = 'llm-tool-cards'; + const bubble = msgEl.querySelector('.llm-msg-bubble'); + msgEl.insertBefore(cont, bubble); + } + return cont; +} + +function llmAppendToolCard(id, name, args) { + const cont = llmEnsureToolCardContainer(); + const argSummary = llmSummarizeArgs(args); + const card = document.createElement('div'); + card.className = 'llm-tool-card running'; + card.dataset.toolId = id; + card.innerHTML = ` +
+ + ${esc(name)} + ${esc(argSummary)} + 실행 중… +
+
+
결과 대기 중…
+
+ `; + cont.appendChild(card); + const messagesEl = document.getElementById('llm-messages'); + if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight; +} + +// 소프트 타임아웃 초과 — 1회 연장 진행 중 알림 +function llmMarkToolCardExtending(id, softSec, extSec) { + const cont = document.querySelector('.llm-tool-cards'); + if (!cont) return; + const card = cont.querySelector(`.llm-tool-card[data-tool-id="${CSS.escape(id)}"]`); + if (!card) return; + card.classList.remove('running'); + card.classList.add('extending'); + const st = card.querySelector('.llm-tool-status'); + if (st) st.textContent = `응답 지연 — 연장 중 (${softSec}s → ${extSec}s)`; +} + +function llmUpdateToolCard(id, name, ok, preview, length, payload) { + const cont = document.querySelector('.llm-tool-cards'); + if (!cont) return; + const card = cont.querySelector(`.llm-tool-card[data-tool-id="${CSS.escape(id)}"]`); + if (!card) return; + card.classList.remove('running', 'extending'); + card.classList.add(ok ? 'ok' : 'err'); + const st = card.querySelector('.llm-tool-status'); + if (st) st.textContent = ok ? `완료 · ${length}자` : '실패'; + const body = card.querySelector('.llm-tool-body'); + if (body) body.innerHTML = llmRenderToolPayload(name, ok, preview, payload); + + // search_kb 결과면 인용 매핑 등록 + if (ok && name === 'search_kb') { + try { + const parsed = JSON.parse(payload); + if (parsed.success && Array.isArray(parsed.hits)) llmRegisterKbHits(parsed.hits); + } catch {} + } +} + +// 영구 보존: sess.messages[*].toolCalls 배열 → 정적 HTML로 재렌더링 +function llmRenderToolCardsHtml(toolCalls) { + const cards = toolCalls.map(tc => { + const ok = tc.ok; + const cls = ok === null || ok === undefined ? 'running' : (ok ? 'ok' : 'err'); + const statusText = ok === null || ok === undefined + ? '실행 중…' + : (ok ? `완료${typeof tc.length === 'number' ? ' · ' + tc.length + '자' : ''}` : '실패'); + const argSummary = llmSummarizeArgs(tc.args); + const body = (ok === null || ok === undefined) + ? '
결과 대기 중…
' + : llmRenderToolPayload(tc.name, ok, tc.preview, tc.payload); + const toolId = tc.id ? `data-tool-id="${esc(tc.id)}"` : ''; + return ` +
+
+ + ${esc(tc.name || '')} + ${esc(argSummary)} + ${esc(statusText)} +
+
${body}
+
+ `; + }).join(''); + return `
${cards}
`; +} + +function llmSummarizeArgs(args) { + if (!args) return ''; + if (typeof args === 'string') { + return args.length > 100 ? args.slice(0, 100) + '…' : args; + } + try { + const s = JSON.stringify(args); + return s.length > 100 ? s.slice(0, 100) + '…' : s; + } catch { return ''; } +} + +function llmRenderToolPayload(name, ok, preview, payload) { + if (!ok) return `
${esc(preview || '오류')}
`; + // JSON 응답이면 표/시계열 자동 렌더 시도 + try { + const j = JSON.parse(payload || preview); + // search_kb 형태 + if (Array.isArray(j.hits)) return llmRenderKbHits(j.hits); + // run_sql/query_with_nl 형태: {success, columns:[], data:[{...}, ...]} + if (j.success && Array.isArray(j.columns) && Array.isArray(j.data)) { + const ts = llmDetectTimeSeries(j.data); + const sparkHtml = ts ? llmBuildSparklineHtml(j.data, ts) : ''; + return sparkHtml + llmRenderTable(j.columns, j.data); + } + // query_pv_history 형태: {success, data:[{tag_name, timestamp, value}, ...]} + if (j.success && Array.isArray(j.data) && j.data.length > 0 && typeof j.data[0] === 'object') { + const cols = Object.keys(j.data[0]); + const ts = llmDetectTimeSeries(j.data); + const sparkHtml = ts ? llmBuildSparklineHtml(j.data, ts) : ''; + return sparkHtml + llmRenderTable(cols, j.data); + } + } catch {} + return `
${esc((preview || '').slice(0, 800))}
`; +} + +// 시계열 감지: data가 {timestamp|recorded_at|ts} + {value|pv|val} 컬럼을 가지면 키 페어 반환 +function llmDetectTimeSeries(data) { + if (!Array.isArray(data) || data.length < 3) return null; + const sample = data[0]; + if (!sample || typeof sample !== 'object') return null; + const timeKeys = ['timestamp', 'recorded_at', 'ts', 'time', 'datetime']; + const valueKeys = ['value', 'pv', 'val', 'fieldvalue', 'sp', 'op']; + const timeKey = timeKeys.find(k => k in sample); + const valueKey = valueKeys.find(k => k in sample && !isNaN(parseFloat(sample[k]))); + if (!timeKey || !valueKey) return null; + // 유효 데이터 수 체크 (timestamp 파싱 가능 + numeric value) + let valid = 0; + for (const r of data) { + const t = new Date(r[timeKey]).getTime(); + const v = parseFloat(r[valueKey]); + if (!isNaN(t) && !isNaN(v)) valid++; + if (valid >= 3) return { timeKey, valueKey }; + } + return null; +} + +// uPlot은 비동기 DOM 부착 후 생성해야 하므로, container를 placeholder로 만들고 +// requestAnimationFrame에서 차트를 그린다. +function llmBuildSparklineHtml(data, keys) { + if (typeof uPlot === 'undefined') return ''; + const id = 'llm-spark-' + (++_llmSparkSeq); + // 데이터를 캐시에 보관 → 비동기 렌더 시 참조 + _llmSparkCache[id] = { data, keys }; + // 다음 프레임에 부착되도록 예약 + requestAnimationFrame(() => llmMountSparkline(id)); + return `
📈 시계열 추세 (${data.length}건)
`; +} + +let _llmSparkSeq = 0; +const _llmSparkCache = {}; + +function llmMountSparkline(id) { + const container = document.getElementById(id); + if (!container) return; + const entry = _llmSparkCache[id]; + if (!entry) return; + delete _llmSparkCache[id]; + if (typeof uPlot === 'undefined') return; + + const { data, keys } = entry; + const pairs = []; + for (const r of data) { + const t = new Date(r[keys.timeKey]).getTime() / 1000; + const v = parseFloat(r[keys.valueKey]); + if (!isNaN(t) && !isNaN(v)) pairs.push([t, v]); + } + if (pairs.length < 2) return; + pairs.sort((a, b) => a[0] - b[0]); + const xs = pairs.map(p => p[0]); + const ys = pairs.map(p => p[1]); + + const opts = { + width: Math.min(container.clientWidth || 360, 600), + height: 90, + cursor: { show: true, points: { show: true } }, + select: { show: false }, + legend: { show: false }, + scales: { x: { time: true } }, + axes: [ + { stroke: '#888', size: 22, grid: { show: false } }, + { stroke: '#888', size: 36, grid: { stroke: 'rgba(255,255,255,0.05)' } } + ], + series: [ + {}, + { + label: keys.valueKey, + stroke: 'rgb(96, 165, 250)', + width: 1.4, + points: { show: false } + } + ] + }; + try { + new uPlot(opts, [xs, ys], container); + } catch (e) { + container.innerHTML = `
차트 렌더 실패
`; + } +} + +function llmRenderTable(columns, data) { + if (!data || data.length === 0) return '
결과 0건
'; + const limit = Math.min(data.length, 50); + const ths = columns.map(c => `${esc(c)}`).join(''); + const rows = data.slice(0, limit).map(row => { + const tds = columns.map(c => { + const v = row[c]; + return `${v == null ? '' : esc(String(v))}`; + }).join(''); + return `${tds}`; + }).join(''); + const more = data.length > limit ? `
…나머지 ${data.length - limit}건 생략
` : ''; + return `
${ths}${rows}
${more}`; +} + +function llmRenderKbHits(hits) { + if (!hits || hits.length === 0) return '
검색 결과 0건
'; + llmRegisterKbHits(hits); + return '
' + hits.slice(0, 8).map(h => { + const score = (h.score || 0).toFixed(3); + const link = h.doc_id + ? `${esc(h.title || '(제목없음)')} ⬇` + : esc(h.title || '(제목없음)'); + const loc = h.locator ? ` · ${esc(h.locator)}` : ''; + const snippet = (h.text || '').slice(0, 200).replace(/\s+/g, ' '); + return `
+
${score} ${link}${loc}
+
${esc(snippet)}…
+
`; + }).join('') + '
'; +} + +// ── 모델 목록 로드 ───────────────────────────────────── +async function llmLoadModels() { + const prefix = llmType === 'vllm' ? '/api/ollama/vllm' : '/api/ollama'; + const label = llmType === 'vllm' ? 'vLLM' : 'Ollama'; + const sel = document.getElementById('llm-model-select'); + if (!sel) return; + + const currentVal = sel.value; + + try { + const d = await api('GET', `${prefix}/models`); + sel.innerHTML = ''; + + if (d.success && d.models) { + d.models.forEach(m => { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = m; + sel.appendChild(opt); + }); + if (currentVal && [...sel.options].some(o => o.value === currentVal)) { + sel.value = currentVal; + } + } + + const dot = document.getElementById('llm-conn-status'); + if (dot) { + dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; + dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패`; + } + } catch (e) { + if (currentVal) sel.value = currentVal; + const dot = document.getElementById('llm-conn-status'); + if (dot) { + dot.className = 'llm-conn-dot error'; + dot.title = `${label} 연결 실패: ` + e.message; + } + } +} + +function llmOnTypeChange() { + llmType = document.getElementById('llm-type-select').value; + localStorage.setItem('llmType', llmType); + llmLoadModels(); + llmLoadMcpTools(); +} + +async function llmLoadMcpTools() { + const useTools = document.getElementById('llm-use-tools'); + const toolsRow = document.getElementById('llm-tools-row'); + const agentRow = document.getElementById('llm-agent-row'); + const agentChk = document.getElementById('llm-agent-mode'); + if (llmType !== 'vllm') { + if (useTools) useTools.checked = false; + if (toolsRow) toolsRow.style.display = 'none'; + if (agentRow) agentRow.style.display = 'none'; + llmUseTools = false; + localStorage.setItem('llmUseTools', 'false'); + return; + } + if (toolsRow) toolsRow.style.display = ''; + // 에이전트 모드 토글은 MCP 도구가 활성일 때만 의미 있음 + if (agentRow) agentRow.style.display = (useTools && useTools.checked) ? '' : 'none'; + if (agentChk) agentChk.checked = !!llmAgentMode; + if (useTools) { + llmUseTools = useTools.checked; + localStorage.setItem('llmUseTools', llmUseTools); + } + if (!llmUseTools) { llmMcpTools = []; return; } + try { + const d = await api('GET', '/api/text-to-sql/tools'); + if (d.success && d.tools) { + llmMcpTools = d.tools.map(t => ({ + type: 'function', + function: { + name: t.Name || t.name, + description: t.Description || t.description || '', + parameters: t.InputSchema || t.inputSchema || { type: 'object', properties: {} } + } + })); + } else { + llmMcpTools = []; + } + } catch { + llmMcpTools = []; + } +} + +function llmToggleTools() { + llmUseTools = document.getElementById('llm-use-tools').checked; + localStorage.setItem('llmUseTools', llmUseTools); + const agentRow = document.getElementById('llm-agent-row'); + if (agentRow) agentRow.style.display = (llmType === 'vllm' && llmUseTools) ? '' : 'none'; +} + +function llmToggleAgentMode() { + const el = document.getElementById('llm-agent-mode'); + llmAgentMode = el && el.checked; + localStorage.setItem('llmAgentMode', llmAgentMode ? 'true' : 'false'); +} + +/* ── Phase 7.2: 대화 요약 ───────────────────────────────── + sess.messages 길이가 임계값을 넘으면 오래된 절반을 LLM에 요약 요청 → sess.summary 보관. + 요약된 만큼 sess.summarizedUpTo (메시지 인덱스, exclusive)를 진전시킨다. + 전송 시: messages는 sess.messages.slice(summarizedUpTo, -1), systemPrompt에 요약 prepend. */ +const LLM_MAX_HISTORY = 20; +const LLM_SUMMARY_KEEP = 10; // 요약 후 유지할 최근 메시지 수 + +async function llmEnsureSummary(sess, model) { + if (!sess) return; + const total = sess.messages.length; + const upTo = sess.summarizedUpTo || 0; + // 활성 메시지 수 (요약되지 않은) 가 임계를 넘으면 요약 + if (total - upTo <= LLM_MAX_HISTORY) return; + + // 요약 대상: [upTo, total - LLM_SUMMARY_KEEP) ← 마지막 placeholder는 호출 시점에 아직 없음 (caller 호출 순서 주의) + const targetEnd = Math.max(upTo + 1, total - LLM_SUMMARY_KEEP); + if (targetEnd <= upTo) return; + + const oldMsgs = sess.messages.slice(upTo, targetEnd) + .filter(m => m && m.content && (m.role === 'user' || m.role === 'assistant')) + .map(m => ({ role: m.role, content: m.content })); + if (oldMsgs.length < 2) return; + + // 기존 summary가 있다면 함께 넘겨 누적 요약 + const merged = sess.summary + ? [{ role: 'system', content: '[이전 요약]\n' + sess.summary }, ...oldMsgs] + : oldMsgs; + + try { + const r = await api('POST', '/api/ollama/summarize', { model, messages: merged }); + if (r && r.success && r.summary) { + sess.summary = r.summary; + sess.summarizedUpTo = targetEnd; + llmSaveSessions(); + } + } catch (e) { + // 요약 실패는 무시 (다음 턴에 재시도) + console.warn('summarize failed:', e); + } +} + +// ── 메시지 전송 (스트리밍) ───────────────────────────── +async function llmSend() { + const input = document.getElementById('llm-input'); + const text = input.value.trim(); + if (!text || llmIsStreaming) return; + + let sess = llmGetActiveSession(); + if (!sess) { + sess = llmCreateSession(); + } + + const model = document.getElementById('llm-model-select').value; + if (!model) { + alert('모델을 선택하세요.'); + return; + } + + const systemPrompt = document.getElementById('llm-system-prompt').value.trim(); + + // 첫 번째 메시지 시 세션 제목 자동 갱신 + if (sess.messages.length === 0 && sess.title === '새 대화') { + sess.title = text.slice(0, 30) + (text.length > 30 ? '…' : ''); + } + + sess.messages.push({ role: 'user', content: text }); + input.value = ''; + input.style.height = 'auto'; + llmSaveSessions(); + llmRenderMessages(); + llmRenderSessionList(); + + sess.model = model; + sess.systemPrompt = systemPrompt; + + // Phase 7.2: 임계 초과 시 오래된 메시지 요약 (assistant placeholder 추가 전에 실행) + await llmEnsureSummary(sess, model); + + const assistantMsg = { role: 'assistant', content: '', toolCalls: [] }; + sess.messages.push(assistantMsg); + + llmIsStreaming = true; + llmUpdateButtons(); + llmRenderMessages(); + + const messagesEl = document.getElementById('llm-messages'); + const typingDiv = document.createElement('div'); + typingDiv.className = 'llm-msg assistant'; + typingDiv.id = 'llm-streaming-msg'; + typingDiv.innerHTML = ` +
AI
+
+
+
+ `; + messagesEl.appendChild(typingDiv); + messagesEl.scrollTop = messagesEl.scrollHeight; + + llmAbortController = new AbortController(); + + try { + const prefix = llmType === 'vllm' ? '/api/ollama/vllm' : '/api/ollama'; + const upTo = sess.summarizedUpTo || 0; + const activeMsgs = sess.messages.slice(upTo, -1); + let effectiveSystemPrompt = systemPrompt || ''; + if (sess.summary) { + const prefixBlock = `[이전 대화 요약 — 사용자에게 보이지 않음]\n${sess.summary}`; + effectiveSystemPrompt = effectiveSystemPrompt + ? `${prefixBlock}\n\n${effectiveSystemPrompt}` + : prefixBlock; + } + const requestBody = { + model, + messages: activeMsgs, + systemPrompt: effectiveSystemPrompt || undefined + }; + if (llmType === 'vllm' && llmUseTools && llmMcpTools.length > 0) { + requestBody.tools = llmMcpTools; + if (llmAgentMode) requestBody.agentMode = true; + } + const res = await fetch(`${prefix}/chat/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signal: llmAbortController.signal + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + let streamDone = false; + + while (true) { + if (streamDone) break; + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + + for (const part of parts) { + if (streamDone) break; + const lines = part.split('\n'); + let eventType = 'message'; + let eventData = ''; + for (const line of lines) { + if (line.startsWith('event: ')) { + eventType = line.slice(7).trim(); + } else if (line.startsWith('data: ')) { + eventData = line.slice(6); + } + } + + if (eventType === 'error') { + throw new Error(eventData || '스트리밍 오류'); + } + if (eventType === 'done') { + streamDone = true; + break; + } + + if (eventType === 'tool_start') { + try { + const t = JSON.parse(eventData); + llmAppendToolCard(t.id, t.name, t.args); + assistantMsg.toolCalls.push({ + id: t.id, name: t.name, args: t.args, + ok: null, preview: null, length: null, payload: null + }); + } catch {} + continue; + } + + if (eventType === 'tool_extending') { + try { + const t = JSON.parse(eventData); + llmMarkToolCardExtending(t.id, t.softTimeoutSec, t.extendedTimeoutSec); + const tc = assistantMsg.toolCalls.find(x => x.id === t.id); + if (tc) { + tc.extended = true; + tc.softTimeoutSec = t.softTimeoutSec; + tc.extendedTimeoutSec = t.extendedTimeoutSec; + } + } catch {} + continue; + } + + if (eventType === 'tool_result') { + try { + const t = JSON.parse(eventData); + llmUpdateToolCard(t.id, t.name, t.ok, t.preview, t.length, t.payload); + const tc = assistantMsg.toolCalls.find(x => x.id === t.id); + if (tc) { + tc.ok = t.ok; + tc.preview = t.preview; + tc.length = t.length; + tc.payload = t.payload; + } else { + assistantMsg.toolCalls.push({ + id: t.id, name: t.name, args: null, + ok: t.ok, preview: t.preview, length: t.length, payload: t.payload + }); + } + } catch {} + continue; + } + + // 일반 message 이벤트 + if (eventData && eventData !== '{}') { + try { + const json = JSON.parse(eventData); + if (json.message && json.message.content) { + assistantMsg.content += json.message.content; + llmUpdateStreamingMessage(assistantMsg.content); + } else if (json.response) { + assistantMsg.content += json.response; + llmUpdateStreamingMessage(assistantMsg.content); + } + } catch { + } + } + } + } + + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + llmRenderSessionList(); + + } catch (e) { + if (e.name === 'AbortError') { + if (assistantMsg.content || (assistantMsg.toolCalls && assistantMsg.toolCalls.length > 0)) { + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + } else { + sess.messages.pop(); + llmSaveSessions(); + llmRenderMessages(); + } + } else { + assistantMsg.content = `❌ 오류: ${e.message}`; + sess.messages.pop(); + sess.messages.push(assistantMsg); + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + } + } finally { + llmIsStreaming = false; + llmAbortController = null; + llmUpdateButtons(); + } +} + +function llmUpdateStreamingMessage(content) { + let msgEl = document.getElementById('llm-streaming-msg'); + if (!msgEl) { + const messagesEl = document.getElementById('llm-messages'); + msgEl = document.createElement('div'); + msgEl.className = 'llm-msg assistant'; + msgEl.id = 'llm-streaming-msg'; + msgEl.innerHTML = ` +
AI
+
+ `; + messagesEl.appendChild(msgEl); + } + + const bubble = msgEl.querySelector('.llm-msg-bubble'); + if (bubble) { + bubble.innerHTML = llmFormatMessage(content); + } + + const messagesEl = document.getElementById('llm-messages'); + if (messagesEl) { + messagesEl.scrollTop = messagesEl.scrollHeight; + } +} + +function llmStop() { + if (llmAbortController) { + llmAbortController.abort(); + } +} + +function llmUpdateButtons() { + const sendBtn = document.getElementById('llm-send-btn'); + const stopBtn = document.getElementById('llm-stop-btn'); + if (sendBtn) sendBtn.disabled = llmIsStreaming; + if (stopBtn) stopBtn.style.display = llmIsStreaming ? 'inline-flex' : 'none'; +} + +// ── 입력 키 처리 ─────────────────────────────────────── +function llmInputKeydown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + llmSend(); + } + const ta = e.target; + setTimeout(() => { + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'; + }, 0); +} + +// ── 세션 초기화 ──────────────────────────────────────── +function llmClearSession() { + const sess = llmGetActiveSession(); + if (!sess) return; + if (!confirm('현재 대화의 메시지를 모두 지우시겠습니까?')) return; + sess.messages = []; + sess.updatedAt = new Date().toISOString(); + llmSaveSessions(); + llmRenderMessages(); + llmRenderSessionList(); +} + +// ── 설정 ─────────────────────────────────────────────── +function llmToggleSettings() { + const panel = document.getElementById('llm-settings-panel'); + if (panel) { + const opening = panel.classList.contains('hidden'); + panel.classList.toggle('hidden'); + if (opening) llmLoadConfigToUI(); + } +} + +async function llmSaveConfig() { + const host = document.getElementById('llm-host').value.trim() || 'localhost'; + const port = parseInt(document.getElementById('llm-port').value) || 11434; + try { + const d = await api('POST', '/api/ollama/config', { host, port }); + if (d.success) { + alert('설정 저장 완료. 변경 사항 적용을 위해 페이지를 새로고침하세요.'); + } else { + alert('설정 저장 실패: ' + (d.error || '알 수 없는 오류')); + } + } catch (e) { + alert('설정 저장 실패: ' + e.message); + } +} + +async function llmTestConnection() { + const prefix = llmType === 'vllm' ? '/api/ollama/vllm' : '/api/ollama'; + const label = llmType === 'vllm' ? 'vLLM' : 'Ollama'; + try { + const d = await api('GET', `${prefix}/ping`); + const dot = document.getElementById('llm-conn-status'); + if (dot) { + dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; + dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패: ${d.error || ''}`; + } + alert(d.success ? `${label} 연결 성공!` : `${label} 연결 실패: ${d.error || ''}`); + } catch (e) { + alert(`${label} 연결 테스트 실패: ` + e.message); + } +} + +function llmLoadConfigToUI() { + api('GET', '/api/ollama/config').then(d => { + if (d.success) { + const hostEl = document.getElementById('llm-host'); + const portEl = document.getElementById('llm-port'); + if (hostEl && d.host) hostEl.value = d.host; + if (portEl && d.port) portEl.value = d.port; + } + }).catch(() => {}); + + api('GET', '/api/llm/config').then(d => { + if (d.success && d.vllmModel) { + const sel = document.getElementById('llm-model-select'); + if (sel && !sel.value) sel.value = d.vllmModel; + } + }).catch(() => {}); +} + +function llmLoadActiveSession() { + if (llmActiveSessionId && !llmSessions.find(s => s.id === llmActiveSessionId)) { + llmActiveSessionId = ''; + localStorage.setItem('llmActiveSessionId', ''); + } +} + +// ── 전체 내보내기 ────────────────────────────────────── +function llmExportAll() { + if (llmSessions.length === 0) { + alert('내보낼 대화가 없습니다.'); + return; + } + const text = llmSessions.map(s => { + const header = `=== ${s.title} (${new Date(s.updatedAt).toLocaleString('ko-KR')}) ===`; + const msgs = s.messages.map(m => `[${m.role}] ${m.content}`).join('\n\n'); + return `${header}\n\n${msgs}`; + }).join('\n\n' + '='.repeat(50) + '\n\n'); + + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `llm-chat-${new Date().toISOString().slice(0, 10)}.txt`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/Web/wwwroot/js/nm-dash.js b/src/Web/wwwroot/js/nm-dash.js new file mode 100644 index 0000000..3681583 --- /dev/null +++ b/src/Web/wwwroot/js/nm-dash.js @@ -0,0 +1,120 @@ +/* ───────────────────────────────────────────────────────────── + 05 노드맵 대시보드 +───────────────────────────────────────────────────────────── */ +let _nmOffset = 0; +let _nmTotal = 0; +let _nmLimit = 100; + +// 이름 드롭다운 옵션만 필요할 때 수동으로 불러오는 함수 (버튼 클릭 시) +async function nmLoadNames() { + try { + const n = await api('GET', '/api/nodemap/names'); + const nameOpts = '' + + (n.names||[]).map(nm => ``).join(''); + ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => { + const el = document.getElementById(id); + const cur = el.value; + el.innerHTML = nameOpts; + if (cur) el.value = cur; + }); + } catch (e) { console.error('nmLoadNames:', e); } +} + +async function nmQuery(offset) { + _nmOffset = offset ?? 0; + _nmLimit = parseInt(document.getElementById('nf-limit').value) || 100; + + const params = new URLSearchParams(); + const lvMin = document.getElementById('nf-lv-min').value.trim(); + const lvMax = document.getElementById('nf-lv-max').value.trim(); + const cls = document.getElementById('nf-class').value; + const nid = document.getElementById('nf-nid').value.trim(); + const dtype = document.getElementById('nf-dtype').value.trim(); + const selNames = ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'] + .map(id => document.getElementById(id).value) + .filter(Boolean); + + if (lvMin) params.set('minLevel', lvMin); + if (lvMax) params.set('maxLevel', lvMax); + if (cls) params.set('nodeClass', cls); + selNames.forEach(nm => params.append('names', nm)); + if (nid) params.set('nodeId', nid); + if (dtype) params.set('dataType', dtype); + params.set('limit', _nmLimit); + params.set('offset', _nmOffset); + + setGlobal('busy', '조회 중'); + try { + const d = await api('GET', `/api/nodemap/query?${params}`); + _nmTotal = d.total; + + // 결과 바 + const bar = document.getElementById('nm-result-bar'); + bar.classList.remove('hidden'); + const from = _nmOffset + 1; + const to = Math.min(_nmOffset + _nmLimit, _nmTotal); + document.getElementById('nm-result-info').textContent = + `총 ${_nmTotal.toLocaleString()}건 중 ${from.toLocaleString()}–${to.toLocaleString()}건`; + + const totalPages = Math.ceil(_nmTotal / _nmLimit) || 1; + const curPage = Math.floor(_nmOffset / _nmLimit) + 1; + document.getElementById('nm-pg-info').textContent = `${curPage} / ${totalPages} 페이지`; + document.getElementById('nm-pg-prev').disabled = _nmOffset === 0; + document.getElementById('nm-pg-next').disabled = to >= _nmTotal; + + // 테이블 렌더 + const tbl = document.getElementById('nm-table'); + tbl.classList.remove('hidden'); + if (d.items?.length) { + tbl.innerHTML = ` + + + + + + + + + ${d.items.map(r => ` + + + + + + + + + `).join('')} + +
IDLevelClassNameNode IDData Type
${r.id}${r.level}${esc(r.class)}${esc(r.name)}${esc(r.nodeId)}${esc(r.dataType)}
+ `; + } else { + tbl.innerHTML = '
조건에 맞는 노드가 없습니다.
'; + } + setGlobal('ok', `${_nmTotal.toLocaleString()}건`); + } catch (e) { + setGlobal('err', '조회 실패'); + console.error('nmQuery:', e); + } +} + +function nmPrev() { + if (_nmOffset > 0) nmQuery(Math.max(0, _nmOffset - _nmLimit)); +} +function nmNext() { + if (_nmOffset + _nmLimit < _nmTotal) nmQuery(_nmOffset + _nmLimit); +} +function nmReset() { + document.getElementById('nf-lv-min').value = ''; + document.getElementById('nf-lv-max').value = ''; + document.getElementById('nf-class').value = ''; + document.getElementById('nf-nid').value = ''; + document.getElementById('nf-dtype').value = ''; + document.getElementById('nf-limit').value = '100'; + ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => { + document.getElementById(id).value = ''; + }); + // 초기화 후 자동 조회 없음 + document.getElementById('nm-result-bar').classList.add('hidden'); + document.getElementById('nm-table').classList.add('hidden'); +} diff --git a/src/Web/wwwroot/js/opcsvr.js b/src/Web/wwwroot/js/opcsvr.js new file mode 100644 index 0000000..9c74b86 --- /dev/null +++ b/src/Web/wwwroot/js/opcsvr.js @@ -0,0 +1,85 @@ +/* ── OPC UA 서버 ─────────────────────────────────────────────── */ +paneInit.opcsvr = srvLoad; + +let _srvPollTimer = null; + +async function srvLoad() { + try { + const s = await api('GET', '/api/opcserver/status'); + _srvRender(s); + } catch (e) { + srvLog([{ c:'err', t:`상태 조회 실패: ${e.message}` }]); + } +} + +async function srvStart() { + srvLog([{ c:'info', t:'OPC UA 서버 시작 중...' }]); + try { + const r = await api('POST', '/api/opcserver/start'); + srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]); + await srvLoad(); + _srvStartPoll(); + } catch (e) { + srvLog([{ c:'err', t:`시작 실패: ${e.message}` }]); + } +} + +async function srvStop() { + srvLog([{ c:'info', t:'OPC UA 서버 중지 중...' }]); + try { + const r = await api('POST', '/api/opcserver/stop'); + srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]); + _srvStopPoll(); + await srvLoad(); + } catch (e) { + srvLog([{ c:'err', t:`중지 실패: ${e.message}` }]); + } +} + +async function srvRebuild() { + srvLog([{ c:'info', t:'주소 공간 재구성 중...' }]); + try { + const r = await api('POST', '/api/opcserver/rebuild'); + srvLog([{ c: r.success ? 'ok' : 'err', t: `재구성 완료 — ${r.nodeCount}개 노드` }]); + await srvLoad(); + } catch (e) { + srvLog([{ c:'err', t:`재구성 실패: ${e.message}` }]); + } +} + +function _srvRender(s) { + const dot = document.getElementById('srv-dot'); + const txt = document.getElementById('srv-status-txt'); + const meta = document.getElementById('srv-meta'); + const ndot = document.getElementById('opcsvr-dot'); + + if (s.running) { + dot.className = 'dot grn'; + txt.textContent = '● 실행 중'; + if (ndot) ndot.className = 'nb grn'; + } else { + dot.className = 'dot'; + txt.textContent = '○ 중지됨'; + if (ndot) ndot.className = 'nb'; + } + + const started = s.startedAt ? new Date(s.startedAt).toLocaleString('ko-KR') : '—'; + meta.innerHTML = + `엔드포인트: ${esc(s.endpointUrl || '—')}` + + `접속 클라이언트: ${s.connectedClientCount}` + + `노드 수: ${s.nodeCount}` + + `시작 시각: ${started}`; +} + +function srvLog(lines) { + log('srv-log', lines); +} + +function _srvStartPoll() { + _srvStopPoll(); + _srvPollTimer = setInterval(srvLoad, 5000); +} + +function _srvStopPoll() { + if (_srvPollTimer) { clearInterval(_srvPollTimer); _srvPollTimer = null; } +} diff --git a/src/Web/wwwroot/js/pb.js b/src/Web/wwwroot/js/pb.js new file mode 100644 index 0000000..8444efd --- /dev/null +++ b/src/Web/wwwroot/js/pb.js @@ -0,0 +1,503 @@ +/* ───────────────────────────────────────────────────────────── + 06 포인트빌더 +───────────────────────────────────────────────────────────── */ +const PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom']; +let pbPreviewData = []; + +function pbCollectGroupData(groupKey) { + const patternInput = document.querySelector(`input[data-group="${groupKey}"][data-field="tagPatterns"]`); + const tagPatterns = patternInput?.value + ? patternInput.value.split(',').map(s => s.trim()).filter(Boolean) + : []; + + const checkedAttrs = Array.from( + document.querySelectorAll(`input[data-group="${groupKey}"][data-field="attributes"]:checked`) + ).map(cb => cb.value); + + const customInputs = document.querySelectorAll(`input[data-group="${groupKey}"][data-field="customAttrs"]`); + customInputs.forEach(inp => { + const v = inp.value.trim(); + if (v) checkedAttrs.push(v); + }); + + const dataTypeEl = document.querySelector(`select[data-group="${groupKey}"][data-field="dataType"]`); + const dataType = dataTypeEl?.value || null; + + return { tagPatterns, attributes: checkedAttrs, dataType }; +} + +async function pbBuild() { + const groups = {}; + for (const gk of PB_GROUPS) { + const gd = pbCollectGroupData(gk); + if (gd.tagPatterns.length > 0) { + groups[gk] = gd; + } + } + + const activeKeys = Object.keys(groups); + if (activeKeys.length === 0) { + const logEl = document.getElementById('pb-build-log'); + logEl.classList.remove('hidden'); + logEl.innerHTML = '
⚠️ 태그명 패턴을 최소 1개 입력하세요.
'; + return; + } + + setGlobal('busy', '포인트 빌드 중'); + try { + const d = await api('POST', '/api/pointbuilder/build', groups); + const logEl = document.getElementById('pb-build-log'); + logEl.classList.remove('hidden'); + logEl.innerHTML = `
${d.success ? '✅' : '❌'} ${esc(d.message)}
`; + setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트` : '빌드 실패'); + if (d.success) await pbRefresh(); + } catch (e) { + const logEl = document.getElementById('pb-build-log'); + logEl.classList.remove('hidden'); + logEl.innerHTML = `
❌ ${esc(e.message)}
`; + setGlobal('err', '오류'); + } +} + +async function pbPreview() { + const groups = {}; + for (const gk of PB_GROUPS) { + const gd = pbCollectGroupData(gk); + if (gd.tagPatterns.length > 0) { + groups[gk] = gd; + } + } + + const activeKeys = Object.keys(groups); + if (activeKeys.length === 0) { + const logEl = document.getElementById('pb-build-log'); + logEl.classList.remove('hidden'); + logEl.innerHTML = '
⚠️ 태그명 패턴을 최소 1개 입력하세요.
'; + return; + } + + setGlobal('busy', '미리보기 조회 중'); + try { + const d = await api('POST', '/api/pointbuilder/preview', groups); + pbPreviewData = (d.items || []).map((item, idx) => ({ ...item, selected: true, idx })); + document.getElementById('pb-preview-count').textContent = `(${d.count}개)`; + document.getElementById('pb-preview').classList.remove('hidden'); + pbRenderPreview(pbPreviewData); + setGlobal('ok', `미리보기: ${d.count}개 포인트`); + } catch (e) { + setGlobal('err', '미리보기 실패'); + } +} + +function pbRenderPreview(data) { + const el = document.getElementById('pb-preview-table'); + const filtered = pbGetFilteredPreview(); + const pts = filtered.length > 0 ? filtered : data; + + if (pts.length === 0) { + el.innerHTML = '
조건에 맞는 포인트가 없습니다.
'; + pbUpdatePreviewCount(); + return; + } + + el.innerHTML = ` + + + + + + + + + + + + + ${pts.map((p, i) => ` + + + + + + + + + `).join('')} + +
IDTagNameNodeTypeDataTypeGroup
${i + 1}${esc((p?.tagName)?.toUpperCase() || '')}${esc(p?.name || '')}${esc(p?.dataType || '')}${esc(p?.group || '')}
+ `; + pbUpdatePreviewCount(); +} + +function pbPreviewToggleItem(idx) { + pbPreviewData[idx].selected = !pbPreviewData[idx].selected; + pbRenderPreview(pbPreviewData); +} + +function pbPreviewToggleAll(checked) { + pbPreviewData.forEach((item) => { + const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase(); + if (searchVal) { + const filtered = pbGetFilteredPreview(); + if (filtered.includes(item)) { + item.selected = checked; + } + } else { + item.selected = checked; + } + }); + pbRenderPreview(pbPreviewData); +} + +function pbPreviewSelectAll() { + pbPreviewData.forEach(p => p.selected = true); + pbRenderPreview(pbPreviewData); +} + +function pbPreviewDeselectAll() { + pbPreviewData.forEach(p => p.selected = false); + pbRenderPreview(pbPreviewData); +} + +function pbPreviewInvert() { + pbPreviewData.forEach(p => p.selected = !p.selected); + pbRenderPreview(pbPreviewData); +} + +function pbGetFilteredPreview() { + const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase(); + if (!searchVal) return []; + return pbPreviewData.filter(p => + (p.tagName || '').toLowerCase().includes(searchVal) || + (p.nodeId || '').toLowerCase().includes(searchVal) || + (p.name || '').toLowerCase().includes(searchVal) + ); +} + +function pbPreviewFilter() { + pbRenderPreview(pbPreviewData); +} + +function pbUpdatePreviewCount() { + const selected = pbPreviewData.filter(p => p.selected).length; + const total = pbPreviewData.length; + document.getElementById('pb-preview-selected').textContent = `선택: ${selected}/${total}`; +} + +function pbCancelPreview() { + document.getElementById('pb-preview').classList.add('hidden'); + pbPreviewData = []; +} + +async function pbApplySelected() { + const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId); + if (selected.length === 0) { + setGlobal('err', '적용할 포인트를 선택하세요.'); + return; + } + + setGlobal('busy', `${selected.length}개 포인트 적용 중`); + try { + const d = await api('POST', '/api/pointbuilder/apply', { selectedNodeIds: selected }); + setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 적용 완료` : '적용 실패'); + if (d.success) { + pbCancelPreview(); + await pbRefresh(); + } + } catch (e) { + setGlobal('err', '적용 오류'); + } +} + +async function pbAppendSelected() { + const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId); + if (selected.length === 0) { + setGlobal('err', '추가할 포인트를 선택하세요.'); + return; + } + + setGlobal('busy', `${selected.length}개 포인트 추가 중`); + try { + const d = await api('POST', '/api/pointbuilder/append', { selectedNodeIds: selected }); + setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 추가 완료` : '추가 실패'); + if (d.success) { + pbCancelPreview(); + await pbRefresh(); + } + } catch (e) { + setGlobal('err', '추가 오류'); + } +} + +async function pbRefresh() { + try { + const d = await api('GET', '/api/pointbuilder/points'); + document.getElementById('pb-count').textContent = `(${d.total}개)`; + pbRender(d.items || []); + rtStatus(); + } catch (e) { console.error('pbRefresh:', e); } +} + +function pbRender(points) { + const tbl = document.getElementById('pb-table'); + const pts = Array.isArray(points) ? points : []; + if (pts.length === 0) { + tbl.innerHTML = '
포인트가 없습니다. 위에서 테이블을 작성하세요.
'; + return; + } + tbl.innerHTML = ` + + + + + + + + + ${pts.map(p => ` + + + + + + + + `).join('')} + +
IDTagNameLiveValueTimestamp이력 / 삭제
${esc(p?.id || '')}${esc((p?.tagName)?.toUpperCase() || '')}${p?.liveValue != null ? esc(String(fmtVal(parseEnumPv(p.liveValue)))) : ''}${p?.liveValue != null ? fmtTs(p.timestamp) : '—'} + + +
+ `; +} + +async function pbAddManual() { + const nodeId = document.getElementById('pb-manual-nid').value.trim(); + if (!nodeId) { alert('Node ID를 입력하세요.'); return; } + try { + const d = await api('POST', '/api/pointbuilder/add', { nodeId }); + log('pb-manual-log', [{ c: d.success ? 'ok' : 'err', t: d.success + ? `✅ 추가됨: ${d.point?.tagName} (${d.point?.nodeId})` + : '❌ 추가 실패' }]); + if (d.success) { + document.getElementById('pb-manual-nid').value = ''; + await pbRefresh(); + } + } catch (e) { + log('pb-manual-log', [{ c: 'err', t: '❌ ' + e.message }]); + } +} + +async function pbDelete(id, tagName) { + const label = tagName ? `${tagName.toUpperCase()} (#${id})` : `#${id}`; + // 행의 "이력" 체크박스 = 이력 함께 삭제 opt-in (기본 미체크 = 이력 보존) + const purgeHistory = !!document.getElementById('pb-hist-' + id)?.checked; + + const msg = purgeHistory + ? `포인트 ${label} 삭제 + 이력 영구 삭제\n\n· realtime 포인트 삭제\n· 잔여 포인트 없으면 메타데이터(area/sub_area) 정리\n· ⚠️ 이력(history_table)까지 영구 삭제 — 복구 불가\n\n계속하시겠습니까?` + : `포인트 ${label}를 삭제하시겠습니까?\n\n· realtime 포인트 삭제\n· 잔여 포인트 없으면 메타데이터(area/sub_area) 정리\n· 이력(history)은 보존 ('이력' 체크 시 함께 삭제)`; + if (!confirm(msg)) return; + + try { + const d = await api('DELETE', `/api/pointbuilder/${id}?purgeHistory=${purgeHistory}`); + if (d.success) { + const parts = []; + if (d.metadataPurged) parts.push('메타데이터 정리됨'); + if (d.historyRowsDeleted > 0) parts.push(`이력 ${d.historyRowsDeleted.toLocaleString()}행 삭제`); + else if (purgeHistory) parts.push('이력 없음'); + else parts.push('이력 보존'); + alert(`삭제 완료: ${label}\n(${parts.join(' · ')})`); + await pbRefresh(); + } else { + alert('삭제 실패: ' + d.message); + } + } catch (e) { alert('삭제 오류: ' + e.message); } +} + +/* ── 실시간 구독 제어 ────────────────────────────────────────── */ +async function rtStart() { + const body = { + serverHostName: document.getElementById('pb-rt-ip').value.trim(), + port: parseInt(document.getElementById('pb-rt-port').value) || 4840, + clientHostName: document.getElementById('pb-rt-client').value.trim(), + userName: document.getElementById('pb-rt-user').value.trim(), + password: document.getElementById('pb-rt-pw').value + }; + setGlobal('busy', '구독 시작 중'); + try { + const d = await api('POST', '/api/realtime/start', body); + log('pb-rt-status', [{ c: 'ok', t: '▶ ' + d.message }]); + setGlobal('ok', '구독 중'); + } catch (e) { + log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } +} + +async function rtStop() { + setGlobal('busy', '구독 중지 중'); + try { + const d = await api('POST', '/api/realtime/stop'); + log('pb-rt-status', [{ c: 'inf', t: '■ ' + d.message }]); + setGlobal('ok', '중지됨'); + } catch (e) { log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]); } +} + +async function rtStatus() { + try { + const d = await api('GET', '/api/realtime/status'); + const logEl = document.getElementById('pb-rt-status'); + logEl.classList.remove('hidden'); + logEl.innerHTML = `
+ ${d.running ? '▶' : '■'} ${esc(d.message)} (구독 ${d.subscribedCount}개) +
`; + } catch (e) { /* 무시 */ } +} + +/* ── 메타데이터 관리 ─────────────────────────────────────────── */ + +async function metaReload() { + const body = { + serverHostName: document.getElementById('pb-rt-ip').value.trim(), + port: parseInt(document.getElementById('pb-rt-port').value) || 4840, + clientHostName: document.getElementById('pb-rt-client').value.trim(), + userName: document.getElementById('pb-rt-user').value.trim(), + password: document.getElementById('pb-rt-pw').value + }; + const logEl = document.getElementById('meta-log'); + logEl.classList.remove('hidden'); + logEl.innerHTML = '
⏳ 메타데이터 갱신 중...
'; + try { + const d = await api('POST', '/api/tags/metadata/reload', body); + logEl.innerHTML = `
${d.success ? '✅' : '❌'} ${esc(d.message)}
`; + } catch (e) { + logEl.innerHTML = `
❌ ${esc(e.message)}
`; + } +} + +async function metaView() { + const viewEl = document.getElementById('meta-view'); + viewEl.classList.remove('hidden'); + viewEl.innerHTML = '
⏳ 조회 중...
'; + try { + const d = await api('GET', '/api/tags/metadata'); + const items = d.items || []; + if (items.length === 0) { + viewEl.innerHTML = '
메타데이터가 없습니다.
'; + return; + } + viewEl.innerHTML = ` + + + + ${items.map(m => ` + + + + + + + `).join('')} + +
BaseTagAttributeValueLoadedAt
${esc(m.baseTag)}${esc(m.attribute)}${esc(m.value || '-')}${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}
+ `; + } catch (e) { + viewEl.innerHTML = `
❌ ${esc(e.message)}
`; + } +} + +/* ── Sub-Area (세부 Area) 관리 ───────────────────────────────── */ +// area별 선택 가능한 sub_area 옵션 (마지막은 공용 = 두 코드) +const SUBAREA_OPTIONS = { + P6: ['P6-1', 'P6-2', 'P6-1,P6-2'], + P9: ['P9-1', 'P9-2', 'P9-1,P9-2'], + P10: ['P10-1', 'P10-2', 'P10-1,P10-2'], + P1: ['P1-1', 'P1-2', 'P1-1,P1-2'], + P2: ['P2-1', 'P2-2', 'P2-1,P2-2'], +}; + +function subAreaLabel(code) { + if (!code) return '(미분류)'; + return code.includes(',') ? `${code} (공용)` : code; +} + +async function subAreaLoad() { + const area = document.getElementById('subarea-area-select').value; + const viewEl = document.getElementById('subarea-view'); + viewEl.classList.remove('hidden'); + viewEl.innerHTML = '
⏳ 조회 중...
'; + try { + const d = await api('GET', `/api/tags/sub-area?area=${encodeURIComponent(area)}&page=1&pageSize=500`); + const tags = d.tags || []; + if (tags.length === 0) { + viewEl.innerHTML = '
태그가 없습니다.
'; + return; + } + const opts = SUBAREA_OPTIONS[area] || []; + viewEl.innerHTML = ` +
총 ${d.total}개 · 미분류는 (미분류)로 두면 NULL 유지
+ + + + ${tags.map(t => { + const cur = t.subArea || ''; + const optionHtml = ['', ...opts].map(o => + `` + ).join(''); + // 현재값이 옵션에 없으면(수동 지정 등) 추가 + const extra = (cur && !opts.includes(cur)) + ? `` : ''; + return ` + + + + + `; + }).join('')} + +
BaseTagDescriptionSub-Area
${esc(t.baseTag)}${esc(t.description || '-')} + +
`; + } catch (e) { + viewEl.innerHTML = `
❌ ${esc(e.message)}
`; + } +} + +async function subAreaUpdate(baseTag, subArea) { + const logEl = document.getElementById('subarea-log'); + logEl.classList.remove('hidden'); + try { + const d = await api('PUT', '/api/tags/sub-area', { baseTag, subArea: subArea || null }); + logEl.innerHTML = `
${d.success ? '✅' : '❌'} ${esc(baseTag)} → ${esc(subAreaLabel(subArea))}
`; + } catch (e) { + logEl.innerHTML = `
❌ ${esc(e.message)}
`; + } +} + +async function subAreaSeed(dryRun) { + const msg = dryRun + ? 'DryRun으로 분류 결과만 미리 봅니다(저장 안 함). 계속할까요?' + : '실제 seed를 실행합니다(기존 sub_area는 보존, 신규만 추가). 계속할까요?'; + if (!confirm(msg)) return; + const logEl = document.getElementById('subarea-log'); + logEl.classList.remove('hidden'); + logEl.innerHTML = `
⏳ Seed ${dryRun ? 'DryRun' : '실행'} 중...
`; + try { + const d = await api('POST', '/api/tags/sub-area/seed', { dryRun }); + const by = d.bySubArea || {}; + const byStr = Object.keys(by).sort().map(k => `${k}=${by[k]}`).join(', '); + logEl.innerHTML = ` +
+ ${d.success ? '✅' : '❌'} ${dryRun ? '[DryRun] ' : ''}단일 ${d.assigned} · 공용 ${d.shared} · 미분류 ${d.unmatched} +
+
분류별: ${esc(byStr || '-')}
`; + subAreaLoad(); + } catch (e) { + logEl.innerHTML = `
❌ ${esc(e.message)}
`; + } +} diff --git a/src/Web/wwwroot/js/pid.js b/src/Web/wwwroot/js/pid.js new file mode 100644 index 0000000..f272e59 --- /dev/null +++ b/src/Web/wwwroot/js/pid.js @@ -0,0 +1,572 @@ +/* ───────────────────────────────────────────────────────────── + 11 P&ID 추출 +───────────────────────────────────────────────────────────── */ +let pidCurrentPage = 1; +let pidPageSize = 20; +let pidLastResult = null; // Excel export용 + +async function pidUpload() { + const fileInput = document.getElementById('pid-file-input'); + const file = fileInput.files[0]; + const statusEl = document.getElementById('pid-upload-status'); + + if (!file) { + if (statusEl) statusEl.textContent = '❌ 파일을 선택하세요.'; + return; + } + const ext = file.name.toLowerCase().split('.').pop(); + if (!['dxf', 'pdf'].includes(ext)) { + if (statusEl) statusEl.textContent = '❌ 지원 형식: .dxf, .pdf'; + return; + } + + if (statusEl) statusEl.textContent = `📤 전송 중... (${(file.size / 1024).toFixed(0)} KB)`; + + try { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch('/api/pid/upload', { method: 'POST', body: formData }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + const data = await res.json(); + if (statusEl) statusEl.textContent = `✅ 전송 완료: ${data.fileName} (${(data.fileSize / 1024).toFixed(0)} KB)`; + + await pidLoadServerFiles(data.fileName); + } catch (e) { + if (statusEl) statusEl.textContent = `❌ 전송 실패: ${e.message}`; + } +} + +async function pidLoadServerFiles(selectFileName) { + const sel = document.getElementById('pid-server-file'); + if (!sel) return; + + try { + const res = await fetch('/api/pid/server-files'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + // 응답이 JSON인지 확인 + const contentType = res.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await res.text(); + // HTML이 반환되면 P&ID 컨트롤러가 비활성화된 것 + if (text.startsWith('-- 서버에 파일 없음 --'; + return; + } + sel.innerHTML = data.files.map(f => + `` + ).join(''); + + if (selectFileName) sel.value = selectFileName; + } catch (e) { + sel.innerHTML = ``; + } +} + +// P&ID 추출 진행 상태 — 탭 전환 시 상태 초기화 방지 +let pidExtracting = false; +let pidElapsedInterval = null; + +async function pidExtract() { + const fileName = document.getElementById('pid-server-file').value; + const statusEl = document.getElementById('pid-status'); + const logEl = document.getElementById('pid-log'); + const elapsedEl = document.getElementById('pid-elapsed'); + + if (!fileName) { + if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.'; + return; + } + + pidExtracting = true; + if (statusEl) statusEl.textContent = '추출 중...'; + if (logEl) { + logEl.style.display = 'block'; + logEl.innerHTML = ''; + } + + // 경과 시간 시계 시작 + const startTime = Date.now(); + if (elapsedEl) { + elapsedEl.style.display = 'inline'; + pidElapsedInterval = setInterval(() => { + const seconds = Math.floor((Date.now() - startTime) / 1000); + const m = String(Math.floor(seconds / 60)).padStart(2, '0'); + const s = String(seconds % 60).padStart(2, '0'); + elapsedEl.textContent = `${m}:${s}`; + }, 1000); + } + + try { + const res = await fetch('/api/pid/extract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName, useImageMode: false }) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + + const data = await res.json(); + pidExtracting = false; + const skipMsg = data.skippedDuplicates > 0 ? ` (${data.skippedDuplicates}건 중복 제외)` : ''; + if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.totalCount}건${skipMsg}`; + log('pid-log', [ + { c: 'ok', t: `✅ 추출 완료: ${data.totalCount}건${skipMsg}` }, + { c: 'inf', t: ` 신뢰도 70%+: ${data.confidenceItems}건` }, + { c: 'inf', t: ` 신뢰도 50%미만: ${data.lowConfidenceItems}건` }, + ...(data.skippedDuplicates > 0 ? [{ c: 'warn', t: ` 중복 스킵: ${data.skippedDuplicates}건` }] : []) + ]); + + pidCurrentPage = 1; + await pidLoadTable(); + pidUpdateStats(); + } catch (e) { + pidExtracting = false; + if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`; + log('pid-log', [{ c: 'err', t: `❌ ${e.message}` }]); + } finally { + // 경과 시간 시계 종료 + if (pidElapsedInterval) { + clearInterval(pidElapsedInterval); + pidElapsedInterval = null; + } + if (elapsedEl) elapsedEl.style.display = 'none'; + } +} + +async function pidLoadTable(page = 1) { + pidCurrentPage = page; + const container = document.getElementById('pid-table-container'); + const tbody = document.getElementById('pid-table-body'); + + if (!container || !tbody) return; + + // tbody만 비우고 로딩 상태 표시 — container.innerHTML을 덮어쓰면 tbody가 DOM에서 떨어져 나가 + // 이후 tbody.innerHTML 할당이 화면에 반영되지 않음. + tbody.innerHTML = '로딩 중...'; + + try { + const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const data = await res.json(); + pidLastResult = data; + + if (!data.items || data.items.length === 0) { + tbody.innerHTML = '데이터가 없습니다'; + document.getElementById('pid-pagination').innerHTML = ''; + return; + } + + tbody.innerHTML = data.items.map(item => ` + + ${item.id} + ${esc(item.tagName)} + ${esc(item.equipmentName) || '-'} + ${esc(item.instrumentType) || '-'} + ${esc(item.lineNumber) || '-'} + ${esc(item.pidDrawingNo) || '-'} + ${(item.confidence * 100).toFixed(1)}% + + ${item.isActive ? '활성' : '비활성'} + + + ${item.experionTagId + ? `✅ ${esc(item.experionTagName || '')}` + : `` + } + + ${item.category + ? `${esc(item.category)}` + : '-'} + + + + + + `).join(''); + + pidRenderPagination(data.total, page); + } catch (e) { + tbody.innerHTML = `오류: ${e.message}`; + } +} + +function pidRenderPagination(total, currentPage) { + const pagination = document.getElementById('pid-pagination'); + if (!pagination) return; + + const totalPages = Math.ceil(total / pidPageSize); + if (totalPages <= 1) { + pagination.innerHTML = ''; + return; + } + + let html = ''; + const start = Math.max(1, currentPage - 3); + const end = Math.min(totalPages, currentPage + 3); + + if (currentPage > 1) { + html += ``; + } + + for (let i = start; i <= end; i++) { + html += ``; + } + + if (currentPage < totalPages) { + html += ``; + } + + pagination.innerHTML = html; +} + +async function pidDeleteRow(id) { + if (!confirm('정말 이 레코드를 삭제하시겠습니까?')) return; + + try { + const res = await fetch(`/api/pid/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || res.statusText); + } + await pidLoadTable(pidCurrentPage); + pidUpdateStats(); + } catch (e) { + alert(`삭제 실패: ${e.message}`); + } +} + +function pidUpdateStats() { + if (!pidLastResult || !pidLastResult.items) return; + + const total = pidLastResult.total || 0; + const highConf = pidLastResult.items.filter(i => i.confidence >= 0.7).length; + const mapped = pidLastResult.items.filter(i => i.experionTagId).length; + + const elTotal = document.getElementById('pid-stat-total'); + const elHigh = document.getElementById('pid-stat-high'); + const elMapped = document.getElementById('pid-stat-mapped'); + + if (elTotal) elTotal.textContent = total; + if (elHigh) elHigh.textContent = highConf; + if (elMapped) elMapped.textContent = mapped; +} + +function pidClearLog() { + const logEl = document.getElementById('pid-log'); + if (logEl) logEl.innerHTML = ''; +} + +async function pidAnalyzeConnections() { + const fileName = document.getElementById('pid-server-file').value; + const statusEl = document.getElementById('pid-status'); + const logEl = document.getElementById('pid-log'); + + if (!fileName) { + if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.'; + return; + } + + if (statusEl) statusEl.textContent = '연결 분석 중...'; + if (logEl) { + logEl.style.display = 'block'; + logEl.innerHTML = '
⏳ 연결 분석 시작...
'; + } + + try { + const res = await fetch('/api/pid/connections', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName }) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + const data = await res.json(); + if (statusEl) statusEl.textContent = `✅ 연결 분석 완료: ${data.connectionCount}건`; + log('pid-log', [ + { c: 'ok', t: `✅ 연결 분석 완료: ${data.connectionCount}개 from→to 연결` } + ]); + pidCurrentPage = 1; + await pidLoadTable(); + pidUpdateStats(); + } catch (e) { + if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`; + log('pid-log', [{ c: 'err', t: `❌ ${e.message}` }]); + } +} + +async function pidDeleteAll() { + if (!confirm('P&ID 추출 데이터를 전부 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.')) return; + + const statusEl = document.getElementById('pid-status'); + const logEl = document.getElementById('pid-log'); + + if (statusEl) statusEl.textContent = '전체 삭제 중...'; + if (logEl) { + logEl.style.display = 'block'; + logEl.innerHTML = '
⏳ 삭제 중...
'; + } + + try { + const res = await fetch('/api/pid/all', { method: 'DELETE' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (statusEl) statusEl.textContent = `🗑 ${data.message}`; + log('pid-log', [{ c: 'warn', t: `🗑 전체 삭제 완료: ${data.deletedCount}건` }]); + pidCurrentPage = 1; + await pidLoadTable(); + pidUpdateStats(); + } catch (e) { + if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`; + log('pid-log', [{ c: 'err', t: `❌ ${e.message}` }]); + } +} + +/* ── Prefix 분류 정의 ─────────────────────────────────────── */ +let pidPrefixPanelVisible = false; + +function pidTogglePrefixPanel() { + const panel = document.getElementById('pid-prefix-panel'); + const toggle = document.getElementById('pid-prefix-toggle'); + pidPrefixPanelVisible = !pidPrefixPanelVisible; + if (panel) panel.style.display = pidPrefixPanelVisible ? 'block' : 'none'; + if (toggle) toggle.textContent = pidPrefixPanelVisible ? '▲' : '▼'; + if (pidPrefixPanelVisible) pidRefreshPrefixRules(); +} + +const CATEGORY_META = { + instrument: { label: 'Instrument', badge: 'ok' }, + power_equipment: { label: 'Power Equipment', badge: 'warn' }, + storage_equipment: { label: 'Storage Equipment', badge: 'inf' }, + process_equipment: { label: 'Process Equipment', badge: '' }, + utility_equipment: { label: 'Utility Equipment', badge: 'warn' }, + pipings: { label: 'Pipings', badge: 'ok' } +}; +const CATEGORY_ORDER = ['instrument', 'power_equipment', 'storage_equipment', 'process_equipment', 'utility_equipment', 'pipings']; + +function pidCategoryBadge(cat) { + return CATEGORY_META[cat] ? CATEGORY_META[cat].badge : ''; +} + +async function pidRefreshPrefixRules() { + const container = document.getElementById('pid-prefix-groups'); + if (!container) return; + container.innerHTML = '
로딩 중...
'; + + try { + const res = await fetch('/api/pid/prefix-rules'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const items = data.items || []; + + if (items.length === 0) { + container.innerHTML = '
규칙이 없습니다. 아래에서 추가하세요.
'; + return; + } + + const grouped = {}; + for (const r of items) { + if (!grouped[r.category]) grouped[r.category] = []; + grouped[r.category].push(r); + } + + let html = ''; + for (const cat of CATEGORY_ORDER) { + const rules = grouped[cat]; + if (!rules) continue; + const meta = CATEGORY_META[cat] || { label: cat, badge: '' }; + rules.sort((a, b) => a.sortOrder - b.sortOrder); + + html += `
+
+ ${meta.label} + ${rules.length} + + + + + + +
+
`; + + for (const r of rules) { + html += `
+ + + + + + + +
`; + } + + html += `
`; + } + + container.innerHTML = html; + } catch (e) { + container.innerHTML = `
오류: ${e.message}
`; + } +} + +async function pidAddPrefixRule(category) { + const row = event.target.closest('.pid-cat-add'); + const prefix = row.querySelector('.pid-cat-prefix-input').value.trim(); + const desc = row.querySelector('.pid-cat-desc-input').value.trim(); + const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10; + + if (!prefix) { alert('Prefix를 입력하세요.'); return; } + + try { + const res = await fetch('/api/pid/prefix-rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prefix, category, description: desc, sortOrder: order }) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + row.querySelector('.pid-cat-prefix-input').value = ''; + row.querySelector('.pid-cat-desc-input').value = ''; + await pidRefreshPrefixRules(); + } catch (e) { + alert(`추가 실패: ${e.message}`); + } +} + +async function pidUpdatePrefixRule(id, btn) { + const row = btn.closest('.pid-cat-row'); + const group = btn.closest('.pid-cat-group'); + const catIdx = [...group.parentElement.children].indexOf(group); + const cat = CATEGORY_ORDER[catIdx]; + const prefix = row.querySelector('.pid-cat-prefix-input').value.trim(); + const desc = row.querySelector('.pid-cat-desc-input').value.trim(); + const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10; + + if (!prefix) { alert('Prefix를 입력하세요.'); return; } + + try { + const res = await fetch(`/api/pid/prefix-rules/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prefix, category: cat, description: desc, sortOrder: order }) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + await pidRefreshPrefixRules(); + } catch (e) { + alert(`수정 실패: ${e.message}`); + } +} + +async function pidDeletePrefixRule(id, prefix) { + if (!confirm(`"${prefix}" 규칙을 삭제하시겠습니까?`)) return; + try { + const res = await fetch(`/api/pid/prefix-rules/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await pidRefreshPrefixRules(); + } catch (e) { + alert(`삭제 실패: ${e.message}`); + } +} + +async function pidApplyCategories() { + if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return; + try { + const res = await fetch('/api/pid/apply-categories', { method: 'POST' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + alert(`${data.applied}건 category 적용 완료`); + await pidLoadTable(pidCurrentPage); + pidUpdateStats(); + } catch (e) { + alert(`재적용 실패: ${e.message}`); + } +} + +function pidOpenMapping(id) { + // 매핑 모달 열기 (추후 구현) + console.log('pidOpenMapping:', id); +} + +// 탭 진입 시 초기화 +paneInit.pid = async function() { + pidCurrentPage = 1; + pidLastResult = null; + if (document.getElementById('pid-file-input')) document.getElementById('pid-file-input').value = ''; + if (!pidExtracting) { + const st = document.getElementById('pid-status'); + if (st) st.textContent = '대기 중...'; + const elapsedEl = document.getElementById('pid-elapsed'); + if (elapsedEl) elapsedEl.style.display = 'none'; + if (pidElapsedInterval) { + clearInterval(pidElapsedInterval); + pidElapsedInterval = null; + } + const tb = document.getElementById('pid-table-body'); + if (tb) tb.innerHTML = ''; + const pg = document.getElementById('pid-pagination'); + if (pg) pg.innerHTML = ''; + const stTot = document.getElementById('pid-stat-total'); + if (stTot) stTot.textContent = '0'; + const stHi = document.getElementById('pid-stat-high'); + if (stHi) stHi.textContent = '0'; + const stMap = document.getElementById('pid-stat-mapped'); + if (stMap) stMap.textContent = '0'; + await pidLoadTable(1); + } + + // Export buttons — attach once + const bind = (id, fn) => { + const el = document.getElementById(id); + if (el && !el.dataset.pidBound) { el.addEventListener('click', fn); el.dataset.pidBound = '1'; } + }; + bind('btn-pid-export-csv', async () => { + if (!pidLastResult) return; + const res = await fetch('/api/pid/export/csv'); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `pid-export-${new Date().toISOString().slice(0,10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }); + bind('btn-pid-export-excel', async () => { + if (!pidLastResult) return; + const res = await fetch('/api/pid/export/excel'); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `pid-export-${new Date().toISOString().slice(0,10)}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + }); +}; diff --git a/src/Web/wwwroot/js/t2s.js b/src/Web/wwwroot/js/t2s.js new file mode 100644 index 0000000..f478d5c --- /dev/null +++ b/src/Web/wwwroot/js/t2s.js @@ -0,0 +1,735 @@ +/* ── Text-to-SQL Export Variables ────────────────────────────────────────── */ +let _t2sLastResult = null; // Excel export용 — 마지막으로 렌더된 결과 보관 + +/* ── Text-to-SQL Dashboard ───────────────────────────────────── */ +paneInit.t2s = t2sInitMode; + +// MCP 모드 설정 (legacy | mcp) +let t2sMode = 'mcp'; + +/** + * t2sInitMode - 현재 t2sMode에 맞게 UI 초기화 (탭 진입 시 호출) + */ +function t2sInitMode() { + const parseBtn = document.getElementById('t2s-parse-btn'); + const executeBtn = document.getElementById('t2s-execute-btn'); + const analyzeBtn = document.getElementById('t2s-analyze-btn'); + const logBox = document.getElementById('t2s-log'); + if (t2sMode === 'mcp') { + if (parseBtn) parseBtn.classList.add('hidden'); + if (analyzeBtn) analyzeBtn.classList.add('hidden'); + if (logBox) logBox.classList.add('hidden'); + } else { + if (parseBtn) parseBtn.classList.remove('hidden'); + if (executeBtn) executeBtn.classList.remove('hidden'); + if (analyzeBtn) analyzeBtn.classList.remove('hidden'); + if (logBox) logBox.classList.remove('hidden'); + } +} + +/** + * toggleMcpMode - MCP 모드 토글 + */ +function toggleMcpMode() { + t2sMode = t2sMode === 'legacy' ? 'mcp' : 'legacy'; + + // 버튼 표시/숨김 처리 + const parseBtn = document.getElementById('t2s-parse-btn'); + const executeBtn = document.getElementById('t2s-execute-btn'); + const analyzeBtn = document.getElementById('t2s-analyze-btn'); + const chatContainer = document.getElementById('t2s-chat-container'); + const logBox = document.getElementById('t2s-log'); + + if (t2sMode === 'mcp') { + // MCP 모드: 변환(Parse) · 분석 버튼 숨김, 실행 버튼은 유지 + if (parseBtn) parseBtn.classList.add('hidden'); + if (analyzeBtn) analyzeBtn.classList.add('hidden'); + if (logBox) logBox.classList.add('hidden'); + setGlobal('ok', 'MCP 모드'); + } else { + // Legacy 모드: 모든 기능 표시 + if (parseBtn) parseBtn.classList.remove('hidden'); + if (executeBtn) executeBtn.classList.remove('hidden'); + if (analyzeBtn) analyzeBtn.classList.remove('hidden'); + if (logBox) logBox.classList.remove('hidden'); + setGlobal('ok', 'Legacy 모드'); + } +} + +/** + * t2sParse - 자연어 쿼리를 SQL로 변환 + * MCP 모드: LLM이 SQL 생성 + 즉시 실행 + * Legacy 모드: C# 파서로 SQL 생성 + */ +async function t2sParse() { + const input = document.getElementById('t2s-query').value.trim(); + if (!input) { + alert('자연어 쿼리를 입력해주세요.'); + return; + } + + const sqlTextarea = document.getElementById('t2s-sql'); + const resultContainer = document.getElementById('t2s-results'); + sqlTextarea.disabled = true; + + if (t2sMode === 'mcp') { + sqlTextarea.value = 'LLM이 SQL 생성 중...'; + resultContainer.innerHTML = '
MCP 조회 중...
'; + try { + const res = await api('POST', '/api/text-to-sql/query-nl', { query: input }); + console.log('[t2sParse] MCP 응답:', res); + if (res.success) { + const d = (typeof res.data === 'object' && res.data !== null) ? res.data : {}; + console.log('[t2sParse] 데이터 파싱:', d, 'type:', typeof d); + console.log('[t2sParse] d.data:', d.data, 'd.columns:', d.columns, 'd.count:', d.count); + + // d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인 + const dataObj = d.data || (Array.isArray(d) ? d : null); + console.log('[t2sParse] dataObj:', dataObj); + + if (d.success === false) { + sqlTextarea.value = `오류: ${d.error || 'SQL 생성 실패'}`; + resultContainer.innerHTML = `
MCP 오류: ${esc(d.error || 'SQL 생성 실패')}
`; + } else { + sqlTextarea.value = d.sql || '(SQL 없음)'; + // d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인 + const rows = (dataObj && Array.isArray(dataObj.rows)) ? dataObj.rows : (Array.isArray(dataObj) ? dataObj : []); + const columns = (dataObj && Array.isArray(dataObj.columns)) ? dataObj.columns : []; + const totalCount = (dataObj && typeof dataObj.count === 'number') ? dataObj.count : (Array.isArray(dataObj) ? dataObj.length : 0); + console.log('[t2sParse] 최종 rows.length:', rows.length, 'columns:', columns); + + if (rows.length === 0) { + resultContainer.innerHTML = '
조회 결과가 없습니다. (SQL은 생성됨)
'; + } else { + t2sRenderTable({ rows, columns, totalCount }); + } + } + } else { + sqlTextarea.value = `오류: ${res.error || '알 수 없는 오류'}`; + resultContainer.innerHTML = ''; + } + } catch (err) { + sqlTextarea.value = `연결 오류: ${err.message}`; + resultContainer.innerHTML = ''; + } + } else { + sqlTextarea.value = '변환 중...'; + try { + const res = await api('POST', '/api/text-to-sql/parse', { query: input }); + if (res.success) { + sqlTextarea.value = res.sql || 'SQL 생성 실패'; + } else { + sqlTextarea.value = `오류: ${res.error || '알 수 없는 오류'}`; + } + } catch (err) { + sqlTextarea.value = `연결 오류: ${err.message}`; + } + } + + sqlTextarea.disabled = false; +} + +/** + * t2sPivot - tagname 컬럼이 있으면 wide format으로 변환 (query_with_nl 서버 로직과 동일) + */ +function t2sPivot(columns, rows) { + console.log('[t2sPivot] 입력: columns:', columns, 'rows.length:', rows.length); + if (!columns.includes('tagname') || !rows.length) return { columns, rows }; + const timeCol = columns.find(c => !['tagname', 'value', 'livevalue', 'avg_val'].includes(c)); + const valCol = ['value', 'avg_val'].find(c => columns.includes(c)) || columns[columns.length - 1]; + if (!timeCol) return { columns, rows }; + const tagNames = [...new Set(rows.map(r => r['tagname']))].sort(); + const pivoted = {}; + for (const row of rows) { + const key = String(row[timeCol]); + if (!pivoted[key]) pivoted[key] = { [timeCol]: row[timeCol] }; + pivoted[key][row['tagname']] = row[valCol]; + } + const result = { columns: [timeCol, ...tagNames], rows: Object.values(pivoted) }; + console.log('[t2sPivot] 결과: columns:', result.columns, 'rows.length:', result.rows.length); + return result; +} + +/** + * t2sExecute - SQL 실행 + * MCP 모드에서는 /api/text-to-sql/execute-mcp 엔드포인트를 사용하며 pivot 적용 + */ +async function t2sExecute() { + const sql = document.getElementById('t2s-sql').value.trim(); + if (!sql) { + alert('SQL을 입력해주세요.'); + return; + } + + const limitInput = document.getElementById('t2s-limit'); + const limit = limitInput.value ? parseInt(limitInput.value) : 1000; + + const resultContainer = document.getElementById('t2s-results'); + resultContainer.innerHTML = '
실행 중...
'; + + try { + if (t2sMode === 'mcp') { + const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql, limit }); + console.log('[t2sExecute] MCP execute 응답:', res); + if (res.success) { + const d = res.data || {}; + console.log('[t2sExecute] d.data:', d.data, 'd.columns:', d.columns); + const { columns, rows } = t2sPivot(d.columns || [], d.data || []); + console.log('[t2sExecute] pivot 후 rows.length:', rows.length); + t2sRenderTable({ rows, columns, totalCount: rows.length }); + } else { + resultContainer.innerHTML = `
오류: ${res.error || '알 수 없는 오류'}
`; + } + } else { + const res = await api('POST', '/api/text-to-sql/execute', { sql, limit }); + if (res.success) { + t2sRenderTable(res); + } else { + resultContainer.innerHTML = `
오류: ${res.error || '알 수 없는 오류'}
`; + } + } + } catch (err) { + resultContainer.innerHTML = `
연결 오류: ${err.message}
`; + } +} + +/** + * t2sRenderTable - 쿼리 결과를 테이블로 렌더링 + */ +function t2sRenderTable(result) { + const container = document.getElementById('t2s-results'); + + console.log('[t2sRenderTable] 입력 결과:', result); + + // 백엔드 응답: columns, rows, totalCount (소문자) + const rows = result.rows || []; + const columns = result.columns || []; + const totalCount = result.totalCount || 0; + + console.log('[t2sRenderTable] rows.length:', rows.length, 'columns:', columns); + + // ── 추가: 결과 저장 (export용) ── + _t2sLastResult = rows.length > 0 ? { columns, rows } : null; + + if (!rows || rows.length === 0) { + console.log('[t2sRenderTable] 빈 결과 - 결과 없음 표시'); + container.innerHTML = '
결과가 없습니다.
'; + return; + } + + // 컬럼이 비어있으면 첫 행에서 추출 + const colNames = columns.length > 0 ? columns : Object.keys(rows[0]); + + let html = '
' + totalCount + '개 결과
'; + html += ''; + + // Header + html += ''; + colNames.forEach(col => { + html += ``; + }); + html += ''; + + // Body + html += ''; + rows.forEach(row => { + html += ''; + colNames.forEach(col => { + const val = row[col]; + const isTimeCol = /recorded_at|timestamp|bucket|time/i.test(col); + const display = val !== null && val !== undefined + ? esc(String(isTimeCol ? fmtTs(val) : fmtVal(val))) + : 'NULL'; + html += ``; + + }); + html += ''; + }); + html += ''; + + html += '
${esc(col)}
${display}
'; + container.innerHTML = html; +} + +/** + * t2sExportExcel — 마지막 쿼리 결과를 .xlsx로 다운로드 + */ +function t2sExportExcel() { + if (!_t2sLastResult) return; + + if (typeof XLSX === 'undefined') { + alert('Excel 라이브러리 로드 실패'); + return; + } + + const { columns, rows } = _t2sLastResult; + + // 1. 헤더 행 + 데이터 행 배열 구성 + const sheetData = [ + columns, // 첫 행 = 컬럼 헤더 + ...rows.map(row => columns.map(col => { + const v = row[col]; + if (v == null) return ''; + const isTimeCol = /recorded_at|timestamp|bucket|time/i.test(col); + if (isTimeCol) return fmtTs(v); + const n = Number(v); + return Number.isFinite(n) ? n : String(v); + })) + ]; + + // 2. 워크시트 생성 + const ws = XLSX.utils.aoa_to_sheet(sheetData); + + // 3. 컬럼 너비 자동 조정 (최대 30자) + ws['!cols'] = columns.map((col, i) => { + const maxLen = Math.max( + col.length, + ...rows.map(r => String(r[col] ?? '').length) + ); + return { wch: Math.min(maxLen + 2, 30) }; + }); + + // 4. 워크북 생성 및 다운로드 + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'QueryResult'); + + const now = new Date(); + const ts = now.toISOString().replace(/[:.]/g, '-').substring(0, 19); + XLSX.writeFile(wb, `query_result_${ts}.xlsx`); +} + +/** + * t2sAnalyze - 태그 분석 (시계열 분석) (Legacy 모드용) + */ +async function t2sAnalyze() { + const tagNames = document.getElementById('t2s-tags').value.trim(); + if (!tagNames) { + alert('분석할 태그 이름을 입력해주세요.'); + return; + } + + const interval = document.getElementById('t2s-interval').value; + const limit = document.getElementById('t2s-limit-analyze').value || '100'; + + const dateFrom = document.getElementById('t2s-date-from').value; + const dateTo = document.getElementById('t2s-date-to').value; + + const resultContainer = document.getElementById('t2s-analysis-results'); + resultContainer.innerHTML = '
분석 중...
'; + + try { + const res = await api('POST', '/api/text-to-sql/analyze', { + tagNames: tagNames.split(',').map(t => t.trim()).filter(t => t), + interval: interval, + from: dateFrom || undefined, + to: dateTo || undefined + }); + + if (res.success) { + t2sRenderAnalysis(res); + } else { + resultContainer.innerHTML = `
오류: ${res.error || '알 수 없는 오류'}
`; + } + } catch (err) { + resultContainer.innerHTML = `
연결 오류: ${err.message}
`; + } +} + +/** + * t2sRenderAnalysis - 분석 결과를 렌더링 + */ +function t2sRenderAnalysis(result) { + const container = document.getElementById('t2s-analysis-results'); + + if (!result.tags || result.tags.length === 0) { + container.innerHTML = '
분석 결과가 없습니다.
'; + return; + } + + let html = '
' + result.tags.length + '개 태그 분석
'; + html += '
'; + + result.tags.forEach(tag => { + html += '
'; + html += `

${esc(tag.tagName.toUpperCase())}

`; + html += '
'; + html += `
평균:${tag.mean?.toFixed(2) || 'N/A'}
`; + html += `
최대:${tag.max?.toFixed(2) || 'N/A'}
`; + html += `
최소:${tag.min?.toFixed(2) || 'N/A'}
`; + html += `
표준편차:${tag.stdDev?.toFixed(2) || 'N/A'}
`; + html += '
'; + html += '
'; + }); + + html += '
'; + container.innerHTML = html; +} + +/** + * t2sSetQuery - 제안 쿼리 설정 + */ +function t2sSetQuery(query) { + document.getElementById('t2s-query').value = query; +} + +/* ── 채팅 기능 ────────────────────────────────────────────────── */ + +/** + * t2sChatSend - 채팅 메시지 전송 + */ +async function t2sChatSend() { + const input = document.getElementById('t2s-chat-input'); + const message = input.value.trim(); + if (!message) return; + + // 입력 필드 비활성화 + input.disabled = true; + document.getElementById('t2s-chat-send-btn').disabled = true; + + // 사용자 메시지 추가 + t2sAddChatMessage('user', message); + input.value = ''; + + try { + if (t2sMode === 'mcp') { + // MCP 모드: 자연어 → LLM SQL 생성 → 실행 + t2sAddChatMessage('system', 'LLM이 SQL 생성 중...'); + + const executeRes = await api('POST', '/api/text-to-sql/query-nl', { query: message }); + + // 로딩 메시지 제거 + const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]'); + loadMsgs.forEach(el => el.remove()); + + if (!executeRes.success) { + t2sAddChatMessage('system', `실패: ${executeRes.error || '알 수 없는 오류'}`); + } else { + const d = executeRes.data || {}; + if (d.sql) { + document.getElementById('t2s-sql').value = d.sql; + t2sAddChatMessage('system', `✅ SQL 생성:
${esc(d.sql)}
`); + } + if (d.success === false) { + t2sAddChatMessage('system', `실행 오류: ${d.error || '알 수 없는 오류'}`); + } else { + t2sRenderTable({ rows: d.data || [], columns: d.columns || [], totalCount: d.count || 0 }); + t2sAddChatMessage('system', `✅ ${d.count || 0}개 결과 조회 완료`); + } + } + } else { + // Legacy 모드: 2-hop 구조 (Parse → Execute) + const loadingId = 't2s-chat-loading-' + Date.now(); + t2sAddChatMessage('system', '변환 중...', loadingId); + + const parseRes = await api('POST', '/api/text-to-sql/parse', { query: message }); + + // 로딩 메시지 제거 + const loadingEl = document.getElementById(loadingId); + if (loadingEl) loadingEl.remove(); + + if (!parseRes.success || !parseRes.sql) { + t2sAddChatMessage('system', `SQL 변환 실패: ${parseRes.error || '알 수 없는 오류'}`); + input.disabled = false; + document.getElementById('t2s-chat-send-btn').disabled = false; + input.focus(); + return; + } + + // SQL 텍스트박스에도 반영 + document.getElementById('t2s-sql').value = parseRes.sql; + + // 시스템 메시지: 변환된 SQL 표시 + t2sAddChatMessage('system', `✅ SQL 변환 완료:
${esc(parseRes.sql)}
`); + + // 2. SQL 자동 실행 + t2sAddChatMessage('system', '쿼리 실행 중...'); + + const limitInput = document.getElementById('t2s-limit'); + const limit = limitInput.value ? parseInt(limitInput.value) : 1000; + const executeRes = await api('POST', '/api/text-to-sql/execute', { sql: parseRes.sql, limit }); + + // 로딩 메시지 제거 + const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]'); + loadMsgs.forEach(el => el.remove()); + + if (!executeRes.success) { + t2sAddChatMessage('system', `쿼리 실행 실패: ${executeRes.error || '알 수 없는 오류'}`); + } else { + // 결과 테이블 업데이트 + t2sRenderTable(executeRes); + + // 결과 수 표시 + const totalCount = executeRes.totalCount || 0; + t2sAddChatMessage('system', `✅ ${totalCount}개 결과 조회 완료`); + } + } + } catch (err) { + // 로딩 메시지 제거 + const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]'); + loadMsgs.forEach(el => el.remove()); + + t2sAddChatMessage('system', `연결 오류: ${err.message}`); + } finally { + // 입력 필드 활성화 + input.disabled = false; + document.getElementById('t2s-chat-send-btn').disabled = false; + input.focus(); + } +} + +/** + * t2sAddChatMessage - 채팅 메시지 추가 + */ +function t2sAddChatMessage(type, content, id) { + const container = document.getElementById('t2s-chat-messages'); + const msgId = id || 't2s-msg-' + Date.now(); + + const div = document.createElement('div'); + div.className = `t2s-chat-msg ${type}`; + div.id = msgId; + + const bubble = document.createElement('div'); + bubble.className = 't2s-chat-bubble'; + bubble.innerHTML = content; + + div.appendChild(bubble); + container.appendChild(div); + + // 스크롤 맨 아래로 + const chatContainer = document.querySelector('.t2s-chat-container'); + chatContainer.scrollTop = chatContainer.scrollHeight; + + return msgId; +} + +/* ─── Text-to-SQL MCP 도구 관련 함수 ───────────────────────── */ + +/** + * loadMcpTools - MCP 도구 목록 로드 + */ +async function loadMcpTools() { + try { + const res = await api('GET', '/api/text-to-sql/tools'); + if (res.success && res.tools && res.tools.length > 0) { + renderToolsChips(res.tools); + return res.tools; + } + } catch (e) { + console.error('MCP 도구 로드 실패:', e); + } + return []; +} + +/** + * renderToolsChips - 도구 목록을 추천 쿼리 칩 형태로 렌더링 + */ +function renderToolsChips(tools) { + const container = document.getElementById('t2s-tools-container'); + if (!container) return; + + const chipColors = ['bg-a', 'bg-b', 'bg-c', 'bg-d']; + container.innerHTML = tools.map((tool, idx) => { + const colorClass = chipColors[idx % chipColors.length]; + return ``; + }).join(''); +} + +/** + * callTool - MCP 도구 직접 호출 (MCP 모드) + */ +async function callTool(toolName) { + const input = document.getElementById('t2s-query').value.trim(); + if (!input) { + alert(toolName === 'query_pv_history' ? '태그 이름을 입력해주세요.' : '쿼리를 입력해주세요.'); + return; + } + + const resultContainer = document.getElementById('t2s-results'); + resultContainer.innerHTML = '
도구 호출 중...
'; + + try { + const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql: input }); + if (res.success) { + t2sRenderTable(res); + } else { + resultContainer.innerHTML = `
오류: ${res.error || '알 수 없는 오류'}
`; + } + } catch (err) { + resultContainer.innerHTML = `
연결 오류: ${err.message}
`; + } +} + +/* ── API 대화 기능 (새 페이지) ────────────────────────────────── */ + +/** + * apiChatSend - API 대화 메시지 전송 + */ +async function apiChatSend() { + const input = document.getElementById('api-chat-input'); + const message = input.value.trim(); + if (!message) return; + + // 입력 필드 비활성화 + input.disabled = true; + document.getElementById('api-chat-send-btn').disabled = true; + + // 사용자 메시지 추가 + apiAddChatMessage('user', message); + input.value = ''; + + // 로딩 메시지 추가 + const loadingId = 'api-chat-loading-' + Date.now(); + apiAddChatMessage('system', '처리 중...', loadingId); + + try { + // 1. 자연어 쿼리를 SQL로 변환 + const parseRes = await api('POST', '/api/text-to-sql/parse', { query: message }); + + // 로딩 메시지 제거 + const loadingEl = document.getElementById(loadingId); + if (loadingEl) loadingEl.remove(); + + if (!parseRes.success || !parseRes.sql) { + apiAddChatMessage('system', `SQL 변환 실패: ${parseRes.error || '알 수 없는 오류'}`); + input.disabled = false; + document.getElementById('api-chat-send-btn').disabled = false; + input.focus(); + return; + } + + // SQL 표시 + apiAddChatMessage('assistant', `📝 변환된 SQL:
${esc(parseRes.sql)}
`); + + // 2. SQL 자동 실행 + apiAddChatMessage('assistant', '쿼리 실행 중...'); + + const executeRes = await api('POST', '/api/text-to-sql/execute', { sql: parseRes.sql, limit: 1000 }); + + // 로딩 메시지 제거 + const loadMsgs = document.querySelectorAll('[id^="api-chat-loading-"]'); + loadMsgs.forEach(el => el.remove()); + + if (!executeRes.success) { + apiAddChatMessage('system', `쿼리 실행 실패: ${executeRes.error || '알 수 없는 오류'}`); + } else { + // 결과를 응답 창에 표시 + apiRenderResponse(executeRes); + + // 결과 수 표시 + const totalCount = executeRes.totalCount || 0; + apiAddChatMessage('assistant', `✅ ${totalCount}개 결과 조회 완료`); + } + } catch (err) { + // 로딩 메시지 제거 + const loadMsgs = document.querySelectorAll('[id^="api-chat-loading-"]'); + loadMsgs.forEach(el => el.remove()); + + apiAddChatMessage('system', `연결 오류: ${err.message}`); + } finally { + // 입력 필드 활성화 + input.disabled = false; + document.getElementById('api-chat-send-btn').disabled = false; + input.focus(); + } +} + +/** + * apiAddChatMessage - API 채팅 메시지 추가 + */ +function apiAddChatMessage(type, content, id) { + const container = document.getElementById('api-chat-messages'); + const msgId = id || 'api-msg-' + Date.now(); + + const div = document.createElement('div'); + div.className = `api-chat-msg ${type}`; + div.id = msgId; + + const bubble = document.createElement('div'); + bubble.className = 'api-chat-bubble'; + bubble.innerHTML = content; + + div.appendChild(bubble); + container.appendChild(div); + + // 스크롤 맨 아래로 + const chatContainer = document.querySelector('.api-chat-messages'); + chatContainer.scrollTop = chatContainer.scrollHeight; + + return msgId; +} + +/** + * t2sChatClear - 채팅 초기화 + */ +function t2sChatClear() { + const container = document.getElementById('t2s-chat-messages'); + container.innerHTML = ` +
+
+ 시스템:
+ 자연어 질의를 입력하면 SQL로 변환하고 결과를 조회합니다.
+ 예: "FICQ-6101.PV 최근 1시간 평균", "최대값 조회", "추세 분석" +
+
+ `; +} + +/* ─── Text-to-SQL MCP 도구 관련 함수 ───────────────────────── */ + +/** + * apiChatClear - API 채팅 초기화 + */ +function apiChatClear() { + const container = document.getElementById('api-chat-messages'); + container.innerHTML = ` +
+
+ 시스템:
+ API와 대화할 수 있습니다. 자연어로 질의를 입력하면 SQL로 변환하고 결과를 조회합니다.
+ 예: "FICQ-6101.PV 최근 1시간 평균", "최대값 조회", "추세 분석" +
+
+ `; + + // 응답 창도 초기화 + document.getElementById('api-response-content').innerHTML = '응답이 여기에 표시됩니다'; +} + +/** + * apiRenderResponse - API 응답을 응답 창에 표시 + */ +function apiRenderResponse(data) { + const container = document.getElementById('api-response-content'); + + if (!data.rows || data.rows.length === 0) { + container.innerHTML = '조회된 결과가 없습니다'; + return; + } + + // 테이블 생성 + let html = ''; + + // 헤더 생성 + const columns = Object.keys(data.rows[0]); + columns.forEach(col => { + html += ``; + }); + html += ''; + + // 데이터 행 생성 + data.rows.forEach(row => { + html += ''; + columns.forEach(col => { + const value = row[col]; + html += ``; + }); + html += ''; + }); + + html += '
${esc(col)}
${value !== null && value !== undefined ? esc(String(value)) : ''}
'; + html += `

총 ${data.totalCount || 0}개 결과

`; + + container.innerHTML = html; +} diff --git a/src/Web/wwwroot/js/write.js b/src/Web/wwwroot/js/write.js new file mode 100644 index 0000000..926611d --- /dev/null +++ b/src/Web/wwwroot/js/write.js @@ -0,0 +1,80 @@ +/* ───────────────────────────────────────────────────────────── + 15 OPC UA Write +───────────────────────────────────────────────────────────── */ +async function wrWriteTag() { + const nodeId = document.getElementById('wr-nodeid').value.trim(); + const value = parseFloat(document.getElementById('wr-value').value); + if (!nodeId) return log('wr-log', [{ c: 'err', t: '❌ Node ID를 입력하세요.' }]); + if (isNaN(value)) return log('wr-log', [{ c: 'err', t: '❌ 유효한 숫자를 입력하세요.' }]); + + try { + const d = await api('POST', '/api/points/write', { nodeId, value }); + log('wr-log', [ + { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Write ' + esc(nodeId) + ' = ' + value + ' → ' + esc(d.status) }, + ...(d.error ? [{ c: 'err', t: ' Error: ' + esc(d.error) }] : []) + ]); + } catch (e) { + log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]); + } +} + +async function wrSetMode() { + const nodeId = document.getElementById('wr-mode-nodeid').value.trim(); + const mode = document.getElementById('wr-mode').value; + if (!nodeId) return log('wr-log', [{ c: 'err', t: '❌ Node ID를 입력하세요.' }]); + + try { + const d = await api('POST', '/api/points/mode', { nodeId, mode }); + log('wr-log', [ + { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Mode ' + esc(nodeId) + ' → ' + mode + ' (enum=' + d.enumValue + ') ' + esc(d.status) }, + ...(d.error ? [{ c: 'err', t: ' Error: ' + esc(d.error) }] : []) + ]); + } catch (e) { + log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]); + } +} + +async function wrControlOp() { + const tagName = document.getElementById('wr-ctrl-tag').value.trim(); + const opValue = parseFloat(document.getElementById('wr-ctrl-op').value); + const restoreAuto = document.getElementById('wr-ctrl-restore').checked; + if (!tagName) return log('wr-log', [{ c: 'err', t: '❌ 태그명을 입력하세요.' }]); + if (isNaN(opValue)) return log('wr-log', [{ c: 'err', t: '❌ 유효한 OP 값을 입력하세요.' }]); + + try { + const d = await api('POST', '/api/points/control', { tagName, opValue, restoreAuto }); + const lines = []; + if (d.steps) { + for (const s of d.steps) { + lines.push({ c: s.success ? 'ok' : 'err', t: (s.success ? '✅ ' : '❌ ') + s.step + ': ' + esc(s.nodeId) + ' → ' + esc(s.status) + (s.value !== undefined ? ' (value=' + s.value + ')' : '') }); + if (s.error) lines.push({ c: 'err', t: ' Error: ' + esc(s.error) }); + } + } + lines.unshift({ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + '통합 제어 ' + esc(tagName) + ' OP=' + opValue + (d.error ? ' — ' + esc(d.error) : '') }); + log('wr-log', lines); + } catch (e) { + log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]); + } +} + +async function wrReadTag() { + const nodeId = document.getElementById('wr-read-nodeid').value.trim(); + if (!nodeId) return; + + try { + const d = await api('POST', '/api/points/read', { nodeId }); + const box = document.getElementById('wr-read-result'); + if (d.success) { + box.innerHTML = ` +
Node ID${esc(d.nodeId)}
+
Value${esc(d.value)}
+
Status${esc(d.statusCode)}
+
Timestamp${d.timestamp ? new Date(d.timestamp).toLocaleString('ko-KR') : '-'}
+ `; + } else { + box.innerHTML = `
Error${esc(d.error || 'Unknown')}
`; + } + } catch (e) { + document.getElementById('wr-read-result').innerHTML = `
Error${esc(e.message)}
`; + } +} diff --git a/src/Web/wwwroot/panes/cert.html b/src/Web/wwwroot/panes/cert.html new file mode 100644 index 0000000..45398f6 --- /dev/null +++ b/src/Web/wwwroot/panes/cert.html @@ -0,0 +1,36 @@ +
+
+

인증서 관리

+

OPC UA 클라이언트 인증서를 생성합니다. 기존 파일이 있으면 재사용됩니다.

+
+
PKI / X.509
+
+ +
+
+
인증서 생성
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
현재 인증서 상태
+ +
+ 상태 확인 버튼을 눌러 주세요 +
+
+
+ + diff --git a/src/Web/wwwroot/panes/conn.html b/src/Web/wwwroot/panes/conn.html new file mode 100644 index 0000000..3e1dcfa --- /dev/null +++ b/src/Web/wwwroot/panes/conn.html @@ -0,0 +1,51 @@ +
+
+

서버 접속 테스트

+

Experion OPC UA 서버에 연결하고 노드 값을 읽습니다.

+
+
OPC UA / TCP
+
+ +
+
서버 설정
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
단일 태그 읽기
+
+ + +
+ +
+ +
+
LLM 설정
+
+ + + +
+
+
+ + + diff --git a/src/Web/wwwroot/panes/crawl.html b/src/Web/wwwroot/panes/crawl.html new file mode 100644 index 0000000..526be65 --- /dev/null +++ b/src/Web/wwwroot/panes/crawl.html @@ -0,0 +1,82 @@ +
+
+

데이터 크롤링

+

지정한 노드 값을 주기적으로 수집하여 CSV 파일로 저장합니다.

+
+
CRAWL / CSV
+
+ +
+
+
서버 설정
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
수집 노드 목록 (한 줄에 하나씩)
+ + +
+
+ + + + + + +
+ +
+
+

노드맵 수집

+

서버 전체 노드를 재귀 탐색하여 AssetLoader 용 CSV 파일로 저장합니다.

+
+
NODE MAP / CSV
+
+ +
+
전체 노드 탐색 설정
+
+
+ + +
+ +
+

+ 서버 설정은 위 크롤링 설정을 그대로 사용합니다  ·  + 노드 수에 따라 수 분이 소요될 수 있습니다  ·  + 결과는 data/csv/{서버명}_*.csv 에 저장됩니다 +

+
+ + + + diff --git a/src/Web/wwwroot/panes/db.html b/src/Web/wwwroot/panes/db.html new file mode 100644 index 0000000..05e6508 --- /dev/null +++ b/src/Web/wwwroot/panes/db.html @@ -0,0 +1,50 @@ +
+
+

DB 저장

+

수집된 CSV 파일을 PostgreSQL DB에 저장하고 레코드를 조회합니다.

+
+
PostgreSQL / EF
+
+ +
+
+
CSV → DB 임포트
+ +
+ 갱신 버튼을 눌러 주세요 +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+
DB 레코드 조회
+
+ + +
+ +
+
+ + + diff --git a/src/Web/wwwroot/panes/docs.html b/src/Web/wwwroot/panes/docs.html new file mode 100644 index 0000000..eb2dc6f --- /dev/null +++ b/src/Web/wwwroot/panes/docs.html @@ -0,0 +1,37 @@ +
+
+

문서 탐색기

+

프로젝트 폴더의 문서를 직접 보고 편집합니다. 뷰어: txt · md · pdf · 그 외 다운로드.

+
+
DOCS
+
+ +
+ + + + +
+
+ 파일을 선택하세요 +
+
+
+
← 왼쪽에서 문서를 선택하세요
+
+
+
diff --git a/src/Web/wwwroot/panes/evt.html b/src/Web/wwwroot/panes/evt.html new file mode 100644 index 0000000..8e2968e --- /dev/null +++ b/src/Web/wwwroot/panes/evt.html @@ -0,0 +1,78 @@ +
+
+

이벤트 히스토리

+

디지털 포인트 상태 변경 이벤트를 조회합니다. (event_history_table)

+
+
EVENT / DIGITAL
+
+ + +
+
조회 조건
+ +
+ 태그 필터 + + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
— 선택 안 함 —
+
+
+ + +
— 선택 안 함 —
+
+
+ +
+ + + +
+
+ + + + + + + diff --git a/src/Web/wwwroot/panes/fast.html b/src/Web/wwwroot/panes/fast.html new file mode 100644 index 0000000..6c9464e --- /dev/null +++ b/src/Web/wwwroot/panes/fast.html @@ -0,0 +1,44 @@ +
+
+

fastRecord

+

고속 샘플링으로 실시간 데이터를 수집하고 트렌드를 분석합니다.

+
+
FAST / RECORD
+
+ + +
+
+ 세션 목록 +
+ DB 미연결 + + +
+
+
+
+ + +
+
+ 세션 상세 +
+ + + + + +
+
+ +
+
+
+
+ 0 / 0 (0%) + 경과: 0s +
+ +
+
diff --git a/src/Web/wwwroot/panes/hist.html b/src/Web/wwwroot/panes/hist.html new file mode 100644 index 0000000..9ac3b82 --- /dev/null +++ b/src/Web/wwwroot/panes/hist.html @@ -0,0 +1,148 @@ +
+
+

이력 조회

+

history_table 의 시계열 데이터를 조회합니다.

+
+
HISTORY / TREND
+
+ +
+
조회 조건
+
+
+ 태그 선택 (최대 8개, OR 조건) + + 대기 중 +
+
+ + + + + + + + +
+
+
+
+ + +
— 선택 안 함 —
+
+
+ + +
— 선택 안 함 —
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
하이퍼테이블 관리
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+ + + + + + + + + diff --git a/src/Web/wwwroot/panes/kbadmin.html b/src/Web/wwwroot/panes/kbadmin.html new file mode 100644 index 0000000..16dfe6d --- /dev/null +++ b/src/Web/wwwroot/panes/kbadmin.html @@ -0,0 +1,158 @@ +
+
+

RAG 관리

+

지식 베이스 문서 업로드 / 인덱싱 / 관리 — 관리자 비밀번호 필요.

+
+
KB / RAG
+
+ + + + + + + + + + + + + + + diff --git a/src/Web/wwwroot/panes/llmchat.html b/src/Web/wwwroot/panes/llmchat.html new file mode 100644 index 0000000..fcd07fc --- /dev/null +++ b/src/Web/wwwroot/panes/llmchat.html @@ -0,0 +1,93 @@ +
+
+

로컬 LLM 채팅

+

로컬 Ollama 서버에 연결하여 LLM과 대화합니다.

+
+
LLM / CHAT
+
+ +
+
+
+ 대화 목록 + +
+
+
대화가 없습니다. + 버튼을 눌러 새 대화를 시작하세요.
+
+ +
+ +
+
+
+
+ + +
+
+ + +
+ + + + +
+
+ + +
+
+ + + +
+
+
💬
+
새 대화를 시작하세요
+
모델을 선택하고 메시지를 입력하세요
+
+
+ +
+
+ +
+ + +
+
+
+
+
diff --git a/src/Web/wwwroot/panes/nm-dash.html b/src/Web/wwwroot/panes/nm-dash.html new file mode 100644 index 0000000..c23508e --- /dev/null +++ b/src/Web/wwwroot/panes/nm-dash.html @@ -0,0 +1,73 @@ +
+
+

노드맵 대시보드

+

node_map_master 테이블을 조회합니다.

+
+
NODE MAP / MASTER
+
+ + +
+
필터 조건
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + diff --git a/src/Web/wwwroot/panes/opcsvr.html b/src/Web/wwwroot/panes/opcsvr.html new file mode 100644 index 0000000..6a475e0 --- /dev/null +++ b/src/Web/wwwroot/panes/opcsvr.html @@ -0,0 +1,25 @@ +
+
+

OPC UA 서버

+

ExperionCrawler를 OPC UA 서버로 동작시켜 외부 클라이언트에 실시간 값을 제공합니다.

+
+
+ + +
+
+ + 상태 조회 중... +
+
+
+ + +
+ + + + +
+ + diff --git a/src/Web/wwwroot/panes/pb.html b/src/Web/wwwroot/panes/pb.html new file mode 100644 index 0000000..8cd815a --- /dev/null +++ b/src/Web/wwwroot/panes/pb.html @@ -0,0 +1,311 @@ +
+
+

포인트빌더

+

node_map_master 에서 실시간 모니터링할 포인트를 선택해 realtime_table 을 구성합니다.

+
+
REALTIME / BUILD
+
+ + +
+
+
조건으로 테이블 작성
+ + + +
+
+ 컨트롤러 포인트 #1 +
+
+ + +
+
+ + + + +
+
+ + +
+
+ +
+
+ + +
+
+ 아날로그 모니터링 포인트 #2 +
+
+ + +
+
+ + + + +
+
+ + +
+
+ +
+
+ + +
+
+ 디지털 포인트 #1 +
+
+ + +
+
+ + + + +
+
+ + +
+
+ +
+
+ + +
+
+ 디지털 포인트 #2 +
+
+ + +
+
+ + + + +
+
+ + +
+
+ +
+
+ + +
+
+ 사용자 정의 +
+
+ + +
+
+ + + + +
+
+ + +
+
+ +
+
+ +
+ + + +
+ + +
+ +
+
수동 포인트 추가
+
+ + +
+ + + +
실시간 구독 제어
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+ +
+ + +
+
메타데이터 관리
+

+ 태그의 desc, area, state descriptor 정보를 OPC UA에서 조회하여 저장합니다. +

+
+ + +
+ + +
+ + +
+
Sub-Area (세부 Area) 관리
+

+ area(P6 등)를 Column 단위 sub_area(P6-1/P6-2)로 분류합니다. 공용 설비는 P6-1,P6-2처럼 + 두 코드가 함께 부여되어 어느 쪽 필터에도 잡힙니다. Seed는 번호 prefix 규칙 + pid_equipment 공용 검출을 사용합니다. +

+
+ + + + + +
+ + +
+
+ + +
+
+ 포인트 목록 (0개) + +
+
+
포인트가 없습니다. 위에서 테이블을 작성하세요.
+
+
diff --git a/src/Web/wwwroot/panes/pid.html b/src/Web/wwwroot/panes/pid.html new file mode 100644 index 0000000..6873454 --- /dev/null +++ b/src/Web/wwwroot/panes/pid.html @@ -0,0 +1,112 @@ +
+
+

P&ID 추출

+

DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출합니다.

+
+
P&ID / AI
+
+ + +
+
① PC → 서버 파일 전송
+
+ + +
+
+ 파일을 선택하고 서버로 전송하세요. +
+
+ + +
+
② 서버 파일 선택 → 추출
+
+ +
+
+ + + + + +
+
+ 대기 중... + +
+ +
+ + +
+
+ Prefix 분류 정의 + + 태그 prefix 기준으로 카테고리 자동 분류 + +
+ +
+ + +
+
+ 추출 결과 +
+ + +
+
+
+ + + + + + + + + + + + + + + + + +
ID태그번호장비명유형라인번호도면번호신뢰도상태매핑카테고리삭제
+
+ + +
+ + +
+
통계 정보
+
+
+
총 추출 건수
+
0
+
+
+
신뢰도 70%+
+
0
+
+
+
매핑 완료
+
0
+
+
+
diff --git a/src/Web/wwwroot/panes/t2s.html b/src/Web/wwwroot/panes/t2s.html new file mode 100644 index 0000000..f577911 --- /dev/null +++ b/src/Web/wwwroot/panes/t2s.html @@ -0,0 +1,89 @@ +
+
+

Text-to-SQL 시계열 대시보드

+

자연어 질의를 통해 TimeScaleDB 시계열 데이터를 조회하고 분석합니다.

+
+
AI / SQL
+
+ + +
+
🗣 자연어 쿼리
+
+ + + + +
+
+ 추천 쿼리: + + + + +
+
+ + +
+
📝 생성된 SQL
+ +
+ + +
+
🏷 태그 분석 옵션
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
📊 조회 결과
+
+ 쿼리를 실행하면 여기에 결과가 표시됩니다 +
+
+ +
+
📈 태그 분석 결과
+
+ 분석을 실행하면 여기에 결과가 표시됩니다 +
+
+ + diff --git a/src/Web/wwwroot/panes/write.html b/src/Web/wwwroot/panes/write.html new file mode 100644 index 0000000..9e77850 --- /dev/null +++ b/src/Web/wwwroot/panes/write.html @@ -0,0 +1,80 @@ +
+
+

OPC UA Write

+

Experion OPC UA 서버에 값을 씁니다. SP/OP 직접 쓰기, MD/MODE 변경, 통합 제어 지원.

+
+
WRITE / CONTROL
+
+ +
+ +
+
직접 쓰기 (SP / OP)
+
+ + +
+
+ + +
+ +
+ + +
+
Mode 변경 (MD / MODE)
+
+ + +
+
+ + +
+ +
+ + +
+
통합 제어 (OP 변경 — 자동 MAN→AUTO)
+

+ 태그명을 입력하면 MD/MODE를 MAN으로 전환 → OP 쓰기 → AUTO로 복귀하는 전체 시퀀스를 실행합니다. +

+
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + +
+
태그 읽기 (확인용)
+
+ + +
+
+
+
+ + diff --git a/웹UI-개선플랜-byOPUS.md b/웹UI-개선플랜-byOPUS.md index 463fcff..6c4ef10 100644 --- a/웹UI-개선플랜-byOPUS.md +++ b/웹UI-개선플랜-byOPUS.md @@ -1,6 +1,6 @@ # 웹 UI 구조 개선 플랜 — HTML/JS 모놀리식 분리 (by Opus) -> 작성: 2026-05-24 · 상태: **제안 (구현 보류)** · 대상: `src/Web/wwwroot/` +> 작성: 2026-05-24 · 상태: **✅ Phase 0~3 완료 (2026-05-24)** · Phase 4 미완 · 대상: `src/Web/wwwroot/` --- @@ -13,36 +13,19 @@ --- -## 1. 현황 진단 (실측 2026-05-24) +## 1. 현황 진단 → 개선 후 (실측 2026-05-24) -| 파일 | 라인 | 비고 | -|---|---|---| -| `wwwroot/index.html` | **1,761** | `
` **15개**가 한 파일에 인라인 | -| `wwwroot/js/app.js` | **5,148** | 전 탭 로직 한 파일 | -| `wwwroot/js/docs.js` | 571 | ✅ **이미 분리된 모범 사례** (문서 탐색기) | -| `wwwroot/css/style.css` | 2,230 | 전 탭 스타일 한 파일 (docs.css만 분리됨) | +| 파일 | 전 | 후 | 비고 | +|---|---|---|---| +| `wwwroot/index.html` | 1,761 → | **229** (-87%) | data-src 셸만 | +| `wwwroot/js/app.js` | 5,148 → | **10** (placeholder) | | +| `wwwroot/js/core.js` | — → | **219** | 공용 유틸 + 탭 라우터 + date picker | +| `wwwroot/js/docs.js` | 571 → | **713** | ✅ 분리 유지 | +| `wwwroot/panes/*.html` | — → | **16개 파일** | pane별 HTML 파셜 | +| `wwwroot/js/.js` | — → | **15개 파일** | 탭별 JS (최대 1,001줄/llmchat) | +| `wwwroot/css/style.css` | 2,230 | 2,230 | ⏳ Phase 4 미적용 | -**빌드 스텝 없음** — 순수 정적 SPA. `