Realtime DB 추가 및 Historical DB추가

This commit is contained in:
windpacer
2026-04-14 09:56:37 +00:00
parent 323aec34af
commit 68758f1bb8
23 changed files with 1743 additions and 47 deletions

View File

@@ -6,7 +6,9 @@ document.querySelectorAll('.nav-item').forEach(item => {
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();
// nm-dash: 탭 진입 시 API 호출 없음 — 조회 버튼으로만 동작
// pb: 탭 진입 시 API 호출 없음 — ▼ 옵션 불러오기 버튼으로만 동작
// hist: 탭 진입 시 API 호출 없음
});
});
@@ -412,30 +414,10 @@ let _nmOffset = 0;
let _nmTotal = 0;
let _nmLimit = 100;
async function nmLoad() {
// 이름 드롭다운 옵션만 필요할 때 수동으로 불러오는 함수 (버튼 클릭 시)
async function nmLoadNames() {
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 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 => {
@@ -444,9 +426,7 @@ async function nmLoad() {
el.innerHTML = nameOpts;
if (cur) el.value = cur;
});
if (s.total > 0) nmQuery(0);
} catch (e) { console.error('nmLoad:', e); }
} catch (e) { console.error('nmLoadNames:', e); }
}
async function nmQuery(offset) {
@@ -458,7 +438,7 @@ async function nmQuery(offset) {
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 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);
@@ -543,7 +523,236 @@ function nmReset() {
['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => {
document.getElementById(id).value = '';
});
nmQuery(0);
// 초기화 후 자동 조회 없음
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 = ''; });
document.getElementById('hf-from').value = '';
document.getElementById('hf-to').value = '';
document.getElementById('hf-limit').value = '500';
document.getElementById('hist-result-info').classList.add('hidden');
document.getElementById('hist-table').classList.add('hidden');
}
/* ── 초기 실행 ───────────────────────────────────────────────── */