882 lines
37 KiB
JavaScript
882 lines
37 KiB
JavaScript
/* ── 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,'<').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 => `<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();
|