삽질하다 도저히 문제 파악이 안돼서 opcUaManager로 분리 테스트 중

This commit is contained in:
2026-02-25 08:52:03 +09:00
parent 4ea351946a
commit e88ab87771
138 changed files with 1051971 additions and 351 deletions

View File

@@ -0,0 +1,828 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OPC UA Control Panel</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0c10;
--surface: #111520;
--border: #1e2535;
--border2: #2a3550;
--accent: #00d4ff;
--accent2: #00ff9d;
--warn: #ffb800;
--danger: #ff3c5a;
--text: #c8d8f0;
--muted: #4a5a78;
--mono: 'Share Tech Mono', monospace;
--sans: 'Rajdhani', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg); color: var(--text);
font-family: var(--sans); font-size: 15px;
min-height: 100vh; overflow-x: hidden;
}
body::before {
content: ''; position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,212,255,.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,212,255,.03) 1px, transparent 1px);
background-size: 40px 40px; pointer-events: none; z-index: 0;
}
header {
position: relative; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
padding: 14px 32px;
background: rgba(17,21,32,.95);
border-bottom: 1px solid var(--border2);
backdrop-filter: blur(12px);
}
.logo { display: flex; align-items: center; gap: 14px; }
.logo-mark {
width: 36px; height: 36px; border: 2px solid var(--accent);
transform: rotate(45deg); position: relative;
animation: spin-pulse 4s ease-in-out infinite;
}
.logo-mark::after {
content: ''; position: absolute; inset: 5px;
background: var(--accent); opacity: .6;
}
@keyframes spin-pulse {
0%,100% { box-shadow: 0 0 8px var(--accent); }
50% { box-shadow: 0 0 24px var(--accent), 0 0 48px rgba(0,212,255,.3); }
}
.logo-text { font-size: 22px; font-weight: 700; letter-spacing: 4px; color: #fff; }
.logo-sub { font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 2px; }
.header-right { display: flex; align-items: center; gap: 20px; }
.api-url-wrap { display: flex; align-items: center; gap: 8px; }
.api-url-wrap label { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.api-url-wrap input {
background: #080b12; border: 1px solid var(--border2); border-radius: 3px;
padding: 5px 10px; color: var(--text); font-family: var(--mono); font-size: 12px;
outline: none; width: 220px;
}
.header-status { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; color: var(--muted); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); transition: all .4s; }
.status-dot.online { background: var(--accent2); box-shadow: 0 0 10px var(--accent2); animation: blink 2s infinite; }
.status-dot.error { background: var(--danger); box-shadow: 0 0 10px var(--danger); }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.4} }
main {
position: relative; z-index: 1;
max-width: 1200px; margin: 0 auto; padding: 32px 24px;
display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
}
.panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: 4px; overflow: hidden; position: relative; transition: border-color .3s;
}
.panel:hover { border-color: var(--border2); }
.panel::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0; transition: opacity .3s;
}
.panel:hover::before { opacity: 1; }
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid var(--border);
background: rgba(255,255,255,.02);
}
.panel-title {
font-size: 13px; font-weight: 600; letter-spacing: 3px;
color: var(--accent); text-transform: uppercase;
display: flex; align-items: center; gap: 10px;
}
.panel-num { font-family: var(--mono); font-size: 10px; color: var(--muted); padding: 2px 8px; border: 1px solid var(--border2); border-radius: 2px; }
.panel-body { padding: 20px; }
.field { margin-bottom: 16px; }
.field label { display: block; margin-bottom: 6px; font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 1.5px; text-transform: uppercase; }
.field input {
width: 100%; background: #080b12; border: 1px solid var(--border2); border-radius: 3px;
padding: 10px 14px; color: var(--text); font-family: var(--mono); font-size: 13px;
outline: none; transition: border-color .2s, box-shadow .2s;
}
.field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,212,255,.1), inset 0 0 20px rgba(0,212,255,.03); }
.field input::placeholder { color: var(--muted); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.btn {
position: relative; overflow: hidden;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 11px 24px; border: 1px solid; border-radius: 3px;
font-family: var(--sans); font-size: 13px; font-weight: 600;
letter-spacing: 2px; text-transform: uppercase; cursor: pointer; transition: all .2s; outline: none;
}
.btn::before { content: ''; position: absolute; inset: 0; background: currentColor; opacity: 0; transition: opacity .2s; }
.btn:hover::before { opacity: .08; }
.btn:active { transform: scale(.98); }
.btn-primary { background: transparent; color: var(--accent); border-color: var(--accent); box-shadow: inset 0 0 20px rgba(0,212,255,.05); }
.btn-primary:hover { background: rgba(0,212,255,.1); box-shadow: 0 0 20px rgba(0,212,255,.25), inset 0 0 20px rgba(0,212,255,.05); }
.btn-success { background: transparent; color: var(--accent2); border-color: var(--accent2); }
.btn-success:hover { background: rgba(0,255,157,.08); box-shadow: 0 0 20px rgba(0,255,157,.25); }
.btn-warn { background: transparent; color: var(--warn); border-color: var(--warn); }
.btn-warn:hover { background: rgba(255,184,0,.08); box-shadow: 0 0 20px rgba(255,184,0,.2); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.btn-danger:hover { background: rgba(255,60,90,.08); box-shadow: 0 0 20px rgba(255,60,90,.2); }
.btn:disabled { opacity: .35; cursor: not-allowed; pointer-events: none; }
.btn-full { width: 100%; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.console { grid-column: 1/-1; background: #050710; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.console-header { padding: 10px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.console-title { font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 2px; }
.console-body { height: 240px; overflow-y: auto; padding: 14px 20px; font-family: var(--mono); font-size: 12px; line-height: 1.8; }
.console-body::-webkit-scrollbar { width: 4px; }
.console-body::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.log-line { display: flex; gap: 12px; }
.log-time { color: var(--muted); flex-shrink: 0; }
.log-msg.info { color: var(--text); }
.log-msg.ok { color: var(--accent2); }
.log-msg.warn { color: var(--warn); }
.log-msg.error { color: var(--danger); }
.log-msg.accent{ color: var(--accent); }
.db-table { grid-column: 1/-1; }
.data-grid { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
.data-grid th { padding: 10px 16px; text-align: left; color: var(--muted); font-size: 10px; letter-spacing: 2px; text-transform: uppercase; border-bottom: 1px solid var(--border); background: rgba(255,255,255,.02); }
.data-grid td { padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,.04); color: var(--text); }
.data-grid tr:last-child td { border-bottom: none; }
.data-grid tr:hover td { background: rgba(0,212,255,.04); }
.data-grid .val { color: var(--accent2); }
.data-grid .status-ok { color: var(--accent2); }
.data-grid .status-err { color: var(--danger); }
.row-new { animation: row-flash .6s ease; }
@keyframes row-flash { 0%{background:rgba(0,255,157,.15)} 100%{background:transparent} }
.metrics { grid-column: 1/-1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.metric { background: var(--surface); padding: 16px 20px; display: flex; flex-direction: column; gap: 4px; }
.metric-label { font-family: var(--mono); font-size: 10px; color: var(--muted); letter-spacing: 2px; text-transform: uppercase; }
.metric-val { font-family: var(--mono); font-size: 22px; font-weight: 700; color: var(--text); transition: color .3s; }
.metric-val.accent { color: var(--accent); }
.metric-val.success { color: var(--accent2); }
.metric-val.warn { color: var(--warn); }
.progress-wrap { margin-top: 14px; }
.progress-label { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 11px; color: var(--muted); margin-bottom: 6px; }
.progress-bar { height: 4px; background: var(--border2); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 2px; transition: width .4s ease; box-shadow: 0 0 8px var(--accent); }
.badge { display: inline-block; padding: 2px 10px; border-radius: 2px; font-family: var(--mono); font-size: 10px; letter-spacing: 1px; }
.badge-blue { background: rgba(0,212,255,.1); color: var(--accent); border: 1px solid rgba(0,212,255,.3); }
.badge-green { background: rgba(0,255,157,.1); color: var(--accent2); border: 1px solid rgba(0,255,157,.3); }
.cert-result {
margin-top: 14px; padding: 12px 14px;
background: rgba(0,255,157,.04); border: 1px solid rgba(0,255,157,.2); border-radius: 3px;
font-family: var(--mono); font-size: 11px; line-height: 2; display: none;
}
.cert-result .cr-label { color: var(--muted); }
.cert-result .cr-val { color: var(--accent2); }
.divider { grid-column: 1/-1; display: flex; align-items: center; gap: 16px; padding: 4px 0; }
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.divider span { font-family: var(--mono); font-size: 10px; color: var(--muted); letter-spacing: 3px; white-space: nowrap; }
@media (max-width: 768px) { main { grid-template-columns: 1fr; } .metrics { grid-template-columns: repeat(2, 1fr); } }
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-mark"></div>
<div>
<div class="logo-text">OPC·UA</div>
<div class="logo-sub">INDUSTRIAL CONTROL PANEL</div>
</div>
</div>
<div class="header-right">
<div class="api-url-wrap">
<label>API</label>
<input type="text" id="apiBase" value="http://localhost:5000">
</div>
<div class="header-status">
<div class="status-dot" id="connDot"></div>
<span id="connLabel">DISCONNECTED</span>
</div>
</div>
</header>
<main>
<!-- METRICS -->
<div class="metrics">
<div class="metric"><span class="metric-label">OPC SESSION</span><span class="metric-val" id="m-conn">OFFLINE</span></div>
<div class="metric"><span class="metric-label">NODES FOUND</span><span class="metric-val accent" id="m-nodes"></span></div>
<div class="metric"><span class="metric-label">DB RECORDS</span><span class="metric-val success" id="m-records">0</span></div>
<div class="metric"><span class="metric-label">LAST VALUE</span><span class="metric-val warn" id="m-lastval"></span></div>
</div>
<!-- ① CERT -->
<div class="panel">
<div class="panel-header">
<div class="panel-title"><span>🔐</span> Certificate Generator <span class="panel-num">01</span></div>
<span class="badge badge-blue">PKI / X.509</span>
</div>
<div class="panel-body">
<div class="field-row">
<div class="field">
<label>내 컴퓨터 호스트명</label>
<input type="text" id="clientHost" value="dbsvr" oninput="updateUri()">
</div>
<div class="field">
<label>Application Name</label>
<input type="text" id="appName" value="OpcTestClient" oninput="updateUri()">
</div>
</div>
<div class="field">
<label>Application URI (자동 생성)</label>
<input type="text" id="appUri" readonly>
</div>
<div class="field-row">
<div class="field">
<label>OPC 서버 호스트명</label>
<input type="text" id="serverHost" placeholder="opc-server-01">
</div>
<div class="field">
<label>OPC 서버 IP</label>
<input type="text" id="serverIp" value="192.168.0.20">
</div>
</div>
<div class="field-row">
<div class="field">
<label>PFX 비밀번호</label>
<input type="password" id="pfxPassword" placeholder="(없으면 공백)">
</div>
<div class="field">
<label>유효 기간 (일)</label>
<input type="number" id="certDays" value="365" min="1">
</div>
</div>
<!-- 결과 박스 -->
<div class="cert-result" id="certResult">
<div><span class="cr-label">Thumbprint : </span><span class="cr-val" id="crThumb"></span></div>
<div><span class="cr-label">Serial No : </span><span class="cr-val" id="crSerial"></span></div>
<div><span class="cr-label">Not Before : </span><span class="cr-val" id="crFrom"></span></div>
<div><span class="cr-label">Not After : </span><span class="cr-val" id="crTo"></span></div>
<div><span class="cr-label">PFX Path : </span><span class="cr-val" id="crPath"></span></div>
</div>
<div style="display:flex;gap:10px;margin-top:12px">
<button class="btn btn-primary btn-full" id="btnCert" onclick="generateCert()">
<span></span> 인증서 생성
</button>
<button class="btn btn-success" id="btnCertDownload" style="padding:11px 16px;display:none" title="PFX 다운로드" onclick="downloadPfx()"></button>
</div>
</div>
</div>
<!-- ② SESSION -->
<div class="panel">
<div class="panel-header">
<div class="panel-title"><span>🔗</span> OPC-UA Session <span class="panel-num">02</span></div>
<span class="badge badge-blue">opc.tcp://</span>
</div>
<div class="panel-body">
<div class="field-row">
<div class="field">
<label>서버 IP</label>
<input type="text" id="endpointIp" value="192.168.0.20">
</div>
<div class="field">
<label>포트</label>
<input type="number" id="endpointPort" value="4840">
</div>
</div>
<div class="field-row">
<div class="field">
<label>사용자명</label>
<input type="text" id="opcUser" value="mngr">
</div>
<div class="field">
<label>비밀번호</label>
<input type="password" id="opcPass" value="mngr">
</div>
</div>
<div class="field">
<label>Security Policy</label>
<input type="text" id="secPolicy" value="Basic256Sha256">
</div>
<div class="field-row">
<div class="field">
<label>PFX 파일명 (pki/own/certs/)</label>
<input type="text" id="pfxFile" value="OpcTestClient.pfx">
</div>
<div class="field">
<label>PFX 비밀번호</label>
<input type="password" id="sessPfxPass">
</div>
</div>
<div style="display:flex;gap:10px;margin-top:8px">
<button class="btn btn-success btn-full" id="btnConnect" onclick="connectSession()">
<span></span> 연결
</button>
<button class="btn btn-danger" id="btnDisconnect" onclick="disconnectSession()" disabled style="padding:11px 18px"></button>
</div>
</div>
</div>
<div class="divider"><span>NODE CRAWLER &amp; DATABASE</span></div>
<!-- ③ CRAWLER -->
<div class="panel">
<div class="panel-header">
<div class="panel-title"><span>🌐</span> Node Crawler <span class="panel-num">03</span></div>
<span class="badge badge-green" id="crawlBadge">IDLE</span>
</div>
<div class="panel-body">
<div class="field">
<label>시작 Node ID</label>
<input type="text" id="startNode" value="ns=1;s=$assetmodel">
</div>
<div class="field">
<label>최대 탐색 깊이</label>
<input type="number" id="crawlDepth" value="5" min="1" max="20">
</div>
<div class="progress-wrap" id="crawlProgressWrap" style="display:none">
<div class="progress-label"><span id="crawlStatus">탐색 중...</span><span id="crawlCount">0 nodes</span></div>
<div class="progress-bar"><div class="progress-fill" id="crawlFill"></div></div>
</div>
<div style="display:flex;gap:10px;margin-top:8px">
<button class="btn btn-success btn-full" id="btnCrawl" onclick="startCrawler()" disabled>
<span></span> Crawler 시작
</button>
<button class="btn btn-warn" id="btnExportCsv" onclick="downloadCsv()" disabled style="padding:11px 18px" title="CSV 다운로드"></button>
</div>
</div>
</div>
<!-- ④ DATABASE -->
<div class="panel">
<div class="panel-header">
<div class="panel-title"><span>🗄</span> Database Writer <span class="panel-num">04</span></div>
<span class="badge badge-blue">PostgreSQL</span>
</div>
<div class="panel-body">
<div class="field">
<label>Tag Node ID</label>
<input type="text" id="tagNodeId" value="ns=1;s=shinam:p-6102.hzset.fieldvalue">
</div>
<div class="field">
<label>Tag Name (DB 저장명)</label>
<input type="text" id="tagName" value="p-6102">
</div>
<div class="field-row">
<div class="field">
<label>DB Host</label>
<input type="text" id="dbHost" value="localhost">
</div>
<div class="field">
<label>Database</label>
<input type="text" id="dbName" value="opcdb">
</div>
</div>
<div class="field-row">
<div class="field">
<label>DB User</label>
<input type="text" id="dbUser" value="postgres">
</div>
<div class="field">
<label>DB Password</label>
<input type="password" id="dbPass" value="postgres">
</div>
</div>
<div class="progress-wrap" id="dbProgressWrap" style="display:none">
<div class="progress-label"><span>저장 중...</span><span id="dbPct">0/5</span></div>
<div class="progress-bar"><div class="progress-fill" id="dbFill"></div></div>
</div>
<div style="display:flex;gap:10px;margin-top:8px">
<button class="btn btn-warn" id="btnDbTest" onclick="testDb()" style="padding:11px 16px">TEST</button>
<button class="btn btn-warn btn-full" id="btnDb" onclick="startDbWrite()" disabled>
<span>💾</span> DB 5회 저장
</button>
<button class="btn btn-primary" id="btnDbQuery" onclick="queryDb()" disabled style="padding:11px 16px">조회</button>
</div>
</div>
</div>
<!-- ⑤ CONSOLE -->
<div class="console">
<div class="console-header">
<span class="console-title">// SYSTEM LOG</span>
<button class="btn btn-danger" onclick="clearLog()" style="padding:4px 14px;font-size:11px">CLEAR</button>
</div>
<div class="console-body" id="logBody">
<div class="log-line"><span class="log-time">--:--:--</span><span class="log-msg accent">OPC UA Control Panel initialized.</span></div>
</div>
</div>
<!-- ⑥ DB TABLE -->
<div class="panel db-table">
<div class="panel-header">
<div class="panel-title"><span>📊</span> Recent DB Records <span class="panel-num">05</span></div>
<button class="btn btn-primary" onclick="clearTable()" style="padding:5px 14px;font-size:11px">CLEAR</button>
</div>
<div class="panel-body" style="padding:0">
<table class="data-grid">
<thead><tr><th>#</th><th>TIMESTAMP</th><th>TAG NAME</th><th>VALUE</th><th>STATUS</th><th>DB</th></tr></thead>
<tbody id="dbTableBody">
<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:24px">No records yet.</td></tr>
</tbody>
</table>
</div>
</div>
</main>
<script>
/* ═══════════════════════════════
CONFIG
═══════════════════════════════ */
const api = () => document.getElementById('apiBase').value.replace(/\/$/, '');
let dbRowCount = 0;
let lastPfxPath = '';
let lastPfxName = '';
/* ═══════════════════════════════
LOG
═══════════════════════════════ */
function log(msg, type = 'info') {
const body = document.getElementById('logBody');
const now = new Date();
const ts = [now.getHours(), now.getMinutes(), now.getSeconds()]
.map(n => String(n).padStart(2,'0')).join(':');
const line = document.createElement('div');
line.className = 'log-line';
line.innerHTML = `<span class="log-time">${ts}</span><span class="log-msg ${type}">${escHtml(String(msg))}</span>`;
body.appendChild(line);
body.scrollTop = body.scrollHeight;
}
function clearLog() { document.getElementById('logBody').innerHTML = ''; log('Log cleared.','warn'); }
const escHtml = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
/* ═══════════════════════════════
URI 자동 생성
═══════════════════════════════ */
function updateUri() {
const h = document.getElementById('clientHost').value || '{hostname}';
const a = document.getElementById('appName').value || '{appname}';
document.getElementById('appUri').value = `urn:${h}:${a}`;
}
updateUri();
/* ═══════════════════════════════
API 공통 Fetch
═══════════════════════════════ */
async function apiFetch(path, method = 'GET', body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(api() + path, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
return data;
}
/* ═══════════════════════════════
① CERTIFICATE
═══════════════════════════════ */
async function generateCert() {
const req = {
clientHostName: document.getElementById('clientHost').value.trim(),
applicationName: document.getElementById('appName').value.trim(),
serverHostName: document.getElementById('serverHost').value.trim(),
serverIp: document.getElementById('serverIp').value.trim(),
pfxPassword: document.getElementById('pfxPassword').value,
validDays: parseInt(document.getElementById('certDays').value) || 365
};
if (!req.clientHostName || !req.applicationName) {
log('❌ 호스트명과 Application Name을 입력하세요.', 'error'); return;
}
setBtnLoading('btnCert', true, '생성 중...');
log(`⚙ 인증서 생성 요청: ${req.applicationName} @ ${req.clientHostName}`, 'accent');
try {
const res = await apiFetch('/api/cert/generate', 'POST', req);
lastPfxPath = res.pfxPath;
lastPfxName = `${req.applicationName}.pfx`;
// 결과 표시
document.getElementById('crThumb').textContent = res.thumbprint;
document.getElementById('crSerial').textContent = res.serialNumber;
document.getElementById('crFrom').textContent = res.notBefore;
document.getElementById('crTo').textContent = res.notAfter;
document.getElementById('crPath').textContent = res.pfxPath;
document.getElementById('certResult').style.display = 'block';
document.getElementById('btnCertDownload').style.display = '';
log(`✅ 인증서 생성 완료!`, 'ok');
log(` URI: ${res.applicationUri}`, 'ok');
log(` Thumbprint: ${res.thumbprint}`, 'ok');
log(` 유효기간: ${res.notBefore} ~ ${res.notAfter}`, 'info');
log(` PFX 경로: ${res.pfxPath}`, 'info');
// 세션 패널에 pfxFile 자동 채우기
document.getElementById('pfxFile').value = lastPfxName;
document.getElementById('sessPfxPass').value = req.pfxPassword;
document.getElementById('appUri').value = res.applicationUri;
} catch (e) {
log(`❌ 인증서 생성 실패: ${e.message}`, 'error');
} finally {
setBtnLoading('btnCert', false, '⚙ 인증서 생성');
}
}
async function downloadPfx() {
if (!lastPfxName) return;
try {
const res = await fetch(`${api()}/api/cert/download/${lastPfxName}`);
if (!res.ok) throw new Error('다운로드 실패');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = lastPfxName; a.click();
URL.revokeObjectURL(url);
log(`💾 PFX 다운로드: ${lastPfxName}`, 'ok');
} catch (e) {
log(`${e.message}`, 'error');
}
}
/* ═══════════════════════════════
② SESSION
═══════════════════════════════ */
async function connectSession() {
const pfxFile = document.getElementById('pfxFile').value.trim();
const req = {
serverIp: document.getElementById('endpointIp').value.trim(),
port: parseInt(document.getElementById('endpointPort').value),
userName: document.getElementById('opcUser').value.trim(),
password: document.getElementById('opcPass').value,
securityPolicy: document.getElementById('secPolicy').value.trim(),
sessionTimeoutMs: 60000,
pfxPath: pfxFile ? `pki/own/certs/${pfxFile}` : '',
pfxPassword: document.getElementById('sessPfxPass').value,
applicationName: document.getElementById('appName').value.trim(),
applicationUri: document.getElementById('appUri').value.trim()
};
if (!req.serverIp) { log('❌ 서버 IP를 입력하세요.', 'error'); return; }
setBtnLoading('btnConnect', true, '연결 중...');
log(`🔗 연결 시도: opc.tcp://${req.serverIp}:${req.port}`, 'accent');
log(` Security: ${req.securityPolicy} | User: ${req.userName}`, 'info');
try {
const res = await apiFetch('/api/session/connect', 'POST', req);
updateConnectionState(true);
log(`✅ 연결 성공! SessionId: ${res.sessionId}`, 'ok');
log(` Security Mode: ${res.securityMode}`, 'ok');
} catch (e) {
log(`❌ 연결 실패: ${e.message}`, 'error');
setBtnLoading('btnConnect', false, '▶ 연결');
}
}
async function disconnectSession() {
log('🔌 세션 종료 중...', 'warn');
try {
await apiFetch('/api/session/disconnect', 'POST');
updateConnectionState(false);
log('✅ 세션 종료 완료.', 'ok');
} catch (e) {
log(`${e.message}`, 'error');
}
}
function updateConnectionState(connected) {
const dot = document.getElementById('connDot');
const label = document.getElementById('connLabel');
document.getElementById('m-conn').textContent = connected ? 'ONLINE' : 'OFFLINE';
document.getElementById('m-conn').className = 'metric-val ' + (connected ? 'success' : '');
dot.className = 'status-dot ' + (connected ? 'online' : 'error');
label.textContent = connected ? 'CONNECTED' : 'DISCONNECTED';
document.getElementById('btnDisconnect').disabled = !connected;
document.getElementById('btnCrawl').disabled = !connected;
document.getElementById('btnDb').disabled = !connected;
document.getElementById('btnDbQuery').disabled = !connected;
if (connected) {
document.getElementById('btnConnect').innerHTML = '<span>▶</span> 연결됨';
} else {
document.getElementById('btnConnect').disabled = false;
document.getElementById('btnConnect').innerHTML = '<span>▶</span> 연결';
}
}
// 세션 상태 폴링 (5초마다)
setInterval(async () => {
try {
const st = await apiFetch('/api/session/status');
updateConnectionState(st.isConnected);
} catch { /* API 서버 꺼진 경우 무시 */ }
}, 5000);
/* ═══════════════════════════════
③ CRAWLER
═══════════════════════════════ */
async function startCrawler() {
const req = {
startNodeId: document.getElementById('startNode').value.trim(),
maxDepth: parseInt(document.getElementById('crawlDepth').value)
};
const badge = document.getElementById('crawlBadge');
const wrap = document.getElementById('crawlProgressWrap');
const fill = document.getElementById('crawlFill');
const cnt = document.getElementById('crawlCount');
const stat = document.getElementById('crawlStatus');
setBtnLoading('btnCrawl', true, '탐색 중...');
badge.textContent = 'RUNNING';
badge.style.cssText = 'background:rgba(255,184,0,.1);color:var(--warn);border-color:rgba(255,184,0,.3)';
wrap.style.display = 'block';
stat.textContent = '서버 응답 대기 중...';
// 진행률 애니메이션 (실제 완료 후 100%)
let fakeProgress = 0;
const ticker = setInterval(() => {
if (fakeProgress < 90) { fakeProgress += 2; fill.style.width = fakeProgress + '%'; }
}, 400);
log(`🌐 Crawler 시작: ${req.startNodeId} (depth=${req.maxDepth})`, 'accent');
try {
const res = await apiFetch('/api/crawler/start', 'POST', req);
clearInterval(ticker);
fill.style.width = '100%';
cnt.textContent = `${res.totalNodes} nodes`;
badge.textContent = 'DONE';
badge.style.cssText = 'background:rgba(0,255,157,.1);color:var(--accent2);border-color:rgba(0,255,157,.3)';
document.getElementById('m-nodes').textContent = res.totalNodes;
document.getElementById('btnExportCsv').disabled = false;
log(`✅ 탐사 완료: ${res.totalNodes}개 노드`, 'ok');
log(` CSV: ${res.csvPath}`, 'info');
// 상위 20개 로그
res.tags?.slice(0, 20).forEach(t =>
log(` [Lv${t.level}][${t.nodeClass}] ${t.tagName}${t.fullNodeId}`, 'info'));
if (res.tags?.length > 20) log(` ... 그 외 ${res.tags.length - 20}`, 'muted');
} catch (e) {
clearInterval(ticker);
log(`❌ Crawler 실패: ${e.message}`, 'error');
badge.textContent = 'ERROR';
badge.style.cssText = 'background:rgba(255,60,90,.1);color:var(--danger);border-color:rgba(255,60,90,.3)';
} finally {
setBtnLoading('btnCrawl', false, '⛏ Crawler 시작');
}
}
function downloadCsv() {
const a = document.createElement('a');
a.href = `${api()}/api/crawler/csv`;
a.download = 'Honeywell_FullMap.csv';
a.click();
log('💾 CSV 다운로드 시작.', 'ok');
}
/* ═══════════════════════════════
④ DATABASE
═══════════════════════════════ */
function dbReq() {
return {
tagNodeId: document.getElementById('tagNodeId').value.trim(),
tagName: document.getElementById('tagName').value.trim(),
count: 5,
intervalMs: 2000,
dbHost: document.getElementById('dbHost').value.trim(),
dbName: document.getElementById('dbName').value.trim(),
dbUser: document.getElementById('dbUser').value.trim(),
dbPassword: document.getElementById('dbPass').value
};
}
async function testDb() {
log('🔌 DB 연결 테스트...', 'accent');
try {
const res = await apiFetch('/api/database/test', 'POST', dbReq());
log(`${res.message}`, 'ok');
} catch (e) {
log(`❌ DB 연결 실패: ${e.message}`, 'error');
}
}
async function startDbWrite() {
const req = dbReq();
if (!req.tagNodeId || !req.tagName) {
log('❌ Tag Node ID와 Tag Name을 입력하세요.', 'error'); return;
}
const wrap = document.getElementById('dbProgressWrap');
const fill = document.getElementById('dbFill');
const pct = document.getElementById('dbPct');
setBtnLoading('btnDb', true, '저장 중...');
wrap.style.display = 'block';
fill.style.width = '0%';
pct.textContent = '0/5';
log(`\n💾 DB 저장 시작 (${req.count}회) → ${req.dbUser}@${req.dbHost}/${req.dbName}`, 'accent');
log(` Tag: ${req.tagName} | Node: ${req.tagNodeId}`, 'info');
let fakeP = 0;
const ticker = setInterval(() => {
if (fakeP < 90) { fakeP += 5; fill.style.width = fakeP + '%'; }
}, 500);
try {
const res = await apiFetch('/api/database/write', 'POST', req);
clearInterval(ticker);
fill.style.width = '100%';
pct.textContent = `${res.savedCount}/${req.count}`;
const tbody = document.getElementById('dbTableBody');
if (dbRowCount === 0) tbody.innerHTML = '';
res.records?.forEach(r => {
dbRowCount++;
const tr = document.createElement('tr');
tr.className = 'row-new';
const ts = new Date(r.timestamp).toLocaleTimeString('ko-KR', { hour12: false });
const sc = r.statusCode === 'Good' ? 'status-ok' : 'status-err';
tr.innerHTML = `
<td>${dbRowCount}</td><td>${ts}</td><td>${r.tagName}</td>
<td class="val">${r.value.toFixed(4)}</td>
<td class="${sc}">${r.statusCode}</td>
<td>${r.dbSaved ? '✅' : '❌'}</td>`;
tbody.insertBefore(tr, tbody.firstChild);
document.getElementById('m-records').textContent = dbRowCount;
document.getElementById('m-lastval').textContent = r.value.toFixed(4);
log(` [${r.seq}/${req.count}] ${r.tagName} = ${r.value.toFixed(4)} (${r.statusCode}) → DB: ${r.dbSaved ? '✅' : '❌'}`, r.dbSaved ? 'ok' : 'warn');
});
log(`${res.message}`, 'ok');
} catch (e) {
clearInterval(ticker);
log(`❌ DB 저장 실패: ${e.message}`, 'error');
} finally {
setBtnLoading('btnDb', false, '💾 DB 5회 저장');
}
}
async function queryDb() {
log('📊 DB 조회 중...', 'accent');
try {
const res = await apiFetch('/api/database/query?limit=50', 'POST', dbReq());
const tbody = document.getElementById('dbTableBody');
tbody.innerHTML = '';
dbRowCount = 0;
if (!res.rows?.length) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:24px">No records found.</td></tr>';
return;
}
res.rows.forEach(r => {
dbRowCount++;
const tr = document.createElement('tr');
const ts = new Date(r.createdAt).toLocaleTimeString('ko-KR', {hour12:false});
const sc = r.statusCode === 'Good' ? 'status-ok' : 'status-err';
tr.innerHTML = `
<td>${r.id}</td><td>${ts}</td><td>${r.tagName}</td>
<td class="val">${r.tagValue.toFixed(4)}</td>
<td class="${sc}">${r.statusCode}</td><td>✅</td>`;
tbody.appendChild(tr);
});
document.getElementById('m-records').textContent = res.totalCount;
log(`✅ 조회 완료: 총 ${res.totalCount}개 중 ${res.rows.length}개 표시`, 'ok');
} catch (e) {
log(`❌ 조회 실패: ${e.message}`, 'error');
}
}
function clearTable() {
document.getElementById('dbTableBody').innerHTML =
'<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:24px">No records yet.</td></tr>';
dbRowCount = 0;
document.getElementById('m-records').textContent = '0';
document.getElementById('m-lastval').textContent = '—';
log('🗑 테이블 초기화.', 'warn');
}
/* ═══════════════════════════════
UTILITY
═══════════════════════════════ */
function setBtnLoading(id, loading, label) {
const btn = document.getElementById(id);
btn.disabled = loading;
btn.innerHTML = loading
? `<span class="spinner"></span> ${label}`
: label;
}
</script>
</body>
</html>