829 lines
36 KiB
HTML
829 lines
36 KiB
HTML
<!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 & 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
|
|
/* ═══════════════════════════════
|
|
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>
|