ExperionCrawler First Commit
This commit is contained in:
495
src/Web/wwwroot/css/style.css
Normal file
495
src/Web/wwwroot/css/style.css
Normal file
@@ -0,0 +1,495 @@
|
||||
/* ── Reset & Design Tokens ───────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
/* surfaces */
|
||||
--s0: #080b0f;
|
||||
--s1: #0d1117;
|
||||
--s2: #131920;
|
||||
--s3: #192028;
|
||||
--s4: #1f2a36;
|
||||
--bd: #1e2d3d;
|
||||
--bd2: #243546;
|
||||
|
||||
/* accents */
|
||||
--a: #f0a500; /* amber – Experion brand */
|
||||
--ag: rgba(240,165,0,.13);
|
||||
--a2: #ffbf33;
|
||||
--grn: #3ddc84;
|
||||
--red: #ff4c6a;
|
||||
--blu: #4ca6ff;
|
||||
|
||||
/* text */
|
||||
--t0: #e2ecf8;
|
||||
--t1: #7a94af;
|
||||
--t2: #3d5269;
|
||||
|
||||
/* type */
|
||||
--ff: 'Barlow', sans-serif;
|
||||
--fm: 'JetBrains Mono', monospace;
|
||||
|
||||
--r: 6px;
|
||||
--rl: 10px;
|
||||
--sw: 224px;
|
||||
--tr: .15s ease;
|
||||
}
|
||||
|
||||
html, body { height: 100%; background: var(--s0); color: var(--t1); font-family: var(--ff); font-size: 14px; line-height: 1.6; }
|
||||
|
||||
/* ── Shell ───────────────────────────────────────────────── */
|
||||
.shell { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sw);
|
||||
background: var(--s1);
|
||||
border-right: 1px solid var(--bd);
|
||||
display: flex; flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 22px 18px 18px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.brand-icon { width: 38px; height: 38px; color: var(--a); flex-shrink: 0; }
|
||||
|
||||
.brand-name {
|
||||
font-family: var(--ff); font-weight: 800; font-size: 16px;
|
||||
color: var(--a); letter-spacing: .08em; line-height: 1;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-family: var(--fm); font-size: 9px; color: var(--t2);
|
||||
letter-spacing: .18em; margin-top: 3px;
|
||||
}
|
||||
|
||||
/* nav */
|
||||
.nav { list-style: none; padding: 14px 10px; flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: var(--r);
|
||||
cursor: pointer; transition: all var(--tr);
|
||||
color: var(--t2); font-size: 13px; font-weight: 600;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item:hover { background: var(--s2); color: var(--t1); }
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--ag);
|
||||
color: var(--a);
|
||||
border: 1px solid rgba(240,165,0,.22);
|
||||
}
|
||||
|
||||
.ni {
|
||||
font-family: var(--fm); font-size: 10px;
|
||||
color: inherit; opacity: .6; letter-spacing: .05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nl { flex: 1; }
|
||||
|
||||
.nb {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--grn); display: none;
|
||||
box-shadow: 0 0 6px var(--grn);
|
||||
}
|
||||
.nb.on { display: block; }
|
||||
|
||||
.sb-foot {
|
||||
padding: 14px 18px; border-top: 1px solid var(--bd);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-family: var(--fm); font-size: 10px; color: var(--t2);
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--t2); transition: all var(--tr);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.ok { background: var(--grn); box-shadow: 0 0 6px var(--grn); }
|
||||
.dot.err { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||
.dot.busy { background: var(--a); animation: blink 1s infinite; }
|
||||
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
|
||||
.mono { font-family: var(--fm); }
|
||||
|
||||
/* ── Content ─────────────────────────────────────────────── */
|
||||
.content {
|
||||
flex: 1; overflow-y: auto; background: var(--s1);
|
||||
padding: 32px 36px;
|
||||
}
|
||||
|
||||
.pane { display: none; }
|
||||
.pane.active { display: block; }
|
||||
|
||||
/* ── Panel header ────────────────────────────────────────── */
|
||||
.pane-hdr {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.pane-hdr h1 {
|
||||
font-family: var(--ff); font-weight: 700; font-size: 22px;
|
||||
color: var(--t0); margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.pane-hdr p { color: var(--t2); font-size: 13px; }
|
||||
|
||||
.pane-tag {
|
||||
font-family: var(--fm); font-size: 10px; letter-spacing: .15em;
|
||||
color: var(--a); border: 1px solid rgba(240,165,0,.3);
|
||||
padding: 4px 10px; border-radius: 3px; white-space: nowrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--s3);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--rl);
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card-cap {
|
||||
font-family: var(--ff); font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .12em;
|
||||
color: var(--t2); margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Grid helpers ────────────────────────────────────────── */
|
||||
.cols-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.cols-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 14px; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.cols-2, .cols-3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Forms ───────────────────────────────────────────────── */
|
||||
.fg { display: flex; flex-direction: column; gap: 5px; margin-bottom: 13px; }
|
||||
.fg:last-child { margin-bottom: 0; }
|
||||
|
||||
.fg label {
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .1em; color: var(--t2);
|
||||
}
|
||||
|
||||
.fg label em { text-transform: none; font-weight: 400; letter-spacing: 0; font-style: normal; }
|
||||
|
||||
.inp, .ta {
|
||||
background: var(--s2);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
color: var(--t0);
|
||||
font-family: var(--fm); font-size: 13px;
|
||||
padding: 9px 11px;
|
||||
outline: none; width: 100%;
|
||||
transition: border-color var(--tr), box-shadow var(--tr);
|
||||
}
|
||||
|
||||
.inp:focus, .ta:focus {
|
||||
border-color: var(--a);
|
||||
box-shadow: 0 0 0 3px var(--ag);
|
||||
}
|
||||
|
||||
.ta { resize: vertical; line-height: 1.75; }
|
||||
|
||||
.row-inp { display: flex; gap: 9px; align-items: center; }
|
||||
.flex1 { flex: 1; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────── */
|
||||
.btn-a, .btn-b {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 18px; border-radius: var(--r);
|
||||
font-family: var(--ff); font-size: 13px; font-weight: 700;
|
||||
cursor: pointer; border: none; transition: all var(--tr);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-a { background: var(--a); color: var(--s0); }
|
||||
.btn-a:hover { background: var(--a2); box-shadow: 0 0 14px var(--ag); }
|
||||
.btn-a:disabled { background: var(--bd); color: var(--t2); cursor: not-allowed; }
|
||||
|
||||
.btn-b { background: var(--s4); color: var(--t1); border: 1px solid var(--bd2); }
|
||||
.btn-b:hover { background: var(--bd2); color: var(--t0); }
|
||||
|
||||
.btn-row { display: flex; gap: 10px; margin-top: 18px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Log box ─────────────────────────────────────────────── */
|
||||
.logbox {
|
||||
background: var(--s0);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--rl);
|
||||
padding: 14px 16px;
|
||||
font-family: var(--fm); font-size: 12px; line-height: 1.9;
|
||||
max-height: 260px; overflow-y: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ll { padding: 1px 0; }
|
||||
.ok { color: var(--grn); }
|
||||
.err { color: var(--red); }
|
||||
.inf { color: var(--t1); }
|
||||
.val { color: var(--a); }
|
||||
.mut { color: var(--t2); }
|
||||
|
||||
/* ── KV box (cert status) ────────────────────────────────── */
|
||||
.kv-box {
|
||||
font-family: var(--fm); font-size: 12px; line-height: 2.1;
|
||||
}
|
||||
|
||||
.kv { display: flex; gap: 8px; }
|
||||
.kk { color: var(--t2); min-width: 100px; }
|
||||
.kv2 { color: var(--t0); }
|
||||
.kv2.ok { color: var(--grn); }
|
||||
.kv2.err { color: var(--red); }
|
||||
.placeholder { color: var(--t2); font-style: italic; font-size: 12px; }
|
||||
|
||||
/* ── Tag result ──────────────────────────────────────────── */
|
||||
.tag-box {
|
||||
margin-top: 14px;
|
||||
background: var(--s0);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
padding: 13px 15px;
|
||||
font-family: var(--fm); font-size: 13px; line-height: 2;
|
||||
}
|
||||
|
||||
/* ── Browse ──────────────────────────────────────────────── */
|
||||
.bwrap {
|
||||
background: var(--s3); border: 1px solid var(--bd);
|
||||
border-radius: var(--rl); padding: 18px 20px;
|
||||
margin-top: 18px; max-height: 340px; overflow-y: auto;
|
||||
}
|
||||
|
||||
.bnode {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 10px; border-radius: var(--r);
|
||||
cursor: pointer; font-family: var(--fm); font-size: 12px;
|
||||
color: var(--t1); transition: background var(--tr);
|
||||
}
|
||||
|
||||
.bnode:hover { background: var(--s4); color: var(--t0); }
|
||||
|
||||
.bclass {
|
||||
font-size: 9px; padding: 2px 6px; border-radius: 3px;
|
||||
background: var(--s2); color: var(--t2); letter-spacing: .08em;
|
||||
}
|
||||
|
||||
.bnid { margin-left: auto; color: var(--t2); font-size: 10px; }
|
||||
|
||||
/* ── Progress ────────────────────────────────────────────── */
|
||||
.prog-wrap {
|
||||
background: var(--s3); border: 1px solid var(--bd);
|
||||
border-radius: var(--rl); padding: 18px 22px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.prog-hdr {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 10px; font-size: 12px;
|
||||
}
|
||||
|
||||
.prog-track {
|
||||
height: 5px; background: var(--s2);
|
||||
border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
|
||||
.prog-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #c07800, var(--a));
|
||||
border-radius: 3px; transition: width .4s ease;
|
||||
}
|
||||
|
||||
/* ── File list ───────────────────────────────────────────── */
|
||||
.flist {
|
||||
max-height: 180px; overflow-y: auto;
|
||||
background: var(--s2); border: 1px solid var(--bd);
|
||||
border-radius: var(--r); padding: 6px;
|
||||
}
|
||||
|
||||
.fitem {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 10px; border-radius: var(--r);
|
||||
cursor: pointer; font-family: var(--fm); font-size: 12px;
|
||||
color: var(--t1); transition: background var(--tr);
|
||||
}
|
||||
|
||||
.fitem:hover { background: var(--s3); color: var(--t0); }
|
||||
|
||||
.fitem.sel {
|
||||
background: var(--ag); color: var(--a);
|
||||
border: 1px solid rgba(240,165,0,.25);
|
||||
}
|
||||
|
||||
/* ── Stats ───────────────────────────────────────────────── */
|
||||
.stats { display: flex; gap: 16px; margin-bottom: 12px; }
|
||||
|
||||
.stat {
|
||||
background: var(--s2); border: 1px solid var(--bd);
|
||||
border-radius: var(--r); padding: 12px 16px;
|
||||
flex: 1; text-align: center;
|
||||
}
|
||||
|
||||
.sv {
|
||||
font-family: var(--fm); font-size: 24px; font-weight: 700;
|
||||
color: var(--a);
|
||||
}
|
||||
|
||||
.sk { font-size: 11px; color: var(--t2); margin-top: 3px; }
|
||||
|
||||
/* ── Table ───────────────────────────────────────────────── */
|
||||
.tbl-wrap {
|
||||
margin-top: 20px; overflow-x: auto;
|
||||
border: 1px solid var(--bd); border-radius: var(--rl);
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-family: var(--fm); font-size: 12px; }
|
||||
|
||||
thead { background: var(--s2); }
|
||||
th {
|
||||
padding: 9px 13px; text-align: left;
|
||||
font-size: 9px; text-transform: uppercase; letter-spacing: .12em;
|
||||
color: var(--t2); border-bottom: 1px solid var(--bd);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td { padding: 8px 13px; border-bottom: 1px solid var(--bd); color: var(--t1); }
|
||||
tr:hover td { background: var(--s2); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.bg { color: var(--grn); }
|
||||
.br { color: var(--red); }
|
||||
|
||||
/* ── Section divider ─────────────────────────────────────── */
|
||||
.section-div {
|
||||
border-top: 1px solid var(--bd);
|
||||
margin: 10px 0 28px;
|
||||
}
|
||||
|
||||
.sub-hdr {
|
||||
font-family: var(--ff); font-weight: 700; font-size: 16px;
|
||||
color: var(--t0); margin-bottom: 3px;
|
||||
}
|
||||
|
||||
/* ── Nodemap row ─────────────────────────────────────────── */
|
||||
.nm-row {
|
||||
display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nm-hint {
|
||||
font-family: var(--fm); font-size: 11px; color: var(--t2);
|
||||
margin-top: 14px; line-height: 1.8;
|
||||
}
|
||||
|
||||
.nm-hint code {
|
||||
background: var(--s2); padding: 1px 5px;
|
||||
border-radius: 3px; color: var(--a);
|
||||
}
|
||||
|
||||
/* ── Import mode toggle ──────────────────────────────────── */
|
||||
.mode-group {
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
|
||||
.mode-opt {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 8px 14px; border-radius: var(--r);
|
||||
background: var(--s2); border: 1px solid var(--bd);
|
||||
cursor: pointer; font-size: 13px; font-weight: 600;
|
||||
color: var(--t1); transition: all var(--tr);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mode-opt input[type="radio"] { display: none; }
|
||||
|
||||
.mode-opt:has(input:checked) {
|
||||
background: var(--ag); color: var(--a);
|
||||
border-color: rgba(240,165,0,.4);
|
||||
}
|
||||
|
||||
.mode-opt-danger:has(input:checked) {
|
||||
background: rgba(255,76,106,.12); color: var(--red);
|
||||
border-color: rgba(255,76,106,.4);
|
||||
}
|
||||
|
||||
.mode-opt:hover { border-color: var(--bd2); color: var(--t0); }
|
||||
|
||||
/* ── Nodemap Dashboard ───────────────────────────────────── */
|
||||
.nm-stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.nm-stat-row { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.nm-result-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 10px; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
|
||||
.nm-result-info {
|
||||
font-family: var(--fm); font-size: 12px; color: var(--t2);
|
||||
}
|
||||
|
||||
.pg { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.pg-info {
|
||||
font-family: var(--fm); font-size: 12px; color: var(--t2);
|
||||
min-width: 100px; text-align: center;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px; font-size: 12px;
|
||||
}
|
||||
|
||||
.nm-cls {
|
||||
display: inline-block;
|
||||
font-family: var(--fm); font-size: 10px; letter-spacing: .06em;
|
||||
padding: 2px 7px; border-radius: 3px;
|
||||
background: var(--s2);
|
||||
}
|
||||
|
||||
.nm-cls-object { color: var(--a); border: 1px solid rgba(240,165,0,.3); }
|
||||
.nm-cls-variable { color: var(--grn); border: 1px solid rgba(61,220,132,.3); }
|
||||
|
||||
.nm-dtype {
|
||||
font-family: var(--fm); font-size: 10px; color: var(--blu);
|
||||
background: rgba(76,166,255,.08);
|
||||
padding: 2px 6px; border-radius: 3px;
|
||||
border: 1px solid rgba(76,166,255,.2);
|
||||
}
|
||||
|
||||
/* ── 이름 OR 선택 드롭다운 ────────────────────────────────── */
|
||||
.nm-name-row { margin-top: 10px; }
|
||||
|
||||
.nm-name-selects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.nm-name-sel { width: 100%; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.nm-name-selects { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* ── Utility ─────────────────────────────────────────────── */
|
||||
.hidden { display: none !important; }
|
||||
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: var(--s1); }
|
||||
::-webkit-scrollbar-thumb { background: var(--bd2); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--t2); }
|
||||
385
src/Web/wwwroot/index.html
Normal file
385
src/Web/wwwroot/index.html
Normal file
@@ -0,0 +1,385 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<title>ExperionCrawler</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
|
||||
<!-- ── Sidebar ───────────────────────────────────────────── -->
|
||||
<nav class="sidebar">
|
||||
<div class="brand">
|
||||
<svg class="brand-icon" viewBox="0 0 40 40" fill="none">
|
||||
<rect x="4" y="4" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="22" y="4" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="4" y="22" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="22" y="22" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<circle cx="11" cy="11" r="3" fill="currentColor" opacity=".6"/>
|
||||
<circle cx="29" cy="11" r="3" fill="currentColor" opacity=".6"/>
|
||||
<circle cx="11" cy="29" r="3" fill="currentColor" opacity=".6"/>
|
||||
<circle cx="29" cy="29" r="3" fill="currentColor" opacity="1"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="brand-name">EXPERION</div>
|
||||
<div class="brand-sub">CRAWLER v1.0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav">
|
||||
<li class="nav-item active" data-tab="cert">
|
||||
<span class="ni">01</span>
|
||||
<span class="nl">인증서 관리</span>
|
||||
<span class="nb" id="cert-dot"></span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="conn">
|
||||
<span class="ni">02</span>
|
||||
<span class="nl">서버 접속 테스트</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="crawl">
|
||||
<span class="ni">03</span>
|
||||
<span class="nl">데이터 크롤링</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="db">
|
||||
<span class="ni">04</span>
|
||||
<span class="nl">DB 저장</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="nm-dash">
|
||||
<span class="ni">05</span>
|
||||
<span class="nl">노드맵 대시보드</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sb-foot">
|
||||
<span class="dot" id="g-dot"></span>
|
||||
<span id="g-txt" class="mono">READY</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── Main ──────────────────────────────────────────────── -->
|
||||
<main class="content">
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
01 인증서 관리
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane active" id="pane-cert">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>인증서 관리</h1>
|
||||
<p>OPC UA 클라이언트 인증서를 생성합니다. 기존 파일이 있으면 재사용됩니다.</p>
|
||||
</div>
|
||||
<div class="pane-tag">PKI / X.509</div>
|
||||
</header>
|
||||
|
||||
<div class="cols-2">
|
||||
<div class="card">
|
||||
<div class="card-cap">인증서 생성</div>
|
||||
<div class="fg">
|
||||
<label>Client Hostname</label>
|
||||
<input id="c-host" class="inp" value="dbsvr"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Subject Alt Names <em>(쉼표 구분)</em></label>
|
||||
<input id="c-san" class="inp" value="localhost,192.168.0.50"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>PFX Password <em>(없으면 비워 두세요)</em></label>
|
||||
<input id="c-pw" class="inp" type="password" placeholder=""/>
|
||||
</div>
|
||||
<button class="btn-a" onclick="certCreate()">🔑 인증서 생성</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-cap">현재 인증서 상태</div>
|
||||
<button class="btn-b" onclick="certStatus()" style="margin-bottom:14px">상태 확인</button>
|
||||
<div id="cert-disp" class="kv-box">
|
||||
<span class="placeholder">상태 확인 버튼을 눌러 주세요</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cert-log" class="logbox hidden"></div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
02 서버 접속 테스트
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane" id="pane-conn">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>서버 접속 테스트</h1>
|
||||
<p>Experion OPC UA 서버에 연결하고 노드 값을 읽습니다.</p>
|
||||
</div>
|
||||
<div class="pane-tag">OPC UA / TCP</div>
|
||||
</header>
|
||||
|
||||
<div class="card" style="margin-bottom:18px">
|
||||
<div class="card-cap">서버 설정</div>
|
||||
<div class="cols-3">
|
||||
<div class="fg"><label>Server IP</label>
|
||||
<input id="x-server" class="inp" value="192.168.0.20"/></div>
|
||||
<div class="fg"><label>Port</label>
|
||||
<input id="x-port" class="inp" type="number" value="4840"/></div>
|
||||
<div class="fg"><label>Client Hostname</label>
|
||||
<input id="x-client" class="inp" value="dbsvr"/></div>
|
||||
<div class="fg"><label>Username</label>
|
||||
<input id="x-user" class="inp" value="mngr"/></div>
|
||||
<div class="fg"><label>Password</label>
|
||||
<input id="x-pass" class="inp" type="password" value="mngr"/></div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-a" onclick="connTest()">🔌 접속 테스트</button>
|
||||
<button class="btn-b" onclick="connBrowse()">🌲 노드 탐색</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-cap">단일 태그 읽기</div>
|
||||
<div class="row-inp">
|
||||
<input id="x-node" class="inp flex1"
|
||||
value="ns=1;s=shinam:p-6102.hzset.fieldvalue"
|
||||
placeholder="ns=1;s=..."/>
|
||||
<button class="btn-b" onclick="connRead()">읽기</button>
|
||||
</div>
|
||||
<div id="tag-box" class="tag-box hidden"></div>
|
||||
</div>
|
||||
|
||||
<div id="conn-log" class="logbox hidden"></div>
|
||||
<div id="browse-wrap" class="bwrap hidden"></div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
03 데이터 크롤링
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane" id="pane-crawl">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>데이터 크롤링</h1>
|
||||
<p>지정한 노드 값을 주기적으로 수집하여 CSV 파일로 저장합니다.</p>
|
||||
</div>
|
||||
<div class="pane-tag">CRAWL / CSV</div>
|
||||
</header>
|
||||
|
||||
<div class="cols-2">
|
||||
<div class="card">
|
||||
<div class="card-cap">서버 설정</div>
|
||||
<div class="fg"><label>Server IP</label>
|
||||
<input id="w-server" class="inp" value="192.168.0.20"/></div>
|
||||
<div class="fg"><label>Port</label>
|
||||
<input id="w-port" class="inp" type="number" value="4840"/></div>
|
||||
<div class="fg"><label>Client Hostname</label>
|
||||
<input id="w-client" class="inp" value="dbsvr"/></div>
|
||||
<div class="fg"><label>Username</label>
|
||||
<input id="w-user" class="inp" value="mngr"/></div>
|
||||
<div class="fg"><label>Password</label>
|
||||
<input id="w-pass" class="inp" type="password" value="mngr"/></div>
|
||||
<div class="fg"><label>수집 간격 (초)</label>
|
||||
<input id="w-interval" class="inp" type="number" value="1" min="1"/></div>
|
||||
<div class="fg"><label>수집 시간 (초)</label>
|
||||
<input id="w-duration" class="inp" type="number" value="30" min="1"/></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-cap">수집 노드 목록 <em>(한 줄에 하나씩)</em></div>
|
||||
<textarea id="w-nodes" class="ta" rows="9"
|
||||
placeholder="ns=1;s=...">ns=1;s=shinam:p-6102.hzset.fieldvalue</textarea>
|
||||
<button class="btn-a" id="crawl-btn" onclick="crawlStart()"
|
||||
style="margin-top:14px">📡 크롤링 시작</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="crawl-prog" class="prog-wrap hidden">
|
||||
<div class="prog-hdr">
|
||||
<span id="crawl-ptxt">수집 중...</span>
|
||||
<span id="crawl-cnt" class="mono">0</span>
|
||||
</div>
|
||||
<div class="prog-track"><div id="crawl-bar" class="prog-fill" style="width:0%"></div></div>
|
||||
</div>
|
||||
|
||||
<div id="crawl-log" class="logbox hidden"></div>
|
||||
|
||||
<!-- ── 노드맵 수집 ──────────────────────────────────────── -->
|
||||
<div class="section-div"></div>
|
||||
|
||||
<header class="pane-hdr" style="margin-bottom:16px">
|
||||
<div>
|
||||
<h2 class="sub-hdr">노드맵 수집</h2>
|
||||
<p>서버 전체 노드를 재귀 탐색하여 AssetLoader 용 CSV 파일로 저장합니다.</p>
|
||||
</div>
|
||||
<div class="pane-tag">NODE MAP / CSV</div>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-cap">전체 노드 탐색 설정</div>
|
||||
<div class="nm-row">
|
||||
<div class="fg" style="margin-bottom:0;width:200px">
|
||||
<label>최대 탐색 깊이</label>
|
||||
<input id="nm-depth" class="inp" type="number" value="10" min="1" max="20"/>
|
||||
</div>
|
||||
<button class="btn-a" id="nm-btn" onclick="nodeMapCrawl()">🗺 전체 노드맵 수집</button>
|
||||
</div>
|
||||
<p class="nm-hint">
|
||||
서버 설정은 위 크롤링 설정을 그대로 사용합니다 ·
|
||||
노드 수에 따라 수 분이 소요될 수 있습니다 ·
|
||||
결과는 <code>data/csv/{서버명}_*.csv</code> 에 저장됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="nm-prog" class="prog-wrap hidden">
|
||||
<div class="prog-hdr">
|
||||
<span id="nm-ptxt">탐색 중...</span>
|
||||
<span id="nm-cnt" class="mono"></span>
|
||||
</div>
|
||||
<div class="prog-track"><div id="nm-bar" class="prog-fill" style="width:0%"></div></div>
|
||||
</div>
|
||||
|
||||
<div id="nm-log" class="logbox hidden"></div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
04 DB 저장
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane" id="pane-db">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>DB 저장</h1>
|
||||
<p>수집된 CSV 파일을 PostgreSQL DB에 저장하고 레코드를 조회합니다.</p>
|
||||
</div>
|
||||
<div class="pane-tag">PostgreSQL / EF</div>
|
||||
</header>
|
||||
|
||||
<div class="cols-2">
|
||||
<div class="card">
|
||||
<div class="card-cap">CSV → DB 임포트</div>
|
||||
<button class="btn-b" onclick="dbLoadFiles()" style="margin-bottom:10px">
|
||||
🔄 파일 목록 갱신
|
||||
</button>
|
||||
<div id="file-list" class="flist">
|
||||
<span class="placeholder">갱신 버튼을 눌러 주세요</span>
|
||||
</div>
|
||||
<div class="fg" style="margin-top:12px">
|
||||
<label>선택된 파일</label>
|
||||
<input id="sel-csv" class="inp" readonly placeholder="위 목록에서 파일을 선택하세요"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>저장 방식</label>
|
||||
<div class="mode-group">
|
||||
<label class="mode-opt">
|
||||
<input type="radio" name="import-mode" value="append" checked/>
|
||||
<span>추가 저장</span>
|
||||
</label>
|
||||
<label class="mode-opt mode-opt-danger">
|
||||
<input type="radio" name="import-mode" value="truncate"/>
|
||||
<span>초기화 후 저장</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-a" onclick="dbImport()">💾 DB에 저장</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-cap">DB 레코드 조회</div>
|
||||
<div class="row-inp" style="margin-bottom:12px">
|
||||
<input id="db-limit" class="inp" type="number" value="100"
|
||||
min="1" max="10000" style="width:110px"/>
|
||||
<button class="btn-b" onclick="dbQuery()">조회</button>
|
||||
</div>
|
||||
<div id="db-stats" class="stats hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="db-log" class="logbox hidden"></div>
|
||||
<div id="db-table" class="tbl-wrap hidden"></div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
05 노드맵 대시보드
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane" id="pane-nm-dash">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>노드맵 대시보드</h1>
|
||||
<p>node_map_master 테이블을 조회합니다.</p>
|
||||
</div>
|
||||
<div class="pane-tag">NODE MAP / MASTER</div>
|
||||
</header>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div id="nm-stat-row" class="nm-stat-row hidden"></div>
|
||||
|
||||
<!-- 필터 카드 -->
|
||||
<div class="card">
|
||||
<div class="card-cap">필터 조건</div>
|
||||
<div class="cols-3">
|
||||
<div class="fg">
|
||||
<label>Level 최소</label>
|
||||
<input id="nf-lv-min" class="inp" type="number" min="0" placeholder="0"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Level 최대</label>
|
||||
<input id="nf-lv-max" class="inp" type="number" min="0" placeholder=""/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>클래스</label>
|
||||
<select id="nf-class" class="inp">
|
||||
<option value="">전체</option>
|
||||
<option value="Object">Object</option>
|
||||
<option value="Variable">Variable</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Node ID 검색</label>
|
||||
<input id="nf-nid" class="inp" placeholder="포함 검색"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>데이터 타입</label>
|
||||
<select id="nf-dtype" class="inp">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 이름 OR 조건 선택 (최대 4개) -->
|
||||
<div class="fg nm-name-row">
|
||||
<label>이름 선택 <em>(OR 조건, 최대 4개)</em></label>
|
||||
<div class="nm-name-selects">
|
||||
<select id="nf-name-1" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="nf-name-2" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="nf-name-3" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="nf-name-4" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row" style="align-items:center">
|
||||
<button class="btn-a" onclick="nmQuery(0)">🔍 조회</button>
|
||||
<button class="btn-b" onclick="nmReset()">초기화</button>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||
<label style="font-size:11px;color:var(--t2);white-space:nowrap">페이지당</label>
|
||||
<input id="nf-limit" class="inp" type="number" value="100" min="10" max="500" style="width:80px"/>
|
||||
<label style="font-size:11px;color:var(--t2)">건</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 통계 + 페이지네이션 -->
|
||||
<div id="nm-result-bar" class="nm-result-bar hidden">
|
||||
<span id="nm-result-info" class="nm-result-info"></span>
|
||||
<div class="pg">
|
||||
<button class="btn-b btn-sm" id="nm-pg-prev" onclick="nmPrev()">← 이전</button>
|
||||
<span id="nm-pg-info" class="pg-info"></span>
|
||||
<button class="btn-b btn-sm" id="nm-pg-next" onclick="nmNext()">다음 →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div id="nm-table" class="tbl-wrap hidden"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
550
src/Web/wwwroot/js/app.js
Normal file
550
src/Web/wwwroot/js/app.js
Normal file
@@ -0,0 +1,550 @@
|
||||
/* ── Tab navigation ────────────────────────────────────────── */
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const tab = item.dataset.tab;
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
document.getElementById(`pane-${tab}`).classList.add('active');
|
||||
if (tab === 'nm-dash') nmLoad();
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────── */
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g,'&').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 => `<div class="ll ${l.c}">${esc(l.t)}</div>`).join('');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opt = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body) opt.body = JSON.stringify(body);
|
||||
const res = await fetch(path, opt);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
01 인증서 관리
|
||||
───────────────────────────────────────────────────────────── */
|
||||
async function certCreate() {
|
||||
const clientHostName = document.getElementById('c-host').value.trim();
|
||||
const subjectAltNames = document.getElementById('c-san').value
|
||||
.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const pfxPassword = document.getElementById('c-pw').value;
|
||||
|
||||
setGlobal('busy', '인증서 생성 중');
|
||||
try {
|
||||
const d = await api('POST', '/api/certificate/create', {
|
||||
clientHostName, subjectAltNames, pfxPassword
|
||||
});
|
||||
log('cert-log', [
|
||||
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message },
|
||||
...(d.thumbPrint ? [{ c: 'inf', t: ' Thumbprint : ' + d.thumbPrint }] : [])
|
||||
]);
|
||||
setGlobal(d.success ? 'ok' : 'err', d.success ? '인증서 완료' : '오류');
|
||||
if (d.success) {
|
||||
document.getElementById('cert-dot').classList.add('on');
|
||||
await certStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
log('cert-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
setGlobal('err', '오류');
|
||||
}
|
||||
}
|
||||
|
||||
async function certStatus() {
|
||||
const clientHostName = document.getElementById('c-host').value.trim() || 'dbsvr';
|
||||
try {
|
||||
const d = await api('GET', `/api/certificate/status?clientHostName=${encodeURIComponent(clientHostName)}`);
|
||||
const box = document.getElementById('cert-disp');
|
||||
if (d.exists) {
|
||||
box.innerHTML = `
|
||||
<div class="kv"><span class="kk">상태</span><span class="kv2 ok">✅ 인증서 있음</span></div>
|
||||
<div class="kv"><span class="kk">Subject</span><span class="kv2">${esc(d.subjectName)}</span></div>
|
||||
<div class="kv"><span class="kk">만료일</span><span class="kv2">${d.notAfter ? new Date(d.notAfter).toLocaleDateString('ko-KR') : '-'}</span></div>
|
||||
<div class="kv"><span class="kk">Thumbprint</span><span class="kv2">${d.thumbPrint ? d.thumbPrint.slice(0,20)+'…' : '-'}</span></div>
|
||||
<div class="kv"><span class="kk">파일 경로</span><span class="kv2">${esc(d.filePath)}</span></div>
|
||||
`;
|
||||
document.getElementById('cert-dot').classList.add('on');
|
||||
setGlobal('ok', '인증서 확인됨');
|
||||
} else {
|
||||
box.innerHTML = `
|
||||
<div class="kv"><span class="kk">상태</span><span class="kv2 err">❌ 인증서 없음</span></div>
|
||||
<div class="kv"><span class="kk">경로</span><span class="kv2">${esc(d.filePath)}</span></div>
|
||||
`;
|
||||
setGlobal('', 'READY');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
02 서버 접속 테스트
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function getServerCfg(p) {
|
||||
return {
|
||||
serverHostName: document.getElementById(`${p}-server`).value.trim(),
|
||||
port: parseInt(document.getElementById(`${p}-port`).value) || 4840,
|
||||
clientHostName: document.getElementById(`${p}-client`).value.trim(),
|
||||
userName: document.getElementById(`${p}-user`).value.trim(),
|
||||
password: document.getElementById(`${p}-pass`).value
|
||||
};
|
||||
}
|
||||
|
||||
async function connTest() {
|
||||
setGlobal('busy', '접속 중');
|
||||
try {
|
||||
const d = await api('POST', '/api/connection/test', getServerCfg('x'));
|
||||
log('conn-log', [
|
||||
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message },
|
||||
...(d.sessionId ? [{ c: 'inf', t: ' SessionID : ' + d.sessionId }] : []),
|
||||
...(d.policyUri ? [{ c: 'inf', t: ' Policy : ' + d.policyUri.split('/').pop() }] : [])
|
||||
]);
|
||||
setGlobal(d.success ? 'ok' : 'err', d.success ? '연결 성공' : '연결 실패');
|
||||
} catch (e) {
|
||||
log('conn-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
setGlobal('err', '오류');
|
||||
}
|
||||
}
|
||||
|
||||
async function connRead() {
|
||||
const nodeId = document.getElementById('x-node').value.trim();
|
||||
if (!nodeId) return;
|
||||
setGlobal('busy', '태그 읽기');
|
||||
try {
|
||||
const d = await api('POST', '/api/connection/read', {
|
||||
serverConfig: getServerCfg('x'), nodeId
|
||||
});
|
||||
const box = document.getElementById('tag-box');
|
||||
box.classList.remove('hidden');
|
||||
if (d.success) {
|
||||
box.innerHTML = `
|
||||
<div class="ll ok">✅ 읽기 성공</div>
|
||||
<div class="ll"><span class="mut">NodeID : </span><span style="color:var(--t0)">${esc(d.nodeId)}</span></div>
|
||||
<div class="ll"><span class="mut">Value : </span><span class="val">${esc(d.value ?? 'null')}</span></div>
|
||||
<div class="ll"><span class="mut">Status : </span><span class="ok">${esc(d.statusCode)}</span></div>
|
||||
<div class="ll"><span class="mut">Time : </span><span class="inf">${d.timestamp ? new Date(d.timestamp).toLocaleString('ko-KR') : '-'}</span></div>
|
||||
`;
|
||||
setGlobal('ok', '읽기 완료');
|
||||
} else {
|
||||
box.innerHTML = `<div class="ll err">❌ ${esc(d.error || '읽기 실패')}</div>`;
|
||||
setGlobal('err', '읽기 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
setGlobal('err', '오류');
|
||||
}
|
||||
}
|
||||
|
||||
async function connBrowse() {
|
||||
setGlobal('busy', '노드 탐색');
|
||||
try {
|
||||
const d = await api('POST', '/api/connection/browse', {
|
||||
serverConfig: getServerCfg('x'), startNodeId: null
|
||||
});
|
||||
const wrap = document.getElementById('browse-wrap');
|
||||
wrap.classList.remove('hidden');
|
||||
if (d.success && d.nodes?.length) {
|
||||
wrap.innerHTML =
|
||||
`<div style="font-family:var(--fm);font-size:11px;color:var(--t2);margin-bottom:12px">탐색 결과: ${d.nodes.length}개 노드 (클릭하면 태그 입력란에 복사)</div>` +
|
||||
d.nodes.map(n => `
|
||||
<div class="bnode" onclick="document.getElementById('x-node').value='${esc(n.nodeId)}'">
|
||||
<span>${n.nodeClass === 'Object' ? '📂' : '📌'}</span>
|
||||
<span style="color:var(--t0)">${esc(n.displayName)}</span>
|
||||
<span class="bclass">${esc(n.nodeClass)}</span>
|
||||
<span class="bnid">${esc(n.nodeId)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
setGlobal('ok', '탐색 완료');
|
||||
} else {
|
||||
wrap.innerHTML = `<div style="color:var(--t2);font-size:12px">${esc(d.error || '노드 없음')}</div>`;
|
||||
setGlobal('err', '탐색 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
setGlobal('err', '오류');
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
03 데이터 크롤링
|
||||
───────────────────────────────────────────────────────────── */
|
||||
async function crawlStart() {
|
||||
const nodeIds = document.getElementById('w-nodes').value
|
||||
.trim().split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (!nodeIds.length) { alert('노드 ID를 입력하세요.'); return; }
|
||||
|
||||
const cfg = {
|
||||
serverHostName: document.getElementById('w-server').value.trim(),
|
||||
port: parseInt(document.getElementById('w-port').value) || 4840,
|
||||
clientHostName: document.getElementById('w-client').value.trim(),
|
||||
userName: document.getElementById('w-user').value.trim(),
|
||||
password: document.getElementById('w-pass').value
|
||||
};
|
||||
const intervalSeconds = parseInt(document.getElementById('w-interval').value) || 1;
|
||||
const durationSeconds = parseInt(document.getElementById('w-duration').value) || 30;
|
||||
|
||||
const btn = document.getElementById('crawl-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 수집 중...';
|
||||
|
||||
const prog = document.getElementById('crawl-prog');
|
||||
prog.classList.remove('hidden');
|
||||
document.getElementById('crawl-bar').style.width = '0%';
|
||||
document.getElementById('crawl-ptxt').textContent = '서버 연결 중...';
|
||||
document.getElementById('crawl-cnt').textContent = '0 건';
|
||||
setGlobal('busy', '크롤링 중');
|
||||
|
||||
// 백엔드는 동기식이므로 프런트에서 타이머로 UI 진행 표시
|
||||
let fake = 0;
|
||||
const ticker = setInterval(() => {
|
||||
fake = Math.min(fake + (100 / durationSeconds) * .5, 94);
|
||||
document.getElementById('crawl-bar').style.width = fake + '%';
|
||||
document.getElementById('crawl-ptxt').textContent = `수집 중... ${Math.round(fake)}%`;
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
const d = await api('POST', '/api/crawl/start', {
|
||||
serverConfig: cfg, nodeIds, intervalSeconds, durationSeconds
|
||||
});
|
||||
clearInterval(ticker);
|
||||
document.getElementById('crawl-bar').style.width = '100%';
|
||||
document.getElementById('crawl-ptxt').textContent = '수집 완료';
|
||||
document.getElementById('crawl-cnt').textContent = d.totalRecords + ' 건';
|
||||
|
||||
log('crawl-log', [
|
||||
{ c: 'ok', t: `✅ 크롤링 완료 — ${d.totalRecords}개 레코드` },
|
||||
{ c: 'inf', t: ` Session : ${d.sessionId}` },
|
||||
{ c: 'inf', t: ` CSV : ${d.csvPath}` },
|
||||
{ c: 'mut', t: '--- 미리보기 ---' },
|
||||
...(d.preview || []).map(r => ({
|
||||
c: 'val',
|
||||
t: ` [${new Date(r.collectedAt).toLocaleTimeString('ko-KR')}] ${r.nodeId} = ${r.value} (${r.statusCode})`
|
||||
}))
|
||||
]);
|
||||
setGlobal('ok', '크롤링 완료');
|
||||
} catch (e) {
|
||||
clearInterval(ticker);
|
||||
log('crawl-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
setGlobal('err', '오류');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '📡 크롤링 시작';
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
03-B 노드맵 수집
|
||||
───────────────────────────────────────────────────────────── */
|
||||
async function nodeMapCrawl() {
|
||||
const maxDepth = parseInt(document.getElementById('nm-depth').value) || 10;
|
||||
const cfg = {
|
||||
serverHostName: document.getElementById('w-server').value.trim(),
|
||||
port: parseInt(document.getElementById('w-port').value) || 4840,
|
||||
clientHostName: document.getElementById('w-client').value.trim(),
|
||||
userName: document.getElementById('w-user').value.trim(),
|
||||
password: document.getElementById('w-pass').value
|
||||
};
|
||||
|
||||
const btn = document.getElementById('nm-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 탐색 중...';
|
||||
|
||||
const prog = document.getElementById('nm-prog');
|
||||
prog.classList.remove('hidden');
|
||||
document.getElementById('nm-bar').style.width = '0%';
|
||||
document.getElementById('nm-ptxt').textContent = '서버 연결 중...';
|
||||
document.getElementById('nm-cnt').textContent = '';
|
||||
setGlobal('busy', '노드맵 수집 중');
|
||||
|
||||
// 완료 시점을 알 수 없으므로 완만하게 90%까지 증가
|
||||
let fake = 0;
|
||||
const ticker = setInterval(() => {
|
||||
if (fake < 90) {
|
||||
fake = Math.min(fake + 0.25, 90);
|
||||
document.getElementById('nm-bar').style.width = fake + '%';
|
||||
document.getElementById('nm-ptxt').textContent = `노드 탐색 중... ${Math.round(fake)}%`;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
const d = await api('POST', '/api/crawl/nodemap', { serverConfig: cfg, maxDepth });
|
||||
clearInterval(ticker);
|
||||
document.getElementById('nm-bar').style.width = '100%';
|
||||
document.getElementById('nm-ptxt').textContent = d.success ? '탐색 완료' : '탐색 실패';
|
||||
document.getElementById('nm-cnt').textContent = d.success ? d.totalCount.toLocaleString() + '개 노드' : '';
|
||||
|
||||
log('nm-log', d.success ? [
|
||||
{ c: 'ok', t: `✅ 노드맵 수집 완료 — ${d.totalCount.toLocaleString()}개 노드` },
|
||||
{ c: 'inf', t: ` 저장 경로 : ${d.csvPath}` },
|
||||
{ c: 'mut', t: ` 04 DB 저장 탭에서 raw_node_map 테이블에 적재할 수 있습니다.` }
|
||||
] : [
|
||||
{ c: 'err', t: `❌ ${d.error || '탐색 실패'}` }
|
||||
]);
|
||||
setGlobal(d.success ? 'ok' : 'err', d.success ? `노드 ${d.totalCount}개` : '실패');
|
||||
} catch (e) {
|
||||
clearInterval(ticker);
|
||||
log('nm-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
setGlobal('err', '오류');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🗺 전체 노드맵 수집';
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
04 DB 저장
|
||||
───────────────────────────────────────────────────────────── */
|
||||
async function dbLoadFiles() {
|
||||
try {
|
||||
const d = await api('GET', '/api/database/files');
|
||||
const list = document.getElementById('file-list');
|
||||
if (d.files?.length) {
|
||||
list.innerHTML = d.files.map(f => `
|
||||
<div class="fitem" onclick="selectFile('${esc(f)}',this)">
|
||||
<span>📄</span><span>${esc(f)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
list.innerHTML = '<span class="placeholder">CSV 파일이 없습니다</span>';
|
||||
}
|
||||
} catch (e) { alert('목록 로드 실패: ' + e.message); }
|
||||
}
|
||||
|
||||
function selectFile(name, el) {
|
||||
document.querySelectorAll('.fitem').forEach(i => i.classList.remove('sel'));
|
||||
el.classList.add('sel');
|
||||
document.getElementById('sel-csv').value = name;
|
||||
}
|
||||
|
||||
async function dbImport() {
|
||||
const fileName = document.getElementById('sel-csv').value.trim();
|
||||
if (!fileName) { alert('파일을 선택하세요.'); return; }
|
||||
// experion_ 으로 시작하면 일반 크롤 CSV, 그 외는 노드맵 CSV (서버명_timestamp.csv)
|
||||
const serverHostName = fileName.startsWith('experion_') ? '' : fileName.split('_')[0];
|
||||
const truncate = document.querySelector('input[name="import-mode"]:checked').value === 'truncate';
|
||||
if (truncate && !confirm('기존 데이터를 모두 삭제하고 새로 저장합니다.\n계속하시겠습니까?')) return;
|
||||
setGlobal('busy', 'DB 저장 중');
|
||||
try {
|
||||
const d = await api('POST', '/api/database/import', { fileName, serverHostName, truncate });
|
||||
const isNodeMap = !!serverHostName;
|
||||
const lines = [
|
||||
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }
|
||||
];
|
||||
if (d.success && isNodeMap) {
|
||||
lines.push({ c: 'inf', t: ` raw_node_map : ${d.count}건` });
|
||||
lines.push({ c: 'inf', t: ` node_map_master: ${d.masterCount}건` });
|
||||
} else if (d.success) {
|
||||
lines.push({ c: 'inf', t: ` 저장된 레코드 : ${d.count}건` });
|
||||
}
|
||||
log('db-log', lines);
|
||||
setGlobal(d.success ? 'ok' : 'err', d.success ? 'DB 저장 완료' : '저장 실패');
|
||||
if (d.success) await dbQuery();
|
||||
} catch (e) {
|
||||
log('db-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
setGlobal('err', '오류');
|
||||
}
|
||||
}
|
||||
|
||||
async function dbQuery() {
|
||||
const limit = parseInt(document.getElementById('db-limit').value) || 100;
|
||||
setGlobal('busy', 'DB 조회');
|
||||
try {
|
||||
const d = await api('GET', `/api/database/records?limit=${limit}`);
|
||||
|
||||
const stats = document.getElementById('db-stats');
|
||||
stats.classList.remove('hidden');
|
||||
stats.innerHTML = `
|
||||
<div class="stat"><div class="sv">${d.total.toLocaleString()}</div><div class="sk">전체 레코드</div></div>
|
||||
<div class="stat"><div class="sv">${(d.records||[]).length}</div><div class="sk">현재 조회</div></div>
|
||||
`;
|
||||
|
||||
const tbl = document.getElementById('db-table');
|
||||
tbl.classList.remove('hidden');
|
||||
if (d.records?.length) {
|
||||
tbl.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Node ID</th>
|
||||
<th>Value</th>
|
||||
<th>Status</th>
|
||||
<th>수집 시각</th>
|
||||
<th>Session</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${d.records.map(r => `
|
||||
<tr>
|
||||
<td class="mut">${r.id}</td>
|
||||
<td style="font-size:11px">${esc(r.nodeId)}</td>
|
||||
<td class="val">${esc(r.value ?? 'null')}</td>
|
||||
<td><span class="${r.statusCode === 'Good' ? 'bg' : 'br'}">${esc(r.statusCode)}</span></td>
|
||||
<td>${new Date(r.collectedAt).toLocaleString('ko-KR')}</td>
|
||||
<td class="mut" style="font-size:10px">${esc(r.sessionId ?? '-')}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
} else {
|
||||
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">저장된 레코드가 없습니다.</div>';
|
||||
}
|
||||
setGlobal('ok', `${d.total}건`);
|
||||
} catch (e) { setGlobal('err', '조회 실패'); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
05 노드맵 대시보드
|
||||
───────────────────────────────────────────────────────────── */
|
||||
let _nmOffset = 0;
|
||||
let _nmTotal = 0;
|
||||
let _nmLimit = 100;
|
||||
|
||||
async function nmLoad() {
|
||||
try {
|
||||
const [s, n] = await Promise.all([
|
||||
api('GET', '/api/nodemap/stats'),
|
||||
api('GET', '/api/nodemap/names')
|
||||
]);
|
||||
|
||||
const row = document.getElementById('nm-stat-row');
|
||||
row.classList.remove('hidden');
|
||||
row.innerHTML = `
|
||||
<div class="stat"><div class="sv">${s.total.toLocaleString()}</div><div class="sk">전체 노드</div></div>
|
||||
<div class="stat"><div class="sv" style="color:var(--a)">${s.objectCount.toLocaleString()}</div><div class="sk">Object</div></div>
|
||||
<div class="stat"><div class="sv" style="color:var(--grn)">${s.variableCount.toLocaleString()}</div><div class="sk">Variable</div></div>
|
||||
<div class="stat"><div class="sv" style="color:var(--blu)">${s.maxLevel}</div><div class="sk">최대 깊이</div></div>
|
||||
<div class="stat"><div class="sv">${(s.dataTypes||[]).length}</div><div class="sk">데이터 타입 종류</div></div>
|
||||
`;
|
||||
|
||||
// 데이터타입 드롭다운 채우기
|
||||
const sel = document.getElementById('nf-dtype');
|
||||
const curDtype = sel.value;
|
||||
sel.innerHTML = '<option value="">전체</option>' +
|
||||
(s.dataTypes||[]).map(t => `<option value="${esc(t)}"${t===curDtype?' selected':''}>${esc(t)}</option>`).join('');
|
||||
|
||||
// 이름 드롭다운 4개 채우기
|
||||
const nameOpts = '<option value="">— 선택 안 함 —</option>' +
|
||||
(n.names||[]).map(nm => `<option value="${esc(nm)}">${esc(nm)}</option>`).join('');
|
||||
['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const cur = el.value;
|
||||
el.innerHTML = nameOpts;
|
||||
if (cur) el.value = cur;
|
||||
});
|
||||
|
||||
if (s.total > 0) nmQuery(0);
|
||||
} catch (e) { console.error('nmLoad:', e); }
|
||||
}
|
||||
|
||||
async function nmQuery(offset) {
|
||||
_nmOffset = offset ?? 0;
|
||||
_nmLimit = parseInt(document.getElementById('nf-limit').value) || 100;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const lvMin = document.getElementById('nf-lv-min').value.trim();
|
||||
const lvMax = document.getElementById('nf-lv-max').value.trim();
|
||||
const cls = document.getElementById('nf-class').value;
|
||||
const nid = document.getElementById('nf-nid').value.trim();
|
||||
const dtype = document.getElementById('nf-dtype').value;
|
||||
const selNames = ['nf-name-1','nf-name-2','nf-name-3','nf-name-4']
|
||||
.map(id => document.getElementById(id).value)
|
||||
.filter(Boolean);
|
||||
|
||||
if (lvMin) params.set('minLevel', lvMin);
|
||||
if (lvMax) params.set('maxLevel', lvMax);
|
||||
if (cls) params.set('nodeClass', cls);
|
||||
selNames.forEach(nm => params.append('names', nm));
|
||||
if (nid) params.set('nodeId', nid);
|
||||
if (dtype) params.set('dataType', dtype);
|
||||
params.set('limit', _nmLimit);
|
||||
params.set('offset', _nmOffset);
|
||||
|
||||
setGlobal('busy', '조회 중');
|
||||
try {
|
||||
const d = await api('GET', `/api/nodemap/query?${params}`);
|
||||
_nmTotal = d.total;
|
||||
|
||||
// 결과 바
|
||||
const bar = document.getElementById('nm-result-bar');
|
||||
bar.classList.remove('hidden');
|
||||
const from = _nmOffset + 1;
|
||||
const to = Math.min(_nmOffset + _nmLimit, _nmTotal);
|
||||
document.getElementById('nm-result-info').textContent =
|
||||
`총 ${_nmTotal.toLocaleString()}건 중 ${from.toLocaleString()}–${to.toLocaleString()}건`;
|
||||
|
||||
const totalPages = Math.ceil(_nmTotal / _nmLimit) || 1;
|
||||
const curPage = Math.floor(_nmOffset / _nmLimit) + 1;
|
||||
document.getElementById('nm-pg-info').textContent = `${curPage} / ${totalPages} 페이지`;
|
||||
document.getElementById('nm-pg-prev').disabled = _nmOffset === 0;
|
||||
document.getElementById('nm-pg-next').disabled = to >= _nmTotal;
|
||||
|
||||
// 테이블 렌더
|
||||
const tbl = document.getElementById('nm-table');
|
||||
tbl.classList.remove('hidden');
|
||||
if (d.items?.length) {
|
||||
tbl.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>Level</th><th>Class</th>
|
||||
<th>Name</th><th>Node ID</th><th>Data Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${d.items.map(r => `
|
||||
<tr>
|
||||
<td class="mut" style="font-size:11px">${r.id}</td>
|
||||
<td class="mono" style="text-align:center">${r.level}</td>
|
||||
<td><span class="nm-cls nm-cls-${(r.class||'').toLowerCase()}">${esc(r.class)}</span></td>
|
||||
<td>${esc(r.name)}</td>
|
||||
<td class="mut" style="font-size:11px;font-family:var(--fm)">${esc(r.nodeId)}</td>
|
||||
<td><span class="nm-dtype">${esc(r.dataType)}</span></td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
} else {
|
||||
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 노드가 없습니다.</div>';
|
||||
}
|
||||
setGlobal('ok', `${_nmTotal.toLocaleString()}건`);
|
||||
} catch (e) {
|
||||
setGlobal('err', '조회 실패');
|
||||
console.error('nmQuery:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function nmPrev() {
|
||||
if (_nmOffset > 0) nmQuery(Math.max(0, _nmOffset - _nmLimit));
|
||||
}
|
||||
function nmNext() {
|
||||
if (_nmOffset + _nmLimit < _nmTotal) nmQuery(_nmOffset + _nmLimit);
|
||||
}
|
||||
function nmReset() {
|
||||
document.getElementById('nf-lv-min').value = '';
|
||||
document.getElementById('nf-lv-max').value = '';
|
||||
document.getElementById('nf-class').value = '';
|
||||
document.getElementById('nf-nid').value = '';
|
||||
document.getElementById('nf-dtype').value = '';
|
||||
document.getElementById('nf-limit').value = '100';
|
||||
['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => {
|
||||
document.getElementById(id).value = '';
|
||||
});
|
||||
nmQuery(0);
|
||||
}
|
||||
|
||||
/* ── 초기 실행 ───────────────────────────────────────────────── */
|
||||
certStatus();
|
||||
Reference in New Issue
Block a user