Files
ExperionCrawler/src/Web/wwwroot/js/app.js
2026-04-15 01:43:07 +00:00

882 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ── 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 => `<div class="ll ${l.c}">${esc(l.t)}</div>`).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 = `
<div class="kv"><span class="kk">상태</span><span class="kv2 ok">✅ 인증서 있음</span></div>
<div class="kv"><span class="kk">Subject</span><span class="kv2">${esc(d.subjectName)}</span></div>
<div class="kv"><span class="kk">만료일</span><span class="kv2">${d.notAfter ? new Date(d.notAfter).toLocaleDateString('ko-KR') : '-'}</span></div>
<div class="kv"><span class="kk">Thumbprint</span><span class="kv2">${d.thumbPrint ? d.thumbPrint.slice(0,20)+'…' : '-'}</span></div>
<div class="kv"><span class="kk">파일 경로</span><span class="kv2">${esc(d.filePath)}</span></div>
`;
document.getElementById('cert-dot').classList.add('on');
setGlobal('ok', '인증서 확인됨');
} else {
box.innerHTML = `
<div class="kv"><span class="kk">상태</span><span class="kv2 err">❌ 인증서 없음</span></div>
<div class="kv"><span class="kk">경로</span><span class="kv2">${esc(d.filePath)}</span></div>
`;
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 = `
<div class="ll ok">✅ 읽기 성공</div>
<div class="ll"><span class="mut">NodeID : </span><span style="color:var(--t0)">${esc(d.nodeId)}</span></div>
<div class="ll"><span class="mut">Value : </span><span class="val">${esc(d.value ?? 'null')}</span></div>
<div class="ll"><span class="mut">Status : </span><span class="ok">${esc(d.statusCode)}</span></div>
<div class="ll"><span class="mut">Time : </span><span class="inf">${d.timestamp ? new Date(d.timestamp).toLocaleString('ko-KR') : '-'}</span></div>
`;
setGlobal('ok', '읽기 완료');
} else {
box.innerHTML = `<div class="ll err">❌ ${esc(d.error || '읽기 실패')}</div>`;
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 =
`<div style="font-family:var(--fm);font-size:11px;color:var(--t2);margin-bottom:12px">탐색 결과: ${d.nodes.length}개 노드 (클릭하면 태그 입력란에 복사)</div>` +
d.nodes.map(n => `
<div class="bnode" onclick="document.getElementById('x-node').value='${esc(n.nodeId)}'">
<span>${n.nodeClass === 'Object' ? '📂' : '📌'}</span>
<span style="color:var(--t0)">${esc(n.displayName)}</span>
<span class="bclass">${esc(n.nodeClass)}</span>
<span class="bnid">${esc(n.nodeId)}</span>
</div>
`).join('');
setGlobal('ok', '탐색 완료');
} else {
wrap.innerHTML = `<div style="color:var(--t2);font-size:12px">${esc(d.error || '노드 없음')}</div>`;
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 => `
<div class="fitem" onclick="selectFile('${esc(f)}',this)">
<span>📄</span><span>${esc(f)}</span>
</div>
`).join('');
} else {
list.innerHTML = '<span class="placeholder">CSV 파일이 없습니다</span>';
}
} 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 = `
<div class="stat"><div class="sv">${d.total.toLocaleString()}</div><div class="sk">전체 레코드</div></div>
<div class="stat"><div class="sv">${(d.records||[]).length}</div><div class="sk">현재 조회</div></div>
`;
const tbl = document.getElementById('db-table');
tbl.classList.remove('hidden');
if (d.records?.length) {
tbl.innerHTML = `
<table>
<thead>
<tr>
<th>ID</th>
<th>Node ID</th>
<th>Value</th>
<th>Status</th>
<th>수집 시각</th>
<th>Session</th>
</tr>
</thead>
<tbody>
${d.records.map(r => `
<tr>
<td class="mut">${r.id}</td>
<td style="font-size:11px">${esc(r.nodeId)}</td>
<td class="val">${esc(r.value ?? 'null')}</td>
<td><span class="${r.statusCode === 'Good' ? 'bg' : 'br'}">${esc(r.statusCode)}</span></td>
<td>${new Date(r.collectedAt).toLocaleString('ko-KR')}</td>
<td class="mut" style="font-size:10px">${esc(r.sessionId ?? '-')}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} else {
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">저장된 레코드가 없습니다.</div>';
}
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 = '<option value="">— 선택 안 함 —</option>' +
(n.names||[]).map(nm => `<option value="${esc(nm)}">${esc(nm)}</option>`).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 = `
<table>
<thead>
<tr>
<th>ID</th><th>Level</th><th>Class</th>
<th>Name</th><th>Node ID</th><th>Data Type</th>
</tr>
</thead>
<tbody>
${d.items.map(r => `
<tr>
<td class="mut" style="font-size:11px">${r.id}</td>
<td class="mono" style="text-align:center">${r.level}</td>
<td><span class="nm-cls nm-cls-${(r.class||'').toLowerCase()}">${esc(r.class)}</span></td>
<td>${esc(r.name)}</td>
<td class="mut" style="font-size:11px;font-family:var(--fm)">${esc(r.nodeId)}</td>
<td><span class="nm-dtype">${esc(r.dataType)}</span></td>
</tr>
`).join('')}
</tbody>
</table>
`;
} else {
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 노드가 없습니다.</div>';
}
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 = '<option value="">— 선택 안 함 —</option>' +
(n.names||[]).map(nm => `<option value="${esc(nm)}">${esc(nm)}</option>`).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 = '<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>';
return;
}
tbl.innerHTML = `
<table>
<thead>
<tr>
<th>ID</th><th>TagName</th><th>Node ID</th>
<th>LiveValue</th><th>Timestamp</th><th></th>
</tr>
</thead>
<tbody>
${points.map(p => `
<tr>
<td class="mut">${p.id}</td>
<td style="font-weight:600">${esc(p.tagName)}</td>
<td class="mut" style="font-size:11px;font-family:var(--fm)">${esc(p.nodeId)}</td>
<td class="val">${p.liveValue != null ? esc(p.liveValue) : '<span style="color:var(--t3)">—</span>'}</td>
<td class="mut" style="font-size:11px">${p.liveValue != null ? new Date(p.timestamp).toLocaleString('ko-KR') : '—'}</td>
<td><button class="btn-sm btn-b" style="color:var(--red,#e55)" onclick="pbDelete(${p.id})">✕</button></td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
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 = `<div class="ll ${d.running ? 'ok' : 'inf'}">
${d.running ? '▶' : '■'} ${esc(d.message)} (구독 ${d.subscribedCount}개)
</div>`;
} 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 = '<option value="">— 선택 안 함 —</option>' +
(d.tagNames||[]).map(t => `<option value="${esc(t)}">${esc(t)}</option>`).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 = '<div style="padding:20px;color:var(--t2)">조건에 맞는 이력이 없습니다.</div>';
setGlobal('ok', '0건');
return;
}
tbl.innerHTML = `
<table>
<thead>
<tr>
<th>시각</th>
${tNames.map(t => `<th>${esc(t)}</th>`).join('')}
</tr>
</thead>
<tbody>
${rows.map(r => `
<tr>
<td class="mut" style="white-space:nowrap">${new Date(r.recordedAt).toLocaleString('ko-KR')}</td>
${tNames.map(t => `<td class="val">${r.values?.[t] != null ? esc(r.values[t]) : '<span style="color:var(--t3)">—</span>'}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
`;
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 => `<div class="dt-dow">${d}</div>`).join('');
for (let i = first - 1; i >= 0; i--)
html += `<div class="dt-day other-month">${daysInPrev - i}</div>`;
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 += `<div class="${cls}" onclick="dtSelectDay(${d})">${d}</div>`;
}
const trailing = (first + daysInMon) % 7;
for (let d = 1; d <= (trailing ? 7 - trailing : 0); d++)
html += `<div class="dt-day other-month">${d}</div>`;
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();