/* ── 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 호출 없음 }); }); /* ── 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); 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 connBrowse() { setGlobal('busy', '노드 탐색'); try { const d = await api('POST', '/api/connection/browse', { serverConfig: getServerCfg('x'), startNodeId: null }); const wrap = document.getElementById('browse-wrap'); wrap.classList.remove('hidden'); if (d.success && d.nodes?.length) { wrap.innerHTML = `
탐색 결과: ${d.nodes.length}개 노드 (클릭하면 태그 입력란에 복사)
` + d.nodes.map(n => `
${n.nodeClass === 'Object' ? '📂' : '📌'} ${esc(n.displayName)} ${esc(n.nodeClass)} ${esc(n.nodeId)}
`).join(''); setGlobal('ok', '탐색 완료'); } else { wrap.innerHTML = `
${esc(d.error || '노드 없음')}
`; setGlobal('err', '탐색 실패'); } } catch (e) { 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('')}
ID Node ID Value Status 수집 시각 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('')}
IDLevelClass NameNode 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_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8']; const PB_DT_IDS = ['pb-dt1','pb-dt2']; async function pbLoad() { try { const n = await api('GET', '/api/nodemap/names'); const nameOpts = '' + (n.names||[]).map(nm => ``).join(''); PB_NAME_IDS.forEach(id => { const el = document.getElementById(id); const cur = el.value; el.innerHTML = nameOpts; if (cur) el.value = cur; }); // 데이터타입은 text input으로 직접 입력 — stats API 호출 없음 // 탭 진입 시 자동 조회 없음 — ↻ 새로 고침 버튼을 눌러야만 목록 표시됨 } catch (e) { console.error('pbLoad:', e); } } async function pbRefresh() { try { const d = await api('GET', '/api/pointbuilder/points'); document.getElementById('pb-count').textContent = `(${d.count}개)`; pbRender(d.points || []); rtStatus(); } catch (e) { console.error('pbRefresh:', e); } } function pbRender(points) { const tbl = document.getElementById('pb-table'); if (!points.length) { tbl.innerHTML = '
포인트가 없습니다. 위에서 테이블을 작성하세요.
'; return; } tbl.innerHTML = ` ${points.map(p => ` `).join('')}
IDTagNameNode ID LiveValueTimestamp
${p.id} ${esc(p.tagName)} ${esc(p.nodeId)} ${p.liveValue != null ? esc(p.liveValue) : ''} ${p.liveValue != null ? new Date(p.timestamp).toLocaleString('ko-KR') : '—'}
`; } async function pbBuild() { const names = PB_NAME_IDS.map(id => document.getElementById(id).value).filter(Boolean); const dataTypes = PB_DT_IDS.map(id => document.getElementById(id).value).filter(Boolean); setGlobal('busy', '포인트 빌드 중'); try { const d = await api('POST', '/api/pointbuilder/build', { names, dataTypes }); log('pb-build-log', [{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }]); setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트` : '빌드 실패'); if (d.success) await pbRefresh(); } catch (e) { log('pb-build-log', [{ c: 'err', t: '❌ ' + e.message }]); setGlobal('err', '오류'); } } 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) { if (!confirm(`포인트 #${id}를 삭제하시겠습니까?`)) return; try { const d = await api('DELETE', `/api/pointbuilder/${id}`); if (d.success) 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) { /* 무시 */ } } /* ───────────────────────────────────────────────────────────── 07 이력 조회 ───────────────────────────────────────────────────────────── */ const HIST_TAG_IDS = ['hf-t1','hf-t2','hf-t3','hf-t4','hf-t5','hf-t6','hf-t7','hf-t8']; // "▼ 옵션 불러오기" 버튼 클릭 시에만 호출 — 탭 진입 시 자동 호출 없음 async function histLoad() { try { const d = await api('GET', '/api/history/tagnames'); const opts = '' + (d.tagNames||[]).map(t => ``).join(''); HIST_TAG_IDS.forEach(id => { const el = document.getElementById(id); const cur = el.value; el.innerHTML = opts; if (cur) el.value = cur; }); } catch (e) { console.error('histLoad:', e); } } 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 limit = parseInt(document.getElementById('hf-limit').value) || 500; 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); setGlobal('busy', '이력 조회 중'); try { const d = await api('GET', `/api/history/query?${params}`); const rows = d.rows || []; const tNames = d.tagNames || []; const info = document.getElementById('hist-result-info'); info.classList.remove('hidden'); info.textContent = `총 ${rows.length.toLocaleString()}행 × ${tNames.length}개 태그`; const tbl = document.getElementById('hist-table'); tbl.classList.remove('hidden'); if (!rows.length) { tbl.innerHTML = '
조건에 맞는 이력이 없습니다.
'; setGlobal('ok', '0건'); return; } tbl.innerHTML = ` ${tNames.map(t => ``).join('')} ${rows.map(r => ` ${tNames.map(t => ``).join('')} `).join('')}
시각${esc(t)}
${new Date(r.recordedAt).toLocaleString('ko-KR')}${r.values?.[t] != null ? esc(r.values[t]) : ''}
`; setGlobal('ok', `${rows.length}건`); } catch (e) { setGlobal('err', '조회 실패'); console.error('histQuery:', e); } } function histReset() { HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; }); dtClearField('from'); dtClearField('to'); document.getElementById('hf-limit').value = '500'; document.getElementById('hist-result-info').classList.add('hidden'); document.getElementById('hist-table').classList.add('hidden'); } /* ── 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; } /* ── 초기 실행 ───────────────────────────────────────────────── */ certStatus();