commit 323aec34af7ca04f7d345dbb888bd9eb45bcde93 Author: windpacer Date: Tue Apr 14 04:02:43 2026 +0000 ExperionCrawler First Commit diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9e7b7d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# ExperionCrawler — 작업 이력 + +## 완료된 작업 + +### 노드맵 대시보드 구현 (2026-04-14) + +node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를 풀스택으로 구현했다. + +#### 수정된 파일 + +| 파일 | 내용 | +|------|------| +| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionDbService`에 `GetMasterStatsAsync()` / `QueryMasterAsync()` 추가, `NodeMapStats` / `NodeMapQueryResult` record 추가 | +| `src/Infrastructure/Database/ExperionDbContext.cs` | `ExperionDbService`에 두 메서드 구현 (통계·필터 조회, 페이지네이션) | +| `src/Web/Controllers/ExperionControllers.cs` | `ExperionNodeMapController` 추가 (`GET /api/nodemap/stats`, `GET /api/nodemap/query`) | +| `src/Web/wwwroot/index.html` | 사이드바 05번 탭 추가, `#pane-nm-dash` 섹션 추가 (통계 카드·필터폼·페이지네이션·테이블) | +| `src/Web/wwwroot/js/app.js` | `nmLoad()` / `nmQuery()` / `nmPrev()` / `nmNext()` / `nmReset()` 구현, 탭 클릭 핸들러에 `nmLoad()` 호출 추가 | +| `src/Web/wwwroot/css/style.css` | `.nm-stat-row`, `.nm-cls`, `.nm-dtype`, `.pg`, `.btn-sm` 등 대시보드 전용 스타일 추가 | + +#### 빌드 결과 +- 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공 + +#### 주의 사항 +- 인증서 관련 코드(`ExperionCertificateService.cs`, 인증서 컨트롤러)는 일절 수정하지 않음 + +--- + +### 이름 필터 드롭다운 OR 조건 검색 (2026-04-14) + +노드맵 대시보드의 이름 검색을 텍스트 입력에서 `name` 컬럼 고유값 풀다운 메뉴 4개로 교체, OR 조건 최대 4개 동시 선택 가능하도록 확장했다. + +#### 수정된 파일 + +| 파일 | 내용 | +|------|------| +| `src/Core/Application/Interfaces/IExperionServices.cs` | `GetNameListAsync()` 추가; `QueryMasterAsync` 파라미터 `string? name` → `IEnumerable? names` | +| `src/Infrastructure/Database/ExperionDbContext.cs` | `GetNameListAsync()` 구현 (distinct + 오름차순 정렬); `QueryMasterAsync`에서 `nameList.Contains(x.Name)` → EF가 `WHERE name IN (...)` SQL 생성 | +| `src/Web/Controllers/ExperionControllers.cs` | `GET /api/nodemap/names` 엔드포인트 추가; `Query` 액션 파라미터 `string? name` → `List? names` (ASP.NET Core가 `?names=A&names=B` 자동 바인딩) | +| `src/Web/wwwroot/index.html` | "이름 검색" 텍스트 입력 제거 → `nf-name-1` ~ `nf-name-4` 4개 ` + +
+ + +
+
+ + +
+ + + +
+
현재 인증서 상태
+ +
+ 상태 확인 버튼을 눌러 주세요 +
+
+ + + + + + +
+
+
+

서버 접속 테스트

+

Experion OPC UA 서버에 연결하고 노드 값을 읽습니다.

+
+
OPC UA / TCP
+
+ +
+
서버 설정
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
단일 태그 읽기
+
+ + +
+ +
+ + + +
+ + +
+
+
+

데이터 크롤링

+

지정한 노드 값을 주기적으로 수집하여 CSV 파일로 저장합니다.

+
+
CRAWL / CSV
+
+ +
+
+
서버 설정
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
수집 노드 목록 (한 줄에 하나씩)
+ + +
+
+ + + + + + +
+ +
+
+

노드맵 수집

+

서버 전체 노드를 재귀 탐색하여 AssetLoader 용 CSV 파일로 저장합니다.

+
+
NODE MAP / CSV
+
+ +
+
전체 노드 탐색 설정
+
+
+ + +
+ +
+

+ 서버 설정은 위 크롤링 설정을 그대로 사용합니다  ·  + 노드 수에 따라 수 분이 소요될 수 있습니다  ·  + 결과는 data/csv/{서버명}_*.csv 에 저장됩니다 +

+
+ + + + +
+ + +
+
+
+

DB 저장

+

수집된 CSV 파일을 PostgreSQL DB에 저장하고 레코드를 조회합니다.

+
+
PostgreSQL / EF
+
+ +
+
+
CSV → DB 임포트
+ +
+ 갱신 버튼을 눌러 주세요 +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+
DB 레코드 조회
+
+ + +
+ +
+
+ + + +
+ + +
+
+
+

노드맵 대시보드

+

node_map_master 테이블을 조회합니다.

+
+
NODE MAP / MASTER
+
+ + + + + +
+
필터 조건
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + +
+ + + + + + + diff --git a/src/Web/wwwroot/js/app.js b/src/Web/wwwroot/js/app.js new file mode 100644 index 0000000..3e5df7b --- /dev/null +++ b/src/Web/wwwroot/js/app.js @@ -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,'&').replace(//g,'>'); +} + +function setGlobal(state, text) { + document.getElementById('g-dot').className = `dot ${state}`; + document.getElementById('g-txt').textContent = text.toUpperCase(); +} + +function log(id, lines) { + const el = document.getElementById(id); + el.classList.remove('hidden'); + el.innerHTML = lines.map(l => `
${esc(l.t)}
`).join(''); + el.scrollTop = el.scrollHeight; +} + +async function api(method, path, body) { + const opt = { method, headers: { 'Content-Type': 'application/json' } }; + if (body) opt.body = JSON.stringify(body); + const res = await fetch(path, opt); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + return res.json(); +} + +/* ───────────────────────────────────────────────────────────── + 01 인증서 관리 +───────────────────────────────────────────────────────────── */ +async function certCreate() { + const clientHostName = document.getElementById('c-host').value.trim(); + const subjectAltNames = document.getElementById('c-san').value + .split(',').map(s => s.trim()).filter(Boolean); + const pfxPassword = document.getElementById('c-pw').value; + + setGlobal('busy', '인증서 생성 중'); + try { + const d = await api('POST', '/api/certificate/create', { + clientHostName, subjectAltNames, pfxPassword + }); + log('cert-log', [ + { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }, + ...(d.thumbPrint ? [{ c: 'inf', t: ' Thumbprint : ' + d.thumbPrint }] : []) + ]); + setGlobal(d.success ? 'ok' : 'err', d.success ? '인증서 완료' : '오류'); + if (d.success) { + document.getElementById('cert-dot').classList.add('on'); + await certStatus(); + } + } catch (e) { + log('cert-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } +} + +async function certStatus() { + const clientHostName = document.getElementById('c-host').value.trim() || 'dbsvr'; + try { + const d = await api('GET', `/api/certificate/status?clientHostName=${encodeURIComponent(clientHostName)}`); + const box = document.getElementById('cert-disp'); + if (d.exists) { + box.innerHTML = ` +
상태✅ 인증서 있음
+
Subject${esc(d.subjectName)}
+
만료일${d.notAfter ? new Date(d.notAfter).toLocaleDateString('ko-KR') : '-'}
+
Thumbprint${d.thumbPrint ? d.thumbPrint.slice(0,20)+'…' : '-'}
+
파일 경로${esc(d.filePath)}
+ `; + document.getElementById('cert-dot').classList.add('on'); + setGlobal('ok', '인증서 확인됨'); + } else { + box.innerHTML = ` +
상태❌ 인증서 없음
+
경로${esc(d.filePath)}
+ `; + setGlobal('', 'READY'); + } + } catch {} +} + +/* ───────────────────────────────────────────────────────────── + 02 서버 접속 테스트 +───────────────────────────────────────────────────────────── */ +function getServerCfg(p) { + return { + serverHostName: document.getElementById(`${p}-server`).value.trim(), + port: parseInt(document.getElementById(`${p}-port`).value) || 4840, + clientHostName: document.getElementById(`${p}-client`).value.trim(), + userName: document.getElementById(`${p}-user`).value.trim(), + password: document.getElementById(`${p}-pass`).value + }; +} + +async function connTest() { + setGlobal('busy', '접속 중'); + try { + const d = await api('POST', '/api/connection/test', getServerCfg('x')); + log('conn-log', [ + { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }, + ...(d.sessionId ? [{ c: 'inf', t: ' SessionID : ' + d.sessionId }] : []), + ...(d.policyUri ? [{ c: 'inf', t: ' Policy : ' + d.policyUri.split('/').pop() }] : []) + ]); + setGlobal(d.success ? 'ok' : 'err', d.success ? '연결 성공' : '연결 실패'); + } catch (e) { + log('conn-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } +} + +async function connRead() { + const nodeId = document.getElementById('x-node').value.trim(); + if (!nodeId) return; + setGlobal('busy', '태그 읽기'); + try { + const d = await api('POST', '/api/connection/read', { + serverConfig: getServerCfg('x'), nodeId + }); + const box = document.getElementById('tag-box'); + box.classList.remove('hidden'); + if (d.success) { + box.innerHTML = ` +
✅ 읽기 성공
+
NodeID : ${esc(d.nodeId)}
+
Value : ${esc(d.value ?? 'null')}
+
Status : ${esc(d.statusCode)}
+
Time : ${d.timestamp ? new Date(d.timestamp).toLocaleString('ko-KR') : '-'}
+ `; + setGlobal('ok', '읽기 완료'); + } else { + box.innerHTML = `
❌ ${esc(d.error || '읽기 실패')}
`; + setGlobal('err', '읽기 실패'); + } + } catch (e) { + setGlobal('err', '오류'); + } +} + +async function connBrowse() { + setGlobal('busy', '노드 탐색'); + try { + const d = await api('POST', '/api/connection/browse', { + serverConfig: getServerCfg('x'), startNodeId: null + }); + const wrap = document.getElementById('browse-wrap'); + wrap.classList.remove('hidden'); + if (d.success && d.nodes?.length) { + wrap.innerHTML = + `
탐색 결과: ${d.nodes.length}개 노드 (클릭하면 태그 입력란에 복사)
` + + d.nodes.map(n => ` +
+ ${n.nodeClass === 'Object' ? '📂' : '📌'} + ${esc(n.displayName)} + ${esc(n.nodeClass)} + ${esc(n.nodeId)} +
+ `).join(''); + setGlobal('ok', '탐색 완료'); + } else { + wrap.innerHTML = `
${esc(d.error || '노드 없음')}
`; + setGlobal('err', '탐색 실패'); + } + } catch (e) { + setGlobal('err', '오류'); + } +} + +/* ───────────────────────────────────────────────────────────── + 03 데이터 크롤링 +───────────────────────────────────────────────────────────── */ +async function crawlStart() { + const nodeIds = document.getElementById('w-nodes').value + .trim().split('\n').map(s => s.trim()).filter(Boolean); + if (!nodeIds.length) { alert('노드 ID를 입력하세요.'); return; } + + const cfg = { + serverHostName: document.getElementById('w-server').value.trim(), + port: parseInt(document.getElementById('w-port').value) || 4840, + clientHostName: document.getElementById('w-client').value.trim(), + userName: document.getElementById('w-user').value.trim(), + password: document.getElementById('w-pass').value + }; + const intervalSeconds = parseInt(document.getElementById('w-interval').value) || 1; + const durationSeconds = parseInt(document.getElementById('w-duration').value) || 30; + + const btn = document.getElementById('crawl-btn'); + btn.disabled = true; + btn.textContent = '⏳ 수집 중...'; + + const prog = document.getElementById('crawl-prog'); + prog.classList.remove('hidden'); + document.getElementById('crawl-bar').style.width = '0%'; + document.getElementById('crawl-ptxt').textContent = '서버 연결 중...'; + document.getElementById('crawl-cnt').textContent = '0 건'; + setGlobal('busy', '크롤링 중'); + + // 백엔드는 동기식이므로 프런트에서 타이머로 UI 진행 표시 + let fake = 0; + const ticker = setInterval(() => { + fake = Math.min(fake + (100 / durationSeconds) * .5, 94); + document.getElementById('crawl-bar').style.width = fake + '%'; + document.getElementById('crawl-ptxt').textContent = `수집 중... ${Math.round(fake)}%`; + }, 500); + + try { + const d = await api('POST', '/api/crawl/start', { + serverConfig: cfg, nodeIds, intervalSeconds, durationSeconds + }); + clearInterval(ticker); + document.getElementById('crawl-bar').style.width = '100%'; + document.getElementById('crawl-ptxt').textContent = '수집 완료'; + document.getElementById('crawl-cnt').textContent = d.totalRecords + ' 건'; + + log('crawl-log', [ + { c: 'ok', t: `✅ 크롤링 완료 — ${d.totalRecords}개 레코드` }, + { c: 'inf', t: ` Session : ${d.sessionId}` }, + { c: 'inf', t: ` CSV : ${d.csvPath}` }, + { c: 'mut', t: '--- 미리보기 ---' }, + ...(d.preview || []).map(r => ({ + c: 'val', + t: ` [${new Date(r.collectedAt).toLocaleTimeString('ko-KR')}] ${r.nodeId} = ${r.value} (${r.statusCode})` + })) + ]); + setGlobal('ok', '크롤링 완료'); + } catch (e) { + clearInterval(ticker); + log('crawl-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } finally { + btn.disabled = false; + btn.innerHTML = '📡 크롤링 시작'; + } +} + +/* ───────────────────────────────────────────────────────────── + 03-B 노드맵 수집 +───────────────────────────────────────────────────────────── */ +async function nodeMapCrawl() { + const maxDepth = parseInt(document.getElementById('nm-depth').value) || 10; + const cfg = { + serverHostName: document.getElementById('w-server').value.trim(), + port: parseInt(document.getElementById('w-port').value) || 4840, + clientHostName: document.getElementById('w-client').value.trim(), + userName: document.getElementById('w-user').value.trim(), + password: document.getElementById('w-pass').value + }; + + const btn = document.getElementById('nm-btn'); + btn.disabled = true; + btn.textContent = '⏳ 탐색 중...'; + + const prog = document.getElementById('nm-prog'); + prog.classList.remove('hidden'); + document.getElementById('nm-bar').style.width = '0%'; + document.getElementById('nm-ptxt').textContent = '서버 연결 중...'; + document.getElementById('nm-cnt').textContent = ''; + setGlobal('busy', '노드맵 수집 중'); + + // 완료 시점을 알 수 없으므로 완만하게 90%까지 증가 + let fake = 0; + const ticker = setInterval(() => { + if (fake < 90) { + fake = Math.min(fake + 0.25, 90); + document.getElementById('nm-bar').style.width = fake + '%'; + document.getElementById('nm-ptxt').textContent = `노드 탐색 중... ${Math.round(fake)}%`; + } + }, 500); + + try { + const d = await api('POST', '/api/crawl/nodemap', { serverConfig: cfg, maxDepth }); + clearInterval(ticker); + document.getElementById('nm-bar').style.width = '100%'; + document.getElementById('nm-ptxt').textContent = d.success ? '탐색 완료' : '탐색 실패'; + document.getElementById('nm-cnt').textContent = d.success ? d.totalCount.toLocaleString() + '개 노드' : ''; + + log('nm-log', d.success ? [ + { c: 'ok', t: `✅ 노드맵 수집 완료 — ${d.totalCount.toLocaleString()}개 노드` }, + { c: 'inf', t: ` 저장 경로 : ${d.csvPath}` }, + { c: 'mut', t: ` 04 DB 저장 탭에서 raw_node_map 테이블에 적재할 수 있습니다.` } + ] : [ + { c: 'err', t: `❌ ${d.error || '탐색 실패'}` } + ]); + setGlobal(d.success ? 'ok' : 'err', d.success ? `노드 ${d.totalCount}개` : '실패'); + } catch (e) { + clearInterval(ticker); + log('nm-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } finally { + btn.disabled = false; + btn.textContent = '🗺 전체 노드맵 수집'; + } +} + +/* ───────────────────────────────────────────────────────────── + 04 DB 저장 +───────────────────────────────────────────────────────────── */ +async function dbLoadFiles() { + try { + const d = await api('GET', '/api/database/files'); + const list = document.getElementById('file-list'); + if (d.files?.length) { + list.innerHTML = d.files.map(f => ` +
+ 📄${esc(f)} +
+ `).join(''); + } else { + list.innerHTML = 'CSV 파일이 없습니다'; + } + } catch (e) { alert('목록 로드 실패: ' + e.message); } +} + +function selectFile(name, el) { + document.querySelectorAll('.fitem').forEach(i => i.classList.remove('sel')); + el.classList.add('sel'); + document.getElementById('sel-csv').value = name; +} + +async function dbImport() { + const fileName = document.getElementById('sel-csv').value.trim(); + if (!fileName) { alert('파일을 선택하세요.'); return; } + // experion_ 으로 시작하면 일반 크롤 CSV, 그 외는 노드맵 CSV (서버명_timestamp.csv) + const serverHostName = fileName.startsWith('experion_') ? '' : fileName.split('_')[0]; + const truncate = document.querySelector('input[name="import-mode"]:checked').value === 'truncate'; + if (truncate && !confirm('기존 데이터를 모두 삭제하고 새로 저장합니다.\n계속하시겠습니까?')) return; + setGlobal('busy', 'DB 저장 중'); + try { + const d = await api('POST', '/api/database/import', { fileName, serverHostName, truncate }); + const isNodeMap = !!serverHostName; + const lines = [ + { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message } + ]; + if (d.success && isNodeMap) { + lines.push({ c: 'inf', t: ` raw_node_map : ${d.count}건` }); + lines.push({ c: 'inf', t: ` node_map_master: ${d.masterCount}건` }); + } else if (d.success) { + lines.push({ c: 'inf', t: ` 저장된 레코드 : ${d.count}건` }); + } + log('db-log', lines); + setGlobal(d.success ? 'ok' : 'err', d.success ? 'DB 저장 완료' : '저장 실패'); + if (d.success) await dbQuery(); + } catch (e) { + log('db-log', [{ c: 'err', t: '❌ ' + e.message }]); + setGlobal('err', '오류'); + } +} + +async function dbQuery() { + const limit = parseInt(document.getElementById('db-limit').value) || 100; + setGlobal('busy', 'DB 조회'); + try { + const d = await api('GET', `/api/database/records?limit=${limit}`); + + const stats = document.getElementById('db-stats'); + stats.classList.remove('hidden'); + stats.innerHTML = ` +
${d.total.toLocaleString()}
전체 레코드
+
${(d.records||[]).length}
현재 조회
+ `; + + const tbl = document.getElementById('db-table'); + tbl.classList.remove('hidden'); + if (d.records?.length) { + tbl.innerHTML = ` + + + + + + + + + + + + + ${d.records.map(r => ` + + + + + + + + + `).join('')} + +
IDNode IDValueStatus수집 시각Session
${r.id}${esc(r.nodeId)}${esc(r.value ?? 'null')}${esc(r.statusCode)}${new Date(r.collectedAt).toLocaleString('ko-KR')}${esc(r.sessionId ?? '-')}
+ `; + } else { + tbl.innerHTML = '
저장된 레코드가 없습니다.
'; + } + setGlobal('ok', `${d.total}건`); + } catch (e) { setGlobal('err', '조회 실패'); } +} + +/* ───────────────────────────────────────────────────────────── + 05 노드맵 대시보드 +───────────────────────────────────────────────────────────── */ +let _nmOffset = 0; +let _nmTotal = 0; +let _nmLimit = 100; + +async function 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 = ` +
${s.total.toLocaleString()}
전체 노드
+
${s.objectCount.toLocaleString()}
Object
+
${s.variableCount.toLocaleString()}
Variable
+
${s.maxLevel}
최대 깊이
+
${(s.dataTypes||[]).length}
데이터 타입 종류
+ `; + + // 데이터타입 드롭다운 채우기 + const sel = document.getElementById('nf-dtype'); + const curDtype = sel.value; + sel.innerHTML = '' + + (s.dataTypes||[]).map(t => ``).join(''); + + // 이름 드롭다운 4개 채우기 + const nameOpts = '' + + (n.names||[]).map(nm => ``).join(''); + ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => { + const el = document.getElementById(id); + const cur = el.value; + el.innerHTML = nameOpts; + if (cur) el.value = cur; + }); + + 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 = ` + + + + + + + + + ${d.items.map(r => ` + + + + + + + + + `).join('')} + +
IDLevelClassNameNode IDData Type
${r.id}${r.level}${esc(r.class)}${esc(r.name)}${esc(r.nodeId)}${esc(r.dataType)}
+ `; + } else { + tbl.innerHTML = '
조건에 맞는 노드가 없습니다.
'; + } + setGlobal('ok', `${_nmTotal.toLocaleString()}건`); + } catch (e) { + setGlobal('err', '조회 실패'); + console.error('nmQuery:', e); + } +} + +function nmPrev() { + if (_nmOffset > 0) nmQuery(Math.max(0, _nmOffset - _nmLimit)); +} +function nmNext() { + if (_nmOffset + _nmLimit < _nmTotal) nmQuery(_nmOffset + _nmLimit); +} +function nmReset() { + document.getElementById('nf-lv-min').value = ''; + document.getElementById('nf-lv-max').value = ''; + document.getElementById('nf-class').value = ''; + document.getElementById('nf-nid').value = ''; + document.getElementById('nf-dtype').value = ''; + document.getElementById('nf-limit').value = '100'; + ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => { + document.getElementById(id).value = ''; + }); + nmQuery(0); +} + +/* ── 초기 실행 ───────────────────────────────────────────────── */ +certStatus();