ExperionCrawler First Commit

This commit is contained in:
windpacer
2026-04-14 04:02:43 +00:00
commit 323aec34af
158 changed files with 539535 additions and 0 deletions

550
src/Web/wwwroot/js/app.js Normal file
View File

@@ -0,0 +1,550 @@
/* ── 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();