Files
ExperionCrawler/src/Web/wwwroot/js/app.js
2026-04-14 04:02:43 +00:00

551 lines
24 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');
if (tab === 'nm-dash') nmLoad();
});
});
/* ── 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 nmLoad() {
try {
const [s, n] = await Promise.all([
api('GET', '/api/nodemap/stats'),
api('GET', '/api/nodemap/names')
]);
const row = document.getElementById('nm-stat-row');
row.classList.remove('hidden');
row.innerHTML = `
<div class="stat"><div class="sv">${s.total.toLocaleString()}</div><div class="sk">전체 노드</div></div>
<div class="stat"><div class="sv" style="color:var(--a)">${s.objectCount.toLocaleString()}</div><div class="sk">Object</div></div>
<div class="stat"><div class="sv" style="color:var(--grn)">${s.variableCount.toLocaleString()}</div><div class="sk">Variable</div></div>
<div class="stat"><div class="sv" style="color:var(--blu)">${s.maxLevel}</div><div class="sk">최대 깊이</div></div>
<div class="stat"><div class="sv">${(s.dataTypes||[]).length}</div><div class="sk">데이터 타입 종류</div></div>
`;
// 데이터타입 드롭다운 채우기
const sel = document.getElementById('nf-dtype');
const curDtype = sel.value;
sel.innerHTML = '<option value="">전체</option>' +
(s.dataTypes||[]).map(t => `<option value="${esc(t)}"${t===curDtype?' selected':''}>${esc(t)}</option>`).join('');
// 이름 드롭다운 4개 채우기
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;
});
if (s.total > 0) nmQuery(0);
} catch (e) { console.error('nmLoad:', 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;
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 = '';
});
nmQuery(0);
}
/* ── 초기 실행 ───────────────────────────────────────────────── */
certStatus();