feat: 웹UI Phase 4 완료 — CSS 분리, pane 중첩 버그 수정, app.js 제거

Phase 4 — CSS 분리:
- style.css(2,230→670줄)에서 탭별 스타일을 css/<tab>.css 8개로 분할
  (t2s:437, pid:236, pb:106, hist:100, evt:50, opcsvr:14, llmchat:501, kbadmin:109)
- 크로스탭 공유 스타일(nm-*, hist-status, dt-picker 등)은 style.css 잔류
- index.html head에 11개 CSS link 태그 (1 style.css + 8 tab + 2 lib)

app.js 제거:
- index.html에서 <script src=/js/app.js> 참조 제거
- app.js → 10줄 placeholder (이미 Phase 0-3에서 모든 로직 이전 완료)

Pane wrapper 버그 수정:
- 16개 pane 파일에서 <section class=pane id=pane-xxx> wrapper 제거
- activateTab이 innerHTML로 주입 시 중첩 section + display:none 발생
- 내용이 전혀 안 보이는 문제 해결

문서 갱신:
- AGENTS.md: Frontend Architecture 섹션 추가
- 웹UI-개선플랜-byOPUS.md: Phase 0-4 완료 상태로 갱신, 결과 검증 추가

MCP:
- server.py: timestamp 정밀도 개선 등
This commit is contained in:
windpacer
2026-05-24 18:47:25 +09:00
parent 32a442abd6
commit 9bcba0a317
47 changed files with 8476 additions and 8329 deletions

View File

@@ -95,3 +95,52 @@ Vanilla JS SPA. `wwwroot/index.html` + `js/app.js` + `css/style.css`. No build s
## Deploy ## Deploy
`sudo bash deploy.sh` — publishes to `/opt/ExperionCrawler`, creates systemd service `experioncrawler`, sets up PKI dirs. Service runs as `www-data`. `sudo bash deploy.sh` — publishes to `/opt/ExperionCrawler`, creates systemd service `experioncrawler`, sets up PKI dirs. Service runs as `www-data`.
---
## Frontend Architecture (refactored 2026-05-24)
### Directory layout
```
wwwroot/
├── index.html ← data-src shell (229 lines, -87%)
├── panes/ ← 16 pane HTML partials (lazy-loaded on tab click)
├── js/
│ ├── core.js ← esc/setGlobal/log/api/fmtTs/fmtVal/parseEnumPv
│ │ activateTab/paneInit + dt* date picker
│ ├── app.js ← placeholder (10 lines)
│ ├── cert.js conn.js … ← 15 tab-specific files
│ └── docs.js ← separately maintained
├── css/ ← style.css monolithic (Phase 4 pending)
└── lib/ ← unchanged
```
### Tab lifecycle
1. User clicks `.nav-item[data-tab="X"]`
2. `core.js:activateTab(X)` fires:
- If `pane-X` not loaded → `fetch(/panes/X.html)``innerHTML` inject
- Activate pane + nav highlight
- `paneInit[X]?.()` — each tab file registers its own init
### PaneInit registration
| Tab | Init function | File |
|-----|--------------|------|
| docs | `paneInit.docs = docsInit` | `docs.js` |
| opcsvr | `paneInit.opcsvr = srvLoad` | `opcsvr.js` |
| t2s | `paneInit.t2s = t2sInitMode` | `t2s.js` |
| fast | `paneInit.fast = function() { … }` | `fast.js` |
| pid | `paneInit.pid = async function() { … }` | `pid.js` |
| llmchat | `paneInit.llmchat = function() { … }` | `llmchat.js` |
| kbadmin | `paneInit.kbadmin = async function() { … }` | `kbadmin.js` |
| cert/conn/crawl/db/nm-dash/pb/hist/evt/write | init 불필요 | — |
### JS load order (index.html)
`core.js``app.js` → cert/conn/crawl/db/nm-dash/pb/hist/opcsvr/t2s/fast/pid/evt/llmchat/kbadmin/write → `docs.js`
### Phase 4 pending — CSS 분리
`style.css`(2,230줄)에서 탭별 스타일 분할 미완료. `docs.css`가 선례. `웹UI-개선플랜-byOPUS.md` §11 참조.

View File

@@ -964,6 +964,137 @@ async def run_sql(sql: str) -> str:
return await _execute_sql_internal(sql) return await _execute_sql_internal(sql)
@mcp.tool()
async def upsert_pid_connection(
tag_no: str,
from_tag: str | None = None,
to_tag: str | None = None,
from_at: str | None = None,
to_at: str | None = None,
role: str | None = None,
category: str | None = None,
tag_class: str | None = None,
) -> str:
"""운전자가 작성한 from/to 연결 1건을 pid_equipment 에 멱등(idempotent) 반영.
⚠️ 오직 pid_equipment 테이블만, 아래 컬럼만 변경한다(나머지는 절대 안 건드림):
from_tag, to_tag, from_at, to_at, role, category, tag_class, connection_locked, updated_at
equipment_name/instrument_type/line_number/pid_drawing_no/pos_x/pos_y/confidence/is_active 는 보존.
매칭 규칙(문서규칙 §5):
(1) 같은 연결((tag_no, from_tag, to_tag) 동일)이 있으면 그 행 UPDATE ← 재실행 안전
(2) 없고, 해당 tag_no 의 from_tag·to_tag 가 비어있는 미완성 행(DXF)이 있으면 그 행을 채워 UPDATE
(3) 둘 다 없으면 INSERT (같은 tag 추가 경로면 기존 행의 equipment_name/type/line/drawing/category 복사)
자동 처리:
- from_tag 또는 to_tag 가 있으면 connection_locked=TRUE (연결분석이 덮어쓰지 않게)
- updated_at = now()
Args:
tag_no: 태그번호 (필수, 대소문자 무시 매칭).
from_tag/to_tag: 상류/하류 태그. 병렬이면 호출 측에서 "A, B" 콤마 병합해 전달(이 도구는 분리하지 않음).
from_at/to_at: 위치/서술 텍스트. role/category/tag_class: 그대로 저장(None이면 기존 유지).
Returns:
JSON { success, action(update_existing|update_filled|insert), id, tag_no, before, after }
"""
def _n(v):
if v is None:
return None
v = str(v).strip()
return v or None
tag_no = _n(tag_no)
if not tag_no:
return json.dumps({"success": False, "error": "tag_no 가 비었습니다."}, ensure_ascii=False)
from_tag, to_tag = _n(from_tag), _n(to_tag)
from_at, to_at = _n(from_at), _n(to_at)
role, category, tag_class = _n(role), _n(category), _n(tag_class)
locked = (from_tag is not None) or (to_tag is not None)
_SNAP = ["tag_no", "from_tag", "to_tag", "from_at", "to_at", "role", "category", "tag_class", "connection_locked"]
def _snap(cur, _id):
cur.execute("""SELECT tag_no, from_tag, to_tag, from_at, to_at, role, category, tag_class, connection_locked
FROM pid_equipment WHERE id=%s""", (_id,))
r = cur.fetchone()
return dict(zip(_SNAP, r)) if r else None
conn = None
try:
conn = await _get_db_connection()
with conn.cursor() as cur:
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
# (1) 같은 연결 (재실행 멱등)
cur.execute(
"""SELECT id FROM pid_equipment
WHERE lower(tag_no)=lower(%s)
AND from_tag IS NOT DISTINCT FROM %s
AND to_tag IS NOT DISTINCT FROM %s
ORDER BY id LIMIT 1""",
(tag_no, from_tag, to_tag))
row = cur.fetchone()
target_id = row[0] if row else None
action = "update_existing" if row else None
# (2) 빈 미완성(DXF) 행 채우기
if target_id is None:
cur.execute(
"""SELECT id FROM pid_equipment
WHERE lower(tag_no)=lower(%s)
AND (from_tag IS NULL OR from_tag='')
AND (to_tag IS NULL OR to_tag='')
ORDER BY id LIMIT 1""",
(tag_no,))
r2 = cur.fetchone()
if r2:
target_id, action = r2[0], "update_filled"
if target_id is not None:
before = _snap(cur, target_id)
cur.execute(
"""UPDATE pid_equipment SET
from_tag=%s, to_tag=%s, from_at=%s, to_at=%s, role=%s,
category=COALESCE(%s, category),
tag_class=COALESCE(%s, tag_class),
connection_locked=%s, updated_at=now()
WHERE id=%s""",
(from_tag, to_tag, from_at, to_at, role, category, tag_class, locked, target_id))
else:
action = "insert"
before = None
# 같은 tag 기존 행에서 정적 메타 복사 (없으면 NULL)
cur.execute("""SELECT equipment_name, instrument_type, line_number, pid_drawing_no, category
FROM pid_equipment WHERE lower(tag_no)=lower(%s) ORDER BY id LIMIT 1""", (tag_no,))
meta = cur.fetchone() or (None, None, None, None, None)
eq_name, inst_type, line_no, draw_no, ex_cat = meta
cur.execute(
"""INSERT INTO pid_equipment
(tag_no, equipment_name, instrument_type, line_number, pid_drawing_no,
from_tag, to_tag, from_at, to_at, role, category, tag_class,
connection_locked, confidence, is_active, extracted_at, updated_at)
VALUES (%s,%s,%s,%s,%s, %s,%s,%s,%s,%s, COALESCE(%s,%s),%s, %s, 0, TRUE, now(), now())
RETURNING id""",
(tag_no, eq_name, inst_type, line_no, draw_no,
from_tag, to_tag, from_at, to_at, role, category, ex_cat, tag_class, locked))
target_id = cur.fetchone()[0]
after = _snap(cur, target_id)
conn.commit()
return json.dumps({"success": True, "action": action, "id": target_id,
"tag_no": tag_no, "before": before, "after": after},
ensure_ascii=False, default=str)
except Exception as e:
if conn:
try:
conn.rollback()
except Exception:
pass
return json.dumps({"success": False, "error": f"upsert 실패: {e}"}, ensure_ascii=False)
finally:
if conn:
conn.close()
@mcp.tool() @mcp.tool()
async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회. """과거 값(PV) 히스토리 조회.

View File

@@ -0,0 +1,50 @@
/* ── Event History ─────────────────────────────────────────── */
.evt-badge {
display: inline-block;
font-family: var(--fm); font-size: 10px; font-weight: 700;
letter-spacing: .06em; padding: 2px 8px; border-radius: 3px;
text-transform: uppercase; white-space: nowrap;
}
.evt-badge.trip { background: rgba(239,68,68,.18); color: #f87171; }
.evt-badge.run { background: rgba(16,185,129,.18); color: #34d399; }
.evt-badge.alarm { background: rgba(245,158,11,.18); color: #fbbf24; }
.evt-badge.normal { background: rgba(148,163,184,.18); color: #94a3b8; }
.evt-badge.change { background: rgba(96,165,250,.18); color: #60a5fa; }
.evt-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-top: 12px;
}
.evt-summary-item {
background: var(--s2);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
}
.evt-summary-section {
font-family: var(--fm); font-size: 13px; font-weight: 700;
color: var(--t0); margin-bottom: 10px;
}
.evt-summary-counts {
display: flex; gap: 10px; flex-wrap: wrap;
}
.evt-count {
display: flex; align-items: center; gap: 5px;
font-family: var(--fm); font-size: 11px; color: var(--t2);
}
.evt-count strong { font-size: 14px; color: var(--t0); }
.evt-total {
font-family: var(--fm); font-size: 11px; color: var(--t2);
margin-top: 8px; padding-top: 8px;
border-top: 1px solid var(--bd);
}
.evt-total strong { color: var(--t0); }

View File

@@ -0,0 +1,100 @@
/* ── History Query Status Box ──────────────────────────────── */
.hist-status-box {
background: var(--s2);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
margin: 12px 0;
transition: all var(--tr);
}
.hist-status-box.pending { border-color: var(--t2); }
.hist-status-box.busy { border-color: var(--blu); }
.hist-status-box.ok { border-color: var(--grn); }
.hist-status-box.err { border-color: var(--red); }
.hist-status-header {
display: flex; align-items: center; gap: 10px;
font-size: 14px; font-weight: 600;
}
.hist-status-icon {
font-size: 18px;
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
background: var(--s3);
border-radius: 50%;
}
.hist-status-text { color: var(--t0); }
.hist-status-detail {
margin-top: 8px;
font-size: 12px;
color: var(--t1);
font-family: var(--fm);
line-height: 1.5;
word-break: break-all;
}
/* ── Hypertable Management ─────────────────────────────────── */
.ht-hidden { display: none !important; }
.ht-status-box {
background: var(--s2);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
margin: 12px 0;
transition: all var(--tr);
}
.ht-status-box.pending { border-color: var(--t2); }
.ht-status-box.busy { border-color: var(--blu); }
.ht-status-box.ok { border-color: var(--grn); }
.ht-status-box.err { border-color: var(--red); }
.ht-status-header {
display: flex; align-items: center; gap: 10px;
font-size: 14px; font-weight: 600;
}
.ht-status-icon {
font-size: 18px;
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
background: var(--s3);
border-radius: 50%;
}
.ht-status-text { color: var(--t0); }
.ht-status-detail {
margin-top: 8px;
font-size: 12px;
color: var(--t1);
font-family: var(--fm);
line-height: 1.5;
word-break: break-all;
}
.ht-info-panel {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--bd);
}
.ht-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.ht-info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.ht-info-label {
font-size: 11px;
color: var(--t2);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.ht-info-value {
font-size: 13px;
color: var(--t0);
font-family: var(--fm);
font-weight: 500;
}

View File

@@ -0,0 +1,109 @@
/* ═══════════════════════════════════════════════════════
14 RAG 관리 (KB Admin)
══════════════════════════════════════════════════════ */
.kb-login-card { max-width: 460px; }
.kb-main { display: flex; flex-direction: column; gap: 12px; }
.kb-topbar {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; background: var(--s1); border: 1px solid var(--bd1);
border-radius: var(--rm);
}
.kb-session { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--t1); }
.kb-actions { display: flex; gap: 8px; }
.kb-filters { padding: 12px; }
.kb-msg { font-size: 12px; color: var(--t2); margin-left: 8px; }
.kb-stats { font-size: 12px; color: var(--t2); padding: 4px 6px; }
.kb-doc-tbl { width: 100%; font-size: 12px; }
.kb-doc-tbl th, .kb-doc-tbl td { padding: 8px 10px; border-bottom: 1px solid var(--bd1); }
.kb-doc-tbl th { background: var(--s1); text-align: left; font-weight: 600; color: var(--t1); }
.kb-doc-tbl td.mono { font-family: var(--ffm); font-size: 11px; color: var(--t2); }
.kb-tag {
display: inline-block; padding: 1px 6px; margin: 0 2px 2px 0;
background: var(--s2); border: 1px solid var(--bd1); border-radius: 8px;
font-size: 11px; color: var(--t1);
}
.kb-status {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
}
.kb-st-pending { background: #3a3a55; color: #aab2d4; }
.kb-st-parsing { background: #4a4a1a; color: #f3d76b; }
.kb-st-embedding { background: #4a4a1a; color: #f3d76b; }
.kb-st-indexed { background: #1f4a2a; color: #6bd58b; }
.kb-st-failed { background: #5a1f1f; color: #f37070; }
.kb-st-disabled { background: #303032; color: #888; }
.kb-err {
font-size: 11px; color: #f37070; margin-top: 2px;
max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 모달 */
.kb-modal {
position: fixed; inset: 0; z-index: 950;
background: rgba(0,0,0,.55);
display: flex; align-items: center; justify-content: center;
}
.kb-modal.hidden { display: none; }
.kb-modal-body {
background: var(--s2); border: 1px solid var(--bd2);
border-radius: var(--rl); padding: 22px;
width: 460px; max-width: 92vw; max-height: 90vh; overflow-y: auto;
}
.kb-modal-title { font-weight: 700; font-size: 15px; margin-bottom: 14px; color: var(--t0); }
/* 청크 미리보기 모달 (큰 사이즈) */
.kb-chunk-modal-body { width: 820px; max-width: 96vw; max-height: 92vh; }
.kb-chunk-body { max-height: 70vh; overflow-y: auto; padding-right: 4px; margin-bottom: 12px; }
.kb-chunk-stat { color: var(--t2); font-size: 12px; margin-bottom: 8px; }
.kb-chunk-card {
border: 1px solid var(--bd1); border-radius: var(--rm);
margin-bottom: 8px; background: var(--s1); overflow: hidden;
}
.kb-chunk-head {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; cursor: pointer; user-select: none;
background: var(--s2); border-bottom: 1px solid var(--bd1);
}
.kb-chunk-badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 11px; font-weight: 600;
background: #2a3a5a; color: #b8d4ff;
}
.kb-chunk-kind-table { background: #3a5a2a; color: #c0e0b0; }
.kb-chunk-kind-page { background: #5a4a2a; color: #ffd09c; }
.kb-chunk-kind-row { background: #2a5a4a; color: #a0e0d0; }
.kb-chunk-locator {
flex: 1; font-family: var(--ffm); font-size: 11px; color: var(--t2);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.kb-chunk-len { font-family: var(--ffm); font-size: 11px; color: var(--t3); }
.kb-chunk-preview {
padding: 8px 10px; font-size: 12px; line-height: 1.5; color: var(--t1);
white-space: pre-wrap; word-break: break-word;
}
.kb-chunk-full { display: none; }
.kb-chunk-card.open .kb-chunk-preview { display: none; }
.kb-chunk-card.open .kb-chunk-full { display: block; }
.kb-chunk-full pre {
margin: 0; padding: 10px 12px;
font-size: 12px; line-height: 1.55; color: var(--t1);
white-space: pre-wrap; word-break: break-word;
background: var(--s0); font-family: var(--ffm);
max-height: 50vh; overflow-y: auto;
}
/* Field Instrument Inference */
.kb-infer-card { border-left: 3px solid #4472C4; }
.kb-infer-body { padding: 8px 0; }
.kb-infer-desc { color: #666; font-size: 0.9em; margin: 8px 0; }
.kb-infer-options { margin: 8px 0; }
.kb-toggle { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9em; }
.kb-toggle input { margin: 0; }
.kb-infer-result { margin-top: 12px; padding: 12px; background: #f0f7ff; border-radius: 6px; }
.kb-infer-stats { display: flex; gap: 16px; margin-bottom: 8px; }
.kb-stat { font-weight: 600; color: #333; }
.kb-stat::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4472C4; margin-right: 4px; }

View File

@@ -0,0 +1,501 @@
/* ═══════════════════════════════════════════════════════
로컬 LLM 채팅
══════════════════════════════════════════════════════ */
.llm-layout {
display: flex;
gap: 0;
height: calc(100vh - var(--sw) - 80px);
min-height: 560px;
border: 1px solid var(--bd);
border-radius: var(--r);
overflow: hidden;
background: var(--s1);
}
/* ── 왼쪽 사이드바 ──────────────────────────────────── */
.llm-sidebar {
width: 200px;
flex-shrink: 0;
background: var(--s2);
border-right: 1px solid var(--bd);
display: flex;
flex-direction: column;
}
.llm-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--bd);
}
.llm-sidebar-title {
font-size: 12px;
font-weight: 700;
color: var(--t1);
}
.llm-session-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.llm-session-item {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
color: var(--t2);
transition: all var(--tr);
margin-bottom: 2px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.llm-session-item:hover {
background: var(--s3);
color: var(--t1);
}
.llm-session-item.active {
background: var(--ag);
color: var(--a);
}
.llm-session-item .llm-sess-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.llm-session-item .llm-sess-del {
opacity: 0;
color: var(--red);
font-size: 14px;
transition: opacity var(--tr);
flex-shrink: 0;
}
.llm-session-item:hover .llm-sess-del {
opacity: 1;
}
.llm-sidebar-footer {
padding: 8px;
border-top: 1px solid var(--bd);
}
.llm-empty {
padding: 20px 12px;
font-size: 11px;
color: var(--t2);
text-align: center;
line-height: 1.6;
}
/* ── 메인 영역 ──────────────────────────────────────── */
.llm-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
/* 상단 바 */
.llm-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 10px 14px;
border-bottom: 1px solid var(--bd);
background: var(--s2);
gap: 10px;
}
.llm-header-left,
.llm-header-right {
display: flex;
align-items: flex-end;
gap: 8px;
}
.llm-conn-dot {
font-size: 12px;
color: var(--t2);
padding-bottom: 6px;
}
.llm-conn-dot.connected {
color: var(--grn);
}
.llm-conn-dot.error {
color: var(--red);
}
.llm-header-left .ck {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: var(--t1);
white-space: nowrap;
user-select: none;
}
.llm-header-left .ck input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
/* 설정 패널 */
.llm-settings {
background: var(--s3);
border-bottom: 1px solid var(--bd);
padding: 12px 14px;
}
/* 메시지 영역 */
.llm-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.llm-welcome {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--t2);
gap: 8px;
}
.llm-welcome-icon {
font-size: 40px;
opacity: 0.3;
}
.llm-welcome-text {
font-size: 14px;
font-weight: 600;
}
.llm-welcome-hint {
font-size: 12px;
}
/* 메시지 버블 */
.llm-msg {
display: flex;
gap: 10px;
max-width: 85%;
animation: llm-fade-in 0.2s ease;
}
@keyframes llm-fade-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.llm-msg.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.llm-msg.assistant {
align-self: flex-start;
}
.llm-msg-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
margin-top: 2px;
}
.llm-msg.user .llm-msg-avatar {
background: var(--a);
color: #fff;
}
.llm-msg.assistant .llm-msg-avatar {
background: var(--s4);
color: var(--t1);
}
.llm-msg-bubble {
padding: 10px 14px;
border-radius: var(--r);
font-size: 13px;
line-height: 1.6;
word-break: break-word;
white-space: pre-wrap;
}
.llm-msg.user .llm-msg-bubble {
background: var(--a);
color: #fff;
border-bottom-right-radius: 2px;
}
.llm-msg.assistant .llm-msg-bubble {
background: var(--s3);
color: var(--t0);
border: 1px solid var(--bd);
border-bottom-left-radius: 2px;
}
.llm-msg-bubble code {
background: rgba(0,0,0,0.3);
padding: 1px 5px;
border-radius: 3px;
font-family: var(--fm);
font-size: 12px;
}
.llm-msg-bubble pre {
background: rgba(0,0,0,0.4);
padding: 10px 12px;
border-radius: 6px;
overflow-x: auto;
margin: 8px 0;
font-family: var(--fm);
font-size: 12px;
line-height: 1.5;
}
.llm-msg-bubble pre code {
background: none;
padding: 0;
}
/* 타이핑 인디케이터 */
.llm-typing {
display: flex;
gap: 4px;
padding: 4px 0;
}
.llm-typing span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--t2);
animation: llm-bounce 1.2s infinite;
}
.llm-typing span:nth-child(2) { animation-delay: 0.2s; }
.llm-typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes llm-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }
}
/* ── 입력 영역 ──────────────────────────────────────── */
.llm-input-area {
padding: 12px 14px;
border-top: 1px solid var(--bd);
background: var(--s2);
}
.llm-input-box {
display: flex;
align-items: flex-end;
gap: 8px;
}
.llm-textarea {
flex: 1;
background: var(--s3);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 10px 14px;
color: var(--t0);
font-family: var(--ff);
font-size: 13px;
resize: none;
outline: none;
line-height: 1.5;
max-height: 150px;
transition: border-color var(--tr);
}
.llm-textarea:focus {
border-color: var(--a);
}
.llm-textarea::placeholder {
color: var(--t2);
}
.llm-input-btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* ── 반응형 ─────────────────────────────────────────── */
@media (max-width: 768px) {
.llm-sidebar { display: none; }
.llm-layout { height: calc(100vh - var(--sw) - 120px); }
}
/* ═══════════════════════════════════════════════════════
Phase 5 — 채팅 통합 (툴 카드 / KB 인용 / 표 / 추천 칩)
══════════════════════════════════════════════════════ */
/* 툴 카드 컨테이너 — assistant 메시지 버블 위 */
.llm-tool-cards {
display: flex; flex-direction: column; gap: 6px;
margin: 4px 0 8px 0;
}
.llm-tool-card {
border: 1px solid var(--bd1);
border-radius: var(--rm);
background: var(--s1);
font-size: 12px;
overflow: hidden;
}
.llm-tool-card.running .llm-tool-icon { animation: spin 1.4s linear infinite; }
.llm-tool-card.ok { border-color: #2a5a3a; }
.llm-tool-card.err { border-color: #5a2a2a; }
.llm-tool-card.extending { border-color: #8a6a1f; background: rgba(180, 130, 30, 0.06); }
.llm-tool-card.extending .llm-tool-icon { animation: spin 0.7s linear infinite; }
.llm-tool-card.extending .llm-tool-status { color: #e9b94a; }
.llm-tool-head {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; cursor: pointer;
background: var(--s2);
user-select: none;
}
.llm-tool-head:hover { background: var(--s3, #2a2a2e); }
.llm-tool-icon { font-size: 13px; }
.llm-tool-name { font-weight: 600; color: var(--t0); }
.llm-tool-args {
flex: 1; color: var(--t2); font-size: 11px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.llm-tool-status { font-size: 11px; color: var(--t2); flex-shrink: 0; }
.llm-tool-card.ok .llm-tool-status { color: #6bd58b; }
.llm-tool-card.err .llm-tool-status { color: #f37070; }
.llm-tool-body { display: none; padding: 8px 10px; }
.llm-tool-card.open .llm-tool-body { display: block; }
.llm-tool-raw {
margin: 0; padding: 6px 8px; max-height: 240px; overflow: auto;
font-family: var(--ffm); font-size: 11px;
background: var(--s0); border: 1px solid var(--bd1); border-radius: 4px;
white-space: pre-wrap; word-break: break-all;
}
.llm-tool-err {
color: #f37070; font-family: var(--ffm); font-size: 12px;
padding: 6px 8px;
}
.llm-tool-more {
font-size: 11px; color: var(--t2); margin-top: 4px; text-align: right;
}
/* 툴 결과 표 */
.llm-tool-tbl-wrap { max-height: 320px; overflow: auto; border: 1px solid var(--bd1); border-radius: 4px; }
.llm-tool-tbl {
width: 100%; border-collapse: collapse; font-size: 11px; font-family: var(--ffm);
}
.llm-tool-tbl th, .llm-tool-tbl td {
padding: 4px 8px; border-bottom: 1px solid var(--bd1);
text-align: left; white-space: nowrap;
}
.llm-tool-tbl th {
background: var(--s2); position: sticky; top: 0;
font-weight: 600; color: var(--t1);
}
/* 대화 요약 배너 (Phase 7.2) */
.llm-summary-card {
margin: 4px 0 12px 0; padding: 8px 12px;
border: 1px dashed #4a5a7a; border-radius: 6px;
background: rgba(80, 110, 160, 0.08);
cursor: pointer; user-select: none;
}
.llm-summary-head { font-size: 12px; color: var(--t2); font-weight: 600; }
.llm-summary-body {
display: none; margin-top: 8px; padding-top: 6px;
border-top: 1px solid var(--bd1);
font-size: 12px; color: var(--t1); line-height: 1.5;
white-space: pre-wrap; word-break: break-word;
}
.llm-summary-card.open .llm-summary-body { display: block; }
/* 시계열 스파클라인 (툴 카드 안) */
.llm-sparkline-box {
margin-bottom: 8px; padding: 8px 10px;
border: 1px solid var(--bd1); border-radius: 4px;
background: var(--s0);
}
.llm-sparkline-label { font-size: 11px; color: var(--t2); margin-bottom: 4px; }
.llm-sparkline-chart { width: 100%; min-height: 90px; }
.llm-sparkline-chart .u-legend { display: none !important; }
/* KB 검색 결과 (search_kb 카드 안) */
.llm-kb-hits { display: flex; flex-direction: column; gap: 6px; }
.llm-kb-hit {
padding: 6px 8px; background: var(--s0);
border: 1px solid var(--bd1); border-radius: 4px;
}
.llm-kb-head { font-size: 12px; color: var(--t0); margin-bottom: 2px; }
.llm-kb-head .mono { color: var(--t2); margin-right: 6px; }
.llm-kb-snip { font-size: 11px; color: var(--t1); line-height: 1.4; }
/* KB 인용 링크 (본문 안) */
.kb-cite-link {
color: var(--a); text-decoration: none;
border-bottom: 1px dashed var(--a);
padding-bottom: 1px;
}
.kb-cite-link:hover { color: #fff; border-bottom-style: solid; }
/* welcome 추천 질문 칩 */
.llm-chip-row {
display: flex; flex-wrap: wrap; gap: 8px;
justify-content: center; margin-top: 16px;
max-width: 720px;
}
.llm-chip {
padding: 6px 12px;
background: var(--s2);
border: 1px solid var(--bd1);
border-radius: 16px;
color: var(--t0);
font-family: var(--ff);
font-size: 12px;
cursor: pointer;
transition: all .15s;
}
.llm-chip:hover {
background: var(--s3, var(--s2));
border-color: var(--a);
transform: translateY(-1px);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,14 @@
/* ── OPC UA 서버 탭 ──────────────────────────────────────── */
.srv-status-card {
background: var(--s2); border: 1px solid var(--bd);
border-radius: var(--r); padding: 18px 22px; margin-top: 8px;
}
.srv-status-row {
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
}
.srv-label { font-size: 18px; font-weight: 600; color: var(--t0); }
.srv-meta {
display: flex; flex-wrap: wrap; gap: 16px;
font-size: 13px; color: var(--t1);
}
.srv-meta span b { color: var(--t0); }

106
src/Web/wwwroot/css/pb.css Normal file
View File

@@ -0,0 +1,106 @@
/* ── 포인트빌더 ──────────────────────────────────────────── */
.pb-group-card {
background: var(--s3);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 12px 14px;
margin-bottom: 10px;
}
.pb-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.pb-pattern-input {
width: 100%;
}
.pb-attr-checkboxes {
display: flex;
gap: 14px;
margin: 6px 0;
flex-wrap: wrap;
}
.pb-attr-checkboxes label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--t1);
cursor: pointer;
}
.pb-custom-attr-inputs {
display: flex;
gap: 8px;
margin-bottom: 6px;
}
.pb-custom-attr-inputs .inp {
flex: 1;
min-width: 0;
width: auto;
}
.pb-datatype-select {
max-width: 260px;
}
@media (max-width: 900px) {
.pb-custom-attr-inputs { flex-direction: column; }
}
/* ── 포인트빌더 미리보기 ───────────────────────────────────── */
.pb-preview {
background: var(--s3);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
margin-top: 10px;
}
.pb-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-weight: 600;
font-size: 13px;
}
.pb-preview-actions {
display: flex;
gap: 6px;
align-items: center;
}
.pb-preview table th:first-child,
.pb-preview table td:first-child {
width: 36px;
text-align: center;
}
.pb-preview table input[type="checkbox"] {
cursor: pointer;
width: 15px;
height: 15px;
}
.pb-preview .group-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: var(--ag);
color: var(--a);
font-weight: 600;
}
@media (max-width: 900px) {
.pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
.pb-preview-actions { flex-wrap: wrap; }
}

236
src/Web/wwwroot/css/pid.css Normal file
View File

@@ -0,0 +1,236 @@
/* ── P&ID 추출 스타일 ───────────────────────────────────────── */
#pane-pid .card-cap {
display: flex;
justify-content: space-between;
align-items: center;
}
#pane-pid .btn-sm {
padding: 4px 10px;
font-size: 12px;
border-radius: 4px;
border: 1px solid var(--bd2);
background: var(--s1);
color: var(--t1);
cursor: pointer;
transition: all 0.2s;
}
#pane-pid .btn-sm:hover {
background: var(--s2);
border-color: var(--a);
}
#pane-pid .btn-sm.btn-a {
background: var(--a);
border-color: var(--a);
color: #fff;
}
#pane-pid .btn-sm.btn-a:hover {
background: var(--a2);
}
#pane-pid .btn-sm.btn-b {
background: var(--s1);
border-color: var(--bd2);
color: var(--t1);
}
#pane-pid .btn-sm.btn-b:hover {
background: var(--s2);
}
#pane-pid .badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
white-space: nowrap;
}
#pane-pid .badge.ok {
background: rgba(16,185,129,.15);
color: var(--grn);
}
#pane-pid .badge.warn {
background: rgba(234,179,8,.15);
color: #eab308;
}
#pane-pid .badge.err {
background: rgba(239,68,68,.15);
color: var(--red);
}
#pane-pid .badge.inf {
background: rgba(59,130,246,.15);
color: var(--blu);
}
/* ── Category-grouped prefix rules ──────────────────────────── */
#pane-pid .pid-cat-group {
margin-bottom: 8px;
border: 1px solid var(--bd);
border-radius: var(--r);
overflow: hidden;
}
#pane-pid .pid-cat-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--s2);
border-bottom: 1px solid var(--bd);
font-size: 13px;
}
#pane-pid .pid-cat-count {
font-size: 11px;
color: var(--t2);
}
#pane-pid .pid-cat-add {
display: flex;
align-items: center;
gap: 4px;
}
#pane-pid .pid-cat-body {
padding: 0;
}
#pane-pid .pid-cat-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
font-size: 12px;
border-bottom: 1px solid var(--bd);
}
#pane-pid .pid-cat-row:last-child {
border-bottom: none;
}
#pane-pid .pid-cat-prefix {
width: 80px;
flex-shrink: 0;
}
#pane-pid .pid-cat-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#pane-pid .pid-cat-order {
width: 40px;
text-align: center;
flex-shrink: 0;
color: var(--t2);
}
#pane-pid .pid-cat-actions {
width: 90px;
display: flex;
gap: 3px;
flex-shrink: 0;
}
#pane-pid .stat-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: var(--s1);
border-radius: var(--r);
border: 1px solid var(--bd);
}
#pane-pid .stat-label {
font-size: 12px;
color: var(--t2);
margin-bottom: 4px;
}
#pane-pid .stat-value {
font-size: 20px;
font-weight: 700;
color: var(--t0);
font-family: var(--fm);
}
#pane-pid .pagination {
display: flex;
justify-content: center;
gap: 4px;
padding: 8px;
}
#pane-pid .pagination button {
padding: 4px 10px;
font-size: 12px;
border-radius: 4px;
border: 1px solid var(--bd2);
background: var(--s1);
color: var(--t1);
cursor: pointer;
transition: all 0.2s;
}
#pane-pid .pagination button:hover {
background: var(--s2);
border-color: var(--a);
}
#pane-pid .pagination button.btn-a {
background: var(--a);
border-color: var(--a);
color: #fff;
}
#pane-pid .pagination button.btn-a:hover {
background: var(--a2);
}
#pane-pid .pagination button.btn-b {
background: var(--s1);
border-color: var(--bd2);
color: var(--t1);
}
#pane-pid .pagination button.btn-b:hover {
background: var(--s2);
}
#pane-pid .loading {
padding: 20px;
text-align: center;
color: var(--t2);
}
#pane-pid .logbox {
background: var(--s1);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 12px;
margin-top: 12px;
max-height: 200px;
overflow-y: auto;
font-family: var(--fm);
font-size: 12px;
}
#pane-pid .logbox .ll {
padding: 2px 0;
}
#pane-pid .logbox .ok { color: var(--grn); }
#pane-pid .logbox .err { color: var(--red); }
#pane-pid .logbox .inf { color: var(--blu); }

File diff suppressed because it is too large Load Diff

437
src/Web/wwwroot/css/t2s.css Normal file
View File

@@ -0,0 +1,437 @@
/* ── Text-to-SQL Dashboard ───────────────────────────────── */
/* 입력 행 */
.t2s-input-row {
display: flex; gap: 8px; align-items: flex-end;
}
.t2s-input-row .fg { flex: 1; margin-bottom: 0; }
/* 제안 칩 */
.t2s-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.t2s-chip {
background: var(--s3); border: 1px solid var(--bd);
color: var(--t1); border-radius: 12px;
padding: 4px 12px; cursor: pointer; font-size: 12px;
transition: all var(--tr);
}
.t2s-chip:hover { background: var(--s4); color: var(--t0); border-color: var(--a); }
/* SQL 텍스트영역 */
.t2s-sql-area {
width: 100%; min-height: 80px; resize: vertical;
background: var(--s2); border: 1px solid var(--bd);
color: var(--t0); border-radius: var(--r);
padding: 10px 12px; font-family: var(--fm); font-size: 13px;
line-height: 1.5;
}
.t2s-sql-area:focus { outline: none; border-color: var(--a); }
.t2s-sql-area:disabled { opacity: 0.6; }
/* 태그 분석 옵션 */
.t2s-tag-label {
display: block; font-size: 12px; color: var(--t1);
margin-bottom: 4px;
}
/* 결과 테이블 - 스크롤 및 높이 설정 */
.t2s-result-info {
font-size: 13px; color: var(--t1); margin-bottom: 10px;
padding: 8px 0;
}
/* 조회 결과 컨테이너 - 스크롤 활성화 및 높이 증가 */
.t2s-result-container {
max-height: 600px;
min-height: 400px;
overflow-y: auto;
overflow-x: auto;
border: 1px solid var(--bd);
border-radius: var(--r);
}
/* 조회 결과 영역 - 여러 값 표시 및 스크롤바 */
#t2s-results {
max-height: 600px;
min-height: 400px;
overflow-y: auto;
overflow-x: auto;
min-height: 200px;
}
#t2s-results::-webkit-scrollbar {
width: 10px;
height: 10px;
}
#t2s-results::-webkit-scrollbar-track {
background: var(--s1);
border-radius: 5px;
}
#t2s-results::-webkit-scrollbar-thumb {
background: var(--bd2);
border-radius: 5px;
}
#t2s-results::-webkit-scrollbar-thumb:hover {
background: var(--t2);
}
#t2s-results::-webkit-scrollbar-corner {
background: var(--s1);
}
.t2s-table {
width: 100%; border-collapse: collapse;
font-size: 13px; font-family: var(--fm);
}
.t2s-table thead { background: var(--s3); }
.t2s-table th {
padding: 8px 12px; text-align: left;
color: var(--t0); font-weight: 600;
border-bottom: 2px solid var(--bd);
white-space: nowrap;
}
.t2s-table td {
padding: 6px 12px;
border-bottom: 1px solid var(--bd);
color: var(--t1);
max-width: 300px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.t2s-table tbody tr:hover { background: var(--s3); }
.t2s-null { color: var(--t2); font-style: italic; }
/* 로딩 / 빈 결과 / 오류 */
.t2s-loading, .t2s-empty {
text-align: center; padding: 40px 20px;
color: var(--t1); font-size: 14px;
}
.t2s-loading { color: var(--a); }
.t2s-error {
text-align: center; padding: 40px 20px;
color: var(--red); font-size: 14px;
}
/* 분석 결과 그리드 */
.t2s-analysis-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px; margin-top: 10px;
}
.t2s-tag-card {
background: var(--s2); border: 1px solid var(--bd);
border-radius: var(--r); padding: 14px 16px;
}
.t2s-tag-card h4 {
font-size: 14px; color: var(--t0); margin-bottom: 10px;
font-weight: 600;
}
.t2s-tag-stats { font-size: 13px; }
.t2s-stat-row {
display: flex; justify-content: space-between;
padding: 4px 0; border-bottom: 1px solid var(--bd);
}
.t2s-stat-row:last-child { border-bottom: none; }
.t2s-stat-row span:first-child { color: var(--t1); }
.t2s-value { color: var(--t0); font-family: var(--fm); font-weight: 500; }
.t2s-max { color: var(--red); }
.t2s-min { color: var(--blu); }
/* ── 채팅 UI 스타일 ────────────────────────────────────────── */
/* 채팅 카드 */
.t2s-chat-card {
margin-bottom: 18px;
}
/* 채팅 컨테이너 - 스크롤 활성화 */
.t2s-chat-container {
max-height: 450px;
min-height: 300px;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
}
/* 채팅 메시지 */
.t2s-chat-msg {
margin-bottom: 12px;
display: flex;
animation: t2s-msg-fade-in 0.3s ease;
}
@keyframes t2s-msg-fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.t2s-chat-msg.user {
justify-content: flex-end;
}
.t2s-chat-msg.system {
justify-content: flex-start;
}
/* 채팅 버블 */
.t2s-chat-bubble {
max-width: 85%;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
line-height: 1.5;
word-wrap: break-word;
}
/* 사용자 메시지 */
.t2s-chat-msg.user .t2s-chat-bubble {
background: var(--a);
color: var(--t0);
border-bottom-right-radius: 4px;
}
/* 시스템 메시지 */
.t2s-chat-msg.system .t2s-chat-bubble {
background: var(--s2);
color: var(--t1);
border: 1px solid var(--bd);
border-bottom-left-radius: 4px;
}
.t2s-chat-msg.system .t2s-chat-bubble strong {
color: var(--t0);
}
/* 채팅 입력 행 */
.t2s-chat-input-row {
display: flex;
gap: 8px;
align-items: flex-end;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--bd);
}
.t2s-chat-input {
flex: 1;
}
/* 채팅 SQL 표시 */
.t2s-chat-sql {
background: var(--s1);
border: 1px solid var(--bd);
border-radius: 6px;
padding: 8px 10px;
font-family: var(--fm);
font-size: 12px;
color: var(--t0);
margin-top: 6px;
white-space: pre-wrap;
max-height: 150px;
overflow-y: auto;
}
/* 타이핑 인디케이터 */
.t2s-typing {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--t2);
font-style: italic;
}
.t2s-typing::after {
content: '';
width: 6px;
height: 6px;
background: var(--a);
border-radius: 50%;
animation: t2s-typing-dot 1.4s infinite both;
}
.t2s-typing::before {
content: '';
width: 6px;
height: 6px;
background: var(--a);
border-radius: 50%;
animation: t2s-typing-dot 1.4s infinite both 0.2s;
}
@keyframes t2s-typing-dot {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* ── API 대화 페이지 스타일 ────────────────────────────────── */
/* API 채팅 컨테이너 - 스크롤 활성화 */
.api-chat-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.api-chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* API 채팅 메시지 */
.api-chat-msg {
margin-bottom: 12px;
display: flex;
animation: api-msg-fade-in 0.3s ease;
}
@keyframes api-msg-fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.api-chat-msg.user {
justify-content: flex-end;
}
.api-chat-msg.system {
justify-content: flex-start;
}
.api-chat-msg.assistant {
justify-content: flex-start;
}
/* API 채팅 버블 */
.api-chat-bubble {
max-width: 85%;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
line-height: 1.5;
word-wrap: break-word;
}
/* 사용자 메시지 */
.api-chat-msg.user .api-chat-bubble {
background: var(--a);
color: var(--t0);
border-bottom-right-radius: 4px;
}
/* 시스템 메시지 */
.api-chat-msg.system .api-chat-bubble {
background: var(--s2);
color: var(--t1);
border: 1px solid var(--bd);
border-bottom-left-radius: 4px;
}
.api-chat-msg.system .api-chat-bubble strong {
color: var(--t0);
}
/* 어시스턴트 메시지 */
.api-chat-msg.assistant .api-chat-bubble {
background: var(--s2);
color: var(--t1);
border: 1px solid var(--bd);
border-bottom-left-radius: 4px;
}
.api-chat-msg.assistant .api-chat-bubble strong {
color: var(--t0);
}
/* API 채팅 입력 행 */
.api-chat-input-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--bd);
background: var(--s1);
}
.api-chat-input {
flex: 1;
resize: none;
font-family: var(--fm);
font-size: 13px;
}
.api-chat-btn-row {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* SQL 쿼리 표시 */
.api-chat-sql {
background: var(--s1);
border: 1px solid var(--bd);
border-radius: 6px;
padding: 8px 10px;
font-family: var(--fm);
font-size: 12px;
color: var(--t0);
margin-top: 6px;
white-space: pre-wrap;
max-height: 150px;
overflow-y: auto;
}
/* 응답 컨테이너 */
.api-response-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.api-response-content {
flex: 1;
overflow-y: auto;
padding: 12px;
font-family: var(--fm);
font-size: 13px;
color: var(--t1);
}
.api-response-content .placeholder {
color: var(--t2);
font-style: italic;
}
/* 테이블 스타일 */
.api-response-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.api-response-table th {
background: var(--s2);
color: var(--t0);
font-weight: 600;
padding: 8px 12px;
text-align: left;
border: 1px solid var(--bd);
position: sticky;
top: 0;
}
.api-response-table td {
padding: 6px 12px;
border: 1px solid var(--bd);
color: var(--t1);
}
.api-response-table tr:nth-child(even) {
background: var(--s1);
}
.api-response-table tr:nth-child(odd) {
background: var(--s2);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
/* ─────────────────────────────────────────────────────────────
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 {}
}

156
src/Web/wwwroot/js/conn.js Normal file
View File

@@ -0,0 +1,156 @@
/* ─────────────────────────────────────────────────────────────
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 llmLoadConfig() {
const statusEl = document.getElementById('llm-status');
const input = document.getElementById('llm-model');
statusEl.innerHTML = '<span class="placeholder">불러오는 중...</span>';
try {
const d = await api('GET', '/api/llm/config');
if (d.success) {
input.value = d.vllmModel || '';
statusEl.innerHTML = `<span style="color:var(--color-success)">✓ 현재 모델: ${esc(d.vllmModel)}</span>`;
} else {
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(d.error || '조회 실패')}</span>`;
}
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(e.message)}</span>`;
}
}
async function llmSaveModelConfig() {
const statusEl = document.getElementById('llm-status');
const model = document.getElementById('llm-model').value.trim();
if (!model) {
statusEl.innerHTML = '<span style="color:var(--color-danger)">모델명을 입력하세요</span>';
return;
}
statusEl.innerHTML = '<span class="placeholder">저장 중...</span>';
try {
const d = await api('POST', '/api/llm/config', { vllm_model: model });
if (d.success) {
statusEl.innerHTML = `<span style="color:var(--color-success)">✓ 모델 변경 완료: ${esc(d.vllmModel)} (다음 LLM 요청 시 반영)</span>`;
} else {
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(d.error || '저장 실패')}</span>`;
}
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(e.message)}</span>`;
}
}
async function connBrowse() {
const wrap = document.getElementById('browse-wrap');
const logEl = document.getElementById('conn-log');
setGlobal('busy', '노드 탐색');
// 로그 초기화
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll inf">[진행] 노드 탐색 시작...</div>';
try {
logEl.innerHTML += '<div class="ll inf">[진행] 서버: ' + getServerCfg('x').serverHostName + ':' + getServerCfg('x').port + '</div>';
const d = await api('POST', '/api/connection/browse', {
serverConfig: getServerCfg('x'), startNodeId: null
});
logEl.innerHTML += '<div class="ll inf">[진행] 응답 수신: success=' + d.success + ', nodes=' + (d.nodes ? d.nodes.length : 0) + '</div>';
if (d.success && d.nodes?.length) {
wrap.classList.remove('hidden');
logEl.innerHTML += '<div class="ll ok">✅ [성공] 탐색 성공: ' + d.nodes.length + '개 노드</div>';
// 노드 이름 확인 로그
const emptyNames = d.nodes.filter(n => !n.displayName || n.displayName.trim() === '');
if (emptyNames.length > 0) {
logEl.innerHTML += '<div class="ll err">⚠️ 노드 이름이 비어진 항목: ' + emptyNames.length + '개 (NodeId만 복사됨)</div>';
}
// 노드 목록 생성 (displayName이 없으면 NodeId를 노출 이름으로 사용)
const nodeListHtml = d.nodes.map(n => {
const displayName = n.displayName || '<span style="color:var(--red)">이름 없음</span>';
const nodeId = n.nodeId;
// 중복된 NodeId 건너뛰기
const nodeHtml = `
<div class="bnode" onclick="document.getElementById('x-node').value='${esc(nodeId)}'">
<span>${n.nodeClass === 'Object' ? '📂' : '📌'}</span>
<span style="color:var(--t0)">${esc(displayName)}</span>
<span class="bclass" style="font-size:11px;margin-left:8px;border:1px solid var(--t3);padding:1px 4px;border-radius:3px">[${n.nodeClass}]</span>
<span class="bnid" style="font-size:10px;display:block;margin-top:2px;color:var(--t3);font-family:var(--fm)">📄 ${esc(nodeId)}</span>
</div>
`;
return nodeHtml;
}).join('');
wrap.innerHTML =
`<div style="font-family:var(--fm);font-size:11px;color:var(--t2);margin-bottom:12px">🎉 탐색 성공: ${d.nodes.length}개 노드 발견</div>` +
nodeListHtml;
setGlobal('ok', '탐색 완료');
} else {
const errorMsg = d.error || '알 수 없는 에러';
logEl.innerHTML += '<div class="ll err">❌ [실패] 탐색 실패: ' + errorMsg + '</div>';
wrap.classList.remove('hidden');
wrap.innerHTML = `<div style="color:var(--red,#e55);font-size:12px;padding:10px;border:1px solid var(--red,#e55);border-radius:4px">❌ ${errorMsg}</div>`;
setGlobal('err', '탐색 실패');
}
} catch (e) {
logEl.innerHTML += '<div class="ll err">❌ [오류] 요청 실패: ' + e.message + '</div>';
wrap.classList.remove('hidden');
wrap.innerHTML = `<div style="color:var(--red,#e55);font-size:12px;padding:10px;border:1px solid var(--red,#e55);border-radius:4px">❌ ${e.message}</div>`;
setGlobal('err', '오류');
}
}

219
src/Web/wwwroot/js/core.js Normal file
View File

@@ -0,0 +1,219 @@
/* ════════════════════════════════════════════════════════════════
core.js — ExperionCrawler Web UI Shared Core
- Global utilities: esc, setGlobal, log, api, fmt*
- Tab router: activateTab, paneInit map
- Shared types/dt helpers
════════════════════════════════════════════════════════════════ */
/* ── Helpers ────────────────────────────────────────────────── */
function esc(s) {
return String(s ?? '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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);
if (!el) return;
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();
}
/* ── Formatters ─────────────────────────────────────────────── */
function fmtTs(v) {
if (v == null || v === '') return '-';
const d = new Date(v);
if (isNaN(d.getTime())) return String(v);
const KST = new Date(d.getTime() + 9 * 3600000);
return KST.toISOString().replace('T',' ').replace('Z','').slice(0,21);
}
function fmtVal(v) {
if (v == null) return '-';
if (typeof v === 'number' && isFinite(v))
return Number.isInteger(v) ? String(v) : v.toFixed(2);
return String(v);
}
function parseEnumPv(v) {
if (!v || typeof v !== 'string') return v;
const m = v.match(/\{\d+\s*\|\s*([^|]+?)\s*\|\s*\}/);
return m ? m[1].trim() : v;
}
/* ── Tab Router ─────────────────────────────────────────────── */
const paneInit = {};
async function activateTab(tab) {
const el = document.getElementById(`pane-${tab}`);
if (!el) return;
// lazy-load pane HTML if not loaded
if (el.dataset.loaded !== '1' && el.dataset.src) {
try {
const res = await fetch(el.dataset.src);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
el.innerHTML = await res.text();
el.dataset.loaded = '1';
} catch (e) {
el.innerHTML = `<div class="pane-error" style="padding:40px;text-align:center;color:var(--t2)">
<p>⚠️ 파셜 로드 실패: <code>${el.dataset.src}</code></p>
<p style="font-size:13px">${esc(e.message)}</p>
</div>`;
el.dataset.loaded = 'err';
}
}
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
el.classList.add('active');
paneInit[tab]?.();
}
/* ── Date Time Picker (공용 — hist/evt 탭에서 사용) ────────── */
const _dtp = { target: null, year: 0, month: 0,
selYear: null, selMonth: null, selDay: null,
selHour: 0, selMin: 0 };
const _DTP_DAYS = ['일','월','화','수','목','금','토'];
function dtOpen(target) {
_dtp.target = target;
const hidden = document.getElementById(`hf-${target}`).value;
const d = hidden ? new Date(hidden) : new Date();
_dtp.year = d.getFullYear();
_dtp.month = d.getMonth();
if (hidden && !isNaN(d)) {
_dtp.selYear = d.getFullYear(); _dtp.selMonth = d.getMonth();
_dtp.selDay = d.getDate();
_dtp.selHour = d.getHours(); _dtp.selMin = d.getMinutes();
} else {
_dtp.selYear = null; _dtp.selMonth = null; _dtp.selDay = null;
_dtp.selHour = 0; _dtp.selMin = 0;
}
document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0');
document.getElementById('dt-min').value = String(_dtp.selMin).padStart(2,'0');
dtRenderCal();
const popup = document.getElementById('dt-popup');
const display = document.getElementById(`dtp-${target}-display`);
const rect = display.getBoundingClientRect();
popup.classList.remove('hidden');
document.getElementById('dt-overlay').classList.remove('hidden');
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow < popup.offsetHeight + 8) {
popup.style.top = (rect.top - popup.offsetHeight - 4) + 'px';
} else {
popup.style.top = (rect.bottom + 4) + 'px';
}
popup.style.left = Math.min(rect.left, window.innerWidth - 296) + 'px';
}
function dtRenderCal() {
document.getElementById('dt-month-label').textContent =
`${_dtp.year}${_dtp.month + 1}`;
const first = new Date(_dtp.year, _dtp.month, 1).getDay();
const daysInMon = new Date(_dtp.year, _dtp.month + 1, 0).getDate();
const daysInPrev = new Date(_dtp.year, _dtp.month, 0).getDate();
const today = new Date();
let html = _DTP_DAYS.map(d => `<div class="dt-dow">${d}</div>`).join('');
for (let i = first - 1; i >= 0; i--)
html += `<div class="dt-day other-month">${daysInPrev - i}</div>`;
for (let d = 1; d <= daysInMon; d++) {
let cls = 'dt-day';
if (_dtp.year === today.getFullYear() && _dtp.month === today.getMonth() && d === today.getDate())
cls += ' today';
if (_dtp.selYear === _dtp.year && _dtp.selMonth === _dtp.month && _dtp.selDay === d)
cls += ' selected';
html += `<div class="${cls}" onclick="dtSelectDay(${d})">${d}</div>`;
}
const trailing = (first + daysInMon) % 7;
for (let d = 1; d <= (trailing ? 7 - trailing : 0); d++)
html += `<div class="dt-day other-month">${d}</div>`;
document.getElementById('dt-cal-grid').innerHTML = html;
}
function dtSelectDay(day) {
_dtp.selYear = _dtp.year; _dtp.selMonth = _dtp.month; _dtp.selDay = day;
dtRenderCal();
}
function dtPrevMonth() {
if (--_dtp.month < 0) { _dtp.month = 11; _dtp.year--; }
dtRenderCal();
}
function dtNextMonth() {
if (++_dtp.month > 11) { _dtp.month = 0; _dtp.year++; }
dtRenderCal();
}
function dtAdjTime(part, delta) {
if (part === 'h') {
_dtp.selHour = ((_dtp.selHour + delta) + 24) % 24;
document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0');
} else {
_dtp.selMin = ((_dtp.selMin + delta) + 60) % 60;
document.getElementById('dt-min').value = String(_dtp.selMin).padStart(2,'0');
}
}
function dtClampTime(part, el) {
const max = part === 'h' ? 23 : 59;
let v = parseInt(el.value);
if (isNaN(v) || v < 0) v = 0;
if (v > max) v = max;
el.value = String(v).padStart(2,'0');
if (part === 'h') _dtp.selHour = v; else _dtp.selMin = v;
}
function dtConfirm() {
if (_dtp.selDay === null) { alert('날짜를 선택하세요.'); return; }
_dtp.selHour = parseInt(document.getElementById('dt-hour').value) || 0;
_dtp.selMin = parseInt(document.getElementById('dt-min').value) || 0;
const p = n => String(n).padStart(2,'0');
const val = `${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)}T${p(_dtp.selHour)}:${p(_dtp.selMin)}`;
document.getElementById(`hf-${_dtp.target}`).value = val;
document.getElementById(`dtp-${_dtp.target}-display`).textContent =
`${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)} ${p(_dtp.selHour)}:${p(_dtp.selMin)}`;
dtClose();
}
function dtClear() { dtClearField(_dtp.target); dtClose(); }
function dtClearField(target) {
if (!target) return;
document.getElementById(`hf-${target}`).value = '';
document.getElementById(`dtp-${target}-display`).textContent = '— 선택 안 함 —';
}
function dtCancel() { dtClose(); }
function dtClose() {
document.getElementById('dt-popup').classList.add('hidden');
document.getElementById('dt-overlay').classList.add('hidden');
_dtp.target = null;
}
/* ── DOMContentLoaded: bind tab nav, lazy-load on click ──────── */
document.addEventListener('DOMContentLoaded', async () => {
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => activateTab(item.dataset.tab));
});
// Load initial active tab (cert) + run its page-load init
await activateTab('cert');
if (typeof certStatus === 'function') {
certStatus();
}
});

125
src/Web/wwwroot/js/crawl.js Normal file
View File

@@ -0,0 +1,125 @@
/* ─────────────────────────────────────────────────────────────
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 = '🗺 전체 노드맵 수집';
}
}

102
src/Web/wwwroot/js/db.js Normal file
View File

@@ -0,0 +1,102 @@
/* ─────────────────────────────────────────────────────────────
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', '조회 실패'); }
}

View File

@@ -16,6 +16,7 @@ const docsState = {
}; };
/* ── 진입 ──────────────────────────────────────────────────── */ /* ── 진입 ──────────────────────────────────────────────────── */
paneInit.docs = docsInit;
async function docsInit() { async function docsInit() {
await docsLoadConfig(); await docsLoadConfig();
if (!docsState.inited) { if (!docsState.inited) {

152
src/Web/wwwroot/js/evt.js Normal file
View File

@@ -0,0 +1,152 @@
/* ─────────────────────────────────────────────────────────────
12 이벤트 히스토리
───────────────────────────────────────────────────────────── */
async function evtLoadTags() {
const statusEl = document.getElementById('evt-tag-status');
statusEl.textContent = '⏳ 조회 중...';
try {
const d = await api('GET', '/api/event-history/digital-tags');
const tags = d.data || [];
const sel = document.getElementById('ef-tag');
sel.innerHTML = '<option value="">— 전체 태그 —</option>' +
tags.map(t => `<option value="${esc(t.tagName)}">${esc(t.tagName)}</option>`).join('');
statusEl.textContent = `${tags.length}`;
} catch (e) {
statusEl.textContent = `${e.message}`;
}
}
async function evtQuery() {
const tag = document.getElementById('ef-tag').value;
const eventType = document.getElementById('ef-event-type').value;
const area = document.getElementById('ef-area').value.trim();
const section = document.getElementById('ef-section').value.trim();
const limit = document.getElementById('ef-limit').value || 500;
const fromRaw = document.getElementById('hf-evt-from').value;
const toRaw = document.getElementById('hf-evt-to').value;
const params = new URLSearchParams();
if (tag) params.set('tagName', tag);
if (eventType) params.set('eventType', eventType);
if (area) params.set('area', area);
if (section) params.set('section', section);
params.set('limit', limit);
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
if (toRaw) params.set('to', new Date(toRaw).toISOString());
const infoEl = document.getElementById('evt-result-info');
const tableEl = document.getElementById('evt-table');
infoEl.textContent = '⏳ 조회 중...';
infoEl.classList.remove('hidden');
tableEl.classList.add('hidden');
try {
const d = await api('GET', `/api/event-history?${params}`);
if (!d.success) throw new Error(d.error || '조회 실패');
infoEl.textContent = `${d.count}`;
tableEl.innerHTML = _evtBuildTable(d.data);
tableEl.classList.remove('hidden');
} catch (e) {
infoEl.textContent = `${e.message}`;
}
}
async function evtSummary() {
const area = document.getElementById('ef-area').value.trim();
const section = document.getElementById('ef-section').value.trim();
const fromRaw = document.getElementById('hf-evt-from').value;
const toRaw = document.getElementById('hf-evt-to').value;
const params = new URLSearchParams();
if (area) params.set('area', area);
if (section) params.set('section', section);
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
if (toRaw) params.set('to', new Date(toRaw).toISOString());
const card = document.getElementById('evt-summary-card');
const content = document.getElementById('evt-summary-content');
content.textContent = '⏳ 집계 중...';
card.classList.remove('hidden');
try {
const d = await api('GET', `/api/event-history/summary?${params}`);
if (!d.success) throw new Error(d.error || '조회 실패');
content.innerHTML = _evtBuildSummary(d.data);
} catch (e) {
content.textContent = `${e.message}`;
}
}
function _evtBadge(t) {
const cls = { TRIP:'trip', RUN:'run', ALARM:'alarm', NORMAL:'normal', CHANGE:'change' }[t] || 'change';
return `<span class="evt-badge ${cls}">${esc(t)}</span>`;
}
function _evtFmtTime(dt) {
if (!dt) return '—';
return new Date(dt).toLocaleString('ko-KR', {
year:'numeric', month:'2-digit', day:'2-digit',
hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false
});
}
function _evtBuildTable(rows) {
if (!rows || !rows.length)
return '<div style="padding:24px;text-align:center;color:var(--t2)">데이터 없음</div>';
const html = rows.map(r => `
<tr>
<td style="white-space:nowrap;color:var(--t2);font-family:var(--fm);font-size:11px">${_evtFmtTime(r.eventTime)}</td>
<td><code style="font-size:11px;color:var(--blu)">${esc(r.tagName)}</code></td>
<td>${_evtBadge(r.eventType)}</td>
<td style="color:var(--t2)">${esc(r.prevValue ?? '—')}</td>
<td style="color:var(--t0);font-weight:600">${esc(r.currValue)}</td>
<td>${r.area ? `<span class="nm-cls">${esc(r.area)}</span>` : '—'}</td>
<td>${r.section ? `<span class="nm-cls">${esc(r.section)}</span>` : '—'}</td>
<td style="font-family:var(--fm);font-size:11px;color:var(--t2)">${r.durationSeconds != null ? r.durationSeconds + 's' : '—'}</td>
</tr>`).join('');
return `
<table>
<thead><tr>
<th>시간</th>
<th>태그명</th>
<th>이벤트</th>
<th>이전값</th>
<th>현재값</th>
<th>Area</th>
<th>Section</th>
<th>지속(초)</th>
</tr></thead>
<tbody>${html}</tbody>
</table>`;
}
function _evtBuildSummary(data) {
if (!data || !data.length)
return '<div style="padding:12px;color:var(--t2)">데이터 없음</div>';
return `<div class="evt-summary-grid">${data.map(s => `
<div class="evt-summary-item">
<div class="evt-summary-section">${esc(s.section)}</div>
<div class="evt-summary-counts">
<div class="evt-count">${_evtBadge('TRIP')} <strong>${s.tripCount}</strong></div>
<div class="evt-count">${_evtBadge('RUN')} <strong>${s.runCount}</strong></div>
<div class="evt-count">${_evtBadge('ALARM')} <strong>${s.alarmCount}</strong></div>
<div class="evt-count">${_evtBadge('CHANGE')} <strong>${s.changeCount}</strong></div>
</div>
<div class="evt-total">합계 <strong>${s.totalEvents}</strong>건</div>
</div>`).join('')}</div>`;
}
function evtReset() {
document.getElementById('ef-tag').value = '';
document.getElementById('ef-event-type').value = '';
document.getElementById('ef-area').value = '';
document.getElementById('ef-section').value = '';
document.getElementById('ef-limit').value = '500';
dtClearField('evt-from');
dtClearField('evt-to');
document.getElementById('evt-result-info').classList.add('hidden');
document.getElementById('evt-table').classList.add('hidden');
document.getElementById('evt-summary-card').classList.add('hidden');
document.getElementById('evt-tag-status').textContent = '';
}

469
src/Web/wwwroot/js/fast.js Normal file
View File

@@ -0,0 +1,469 @@
// ═══════════════════════════════════════════════════════════════
// fastRecord — 변수 / 모달 헬퍼
// ═══════════════════════════════════════════════════════════════
let fastCurrentSessionId = null;
let fastChart = null;
let fastLivePollTimer = null;
let fastChartTagNames = null; // 현재 차트에 그려진 태그 목록 (재생성 필요 여부 판단용)
let fastDbConnected = false; // DB 연결 상태
function fastModalClose() {
document.getElementById('modal-fast-new').style.display = 'none';
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — API 함수
// ═══════════════════════════════════════════════════════════════
/** DB 연결 테스트 후 세션 목록 로드 */
async function fastDbConnect() {
const statusEl = document.getElementById('fast-db-status');
const btnEl = document.getElementById('btn-fast-db-connect');
statusEl.textContent = 'DB 접속 중...';
statusEl.style.color = 'var(--t2)';
btnEl.disabled = true;
try {
const res = await fetch('/api/fast/sessions');
if (!res.ok) throw new Error('DB 응답 없음');
const data = await res.json();
fastDbConnected = true;
statusEl.textContent = 'DB 연결됨';
statusEl.style.color = '#4caf50';
btnEl.style.display = 'none';
await fastSessionsLoad();
} catch (e) {
console.error('[fast] DB 접속 실패:', e);
statusEl.textContent = 'DB 미연결';
statusEl.style.color = 'var(--red,#e55)';
btnEl.disabled = false;
alert('DB 연결에 실패했습니다. 다시 시도해주세요.');
}
}
async function fastSessionsLoad() {
const res = await fetch('/api/fast/sessions');
if (!res.ok) {
// DB 연결 안 된 상태 — 세션 목록 비우기
const list = document.getElementById('fast-session-list');
list.innerHTML = '<span style="color:var(--t3);font-size:12px;padding:4px 0">DB 연결이 필요합니다. "DB 접속" 버튼을 눌러주세요.</span>';
return;
}
const data = await res.json();
fastDbConnected = true;
const statusEl = document.getElementById('fast-db-status');
if (statusEl) {
statusEl.textContent = 'DB 연결됨';
statusEl.style.color = '#4caf50';
}
const btnEl = document.getElementById('btn-fast-db-connect');
if (btnEl) btnEl.style.display = 'none';
const list = document.getElementById('fast-session-list');
list.innerHTML = '';
if (!data.items || data.items.length === 0) {
list.innerHTML = '<span style="color:var(--t3);font-size:12px;padding:4px 0">세션이 없습니다. + 신규를 눌러 시작하세요.</span>';
return;
}
const statusColor = {
Running: '#4caf50', Completed: '#4363d8',
Cancelled: '#888', Failed: '#e55',
RowLimitReached: '#f58231', Pending: '#aaa'
};
const statusLabel = {
Running: '실행중', Completed: '완료', Cancelled: '취소',
Failed: '실패', RowLimitReached: '행제한', Pending: '대기'
};
data.items.forEach(s => {
const isActive = s.id === fastCurrentSessionId;
const dot = statusColor[s.status] ?? '#aaa';
const label = statusLabel[s.status] ?? s.status;
const chip = document.createElement('div');
chip.dataset.id = s.id;
chip.style.cssText = [
'display:flex;flex-direction:column;gap:3px',
'padding:7px 11px;border-radius:var(--r)',
`background:${isActive ? 'rgba(229,85,85,.12)' : 'var(--s3)'}`,
`border:1px solid ${isActive ? 'var(--red,#e55)' : 'var(--bd)'}`,
'cursor:pointer;min-width:120px;max-width:200px',
'transition:border-color .15s,background .15s'
].join(';');
chip.innerHTML = `
<div style="display:flex;align-items:center;gap:5px">
<span style="width:8px;height:8px;border-radius:50%;background:${dot};flex-shrink:0"></span>
<span style="font-weight:600;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${esc(s.name)}">${esc(s.name)}</span>
${s.pinned ? '<span style="font-size:11px">📌</span>' : ''}
<button data-del="${s.id}" title="삭제" style="margin-left:2px;background:none;border:none;color:var(--t2);cursor:pointer;font-size:13px;line-height:1;padding:0 2px;flex-shrink:0">×</button>
</div>
<div style="font-size:11px;color:var(--t2)">${label} · ${s.tagCount}태그 · ${s.samplingMs}ms</div>
<div style="font-size:10px;color:var(--t3)">${fastFormatDuration(s.durationSec)} · ${fastFormatDateTime(s.startedAt).slice(0,10)}</div>
`;
chip.querySelector('[data-del]').addEventListener('click', e => {
e.stopPropagation();
fastDelete(s.id);
});
chip.onclick = () => fastSelect(s.id);
list.appendChild(chip);
});
}
async function fastStart() {
const name = document.getElementById('fast-session-name').value.trim();
if (!name) { alert('세션 이름을 입력하세요.'); return; }
const select = document.getElementById('fast-tag-select');
const tags = Array.from(select.selectedOptions).map(o => o.value);
if (tags.length === 0) { alert('태그를 최소 1개 이상 선택하세요.'); return; }
if (tags.length > 8) { alert('태그는 최대 8개까지 선택 가능합니다.'); return; }
const samplingMs = parseInt(document.getElementById('fast-sampling-ms').value);
const durationSec = parseInt(document.getElementById('fast-duration-sec').value);
const retVal = document.getElementById('fast-retention-days').value.trim();
const retentionDays = retVal ? parseInt(retVal) : null;
const res = await fetch('/api/fast/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, samplingMs, durationSec, tagList: tags, retentionDays })
});
if (!res.ok) {
const err = await res.json();
alert('오류: ' + (err.error ?? '알 수 없는 오류'));
return;
}
const data = await res.json();
fastModalClose();
await fastSessionsLoad();
fastSelect(data.id);
}
async function fastStop(id) {
if (!confirm('세션을 중지하시겠습니까?')) return;
const res = await fetch(`/api/fast/${id}/stop`, { method: 'POST' });
if (!res.ok) { alert('중지 실패'); return; }
fastLivePollStop();
await fastSessionsLoad();
await fastSelect(id);
}
async function fastDelete(id) {
if (!confirm('세션과 수집 데이터를 삭제하시겠습니까?')) return;
const res = await fetch(`/api/fast/${id}`, { method: 'DELETE' });
if (!res.ok) { alert('삭제 실패'); return; }
fastLivePollStop();
fastCurrentSessionId = null;
fastClearChart();
document.getElementById('fast-session-title').textContent = '세션 상세';
['btn-fast-stop','btn-fast-export-xlsx','btn-fast-export-csv','btn-fast-delete','btn-fast-pin']
.forEach(btnId => document.getElementById(btnId).style.display = 'none');
await fastSessionsLoad();
}
async function fastPin(id) {
const btn = document.getElementById('btn-fast-pin');
const pinned = btn.textContent.trim() === '고정';
const res = await fetch(`/api/fast/${id}/pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned })
});
if (!res.ok) { alert('고정 변경 실패'); return; }
await fastSessionsLoad();
await fastSelect(id);
}
async function fastSelect(id) {
fastCurrentSessionId = id;
// 칩 active 스타일 즉시 갱신
document.querySelectorAll('#fast-session-list > div').forEach(chip => {
const isActive = parseInt(chip.dataset.id) === id;
chip.style.background = isActive ? 'rgba(229,85,85,.12)' : 'var(--s3)';
chip.style.borderColor = isActive ? 'var(--red,#e55)' : 'var(--bd)';
});
const res = await fetch(`/api/fast/${id}`);
if (!res.ok) { alert('세션 조회 실패'); return; }
const session = await res.json();
document.getElementById('fast-session-title').textContent = `${session.name} (${session.status})`;
const isRunning = session.status === 'Running';
const isFinished = !isRunning;
document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline-block' : 'none';
document.getElementById('btn-fast-export-xlsx').style.display = isFinished ? 'inline-block' : 'none';
document.getElementById('btn-fast-export-csv').style.display = isFinished ? 'inline-block' : 'none';
document.getElementById('btn-fast-delete').style.display = 'inline-block';
document.getElementById('btn-fast-pin').style.display = 'inline-block';
document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정';
await fastRenderChart();
await fastUpdateProgress(session);
if (isRunning) fastLivePollStart();
else fastLivePollStop();
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 차트
// ═══════════════════════════════════════════════════════════════
async function fastRenderChart() {
if (!fastCurrentSessionId) return;
const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`);
if (!res.ok) return;
const data = await res.json();
const container = document.getElementById('fast-chart-container');
if (!data.items || data.items.length === 0) {
container.innerHTML = '<div class="text-center text-muted pt-5">수집된 데이터가 없습니다.</div>';
return;
}
// Long 포맷 → PIVOT (recorded_at 기준 그룹화)
const grouped = {};
for (const r of data.items) {
if (!grouped[r.recordedAt]) grouped[r.recordedAt] = {};
grouped[r.recordedAt][r.tagName] = parseFloat(r.value) || null;
}
const times = Object.keys(grouped).sort();
const timesNum = times.map(t => new Date(t).getTime() / 1000); // uPlot: Unix seconds
// uPlot data: [[x...], [y1...], [y2...], ...]
const uData = [timesNum, ...data.tagNames.map(tag => times.map(t => grouped[t][tag] ?? null))];
// 동일 태그 구성이면 setData()로 인플레이스 업데이트 → zoom/pan 상태 유지
const tagsKey = data.tagNames.join('\0');
if (fastChart && fastChartTagNames === tagsKey) {
fastChart.setData(uData, false); // false = 스케일 유지 (zoom 보존)
return;
}
// 최초 생성 또는 태그 구성 변경 시 차트 재생성
fastChartTagNames = tagsKey;
fastClearChart();
const opts = {
title: 'fastRecord 트렌드',
width: container.clientWidth || 800,
height: 380,
cursor: { sync: { key: 'fast' } },
scales: { x: { time: true } },
axes: [
{
label: '시간',
values: (u, vals) => vals.map(v => new Date(v * 1000).toLocaleTimeString('ko-KR'))
},
{ label: '값' }
],
series: [
{},
...data.tagNames.map(tag => ({
label: tag,
stroke: fastTagColor(tag),
width: 2
}))
]
};
fastChart = new uPlot(opts, uData, container);
}
function fastClearChart() {
fastChartTagNames = null;
if (fastChart) {
fastChart.destroy();
fastChart = null;
}
document.getElementById('fast-chart-container').innerHTML = '';
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 라이브 폴링
// ═══════════════════════════════════════════════════════════════
function fastLivePollStart() {
if (fastLivePollTimer) return;
fastLivePollTimer = setInterval(async () => {
if (!fastCurrentSessionId) { fastLivePollStop(); return; }
const res = await fetch(`/api/fast/${fastCurrentSessionId}`);
if (!res.ok) return;
const session = await res.json();
await fastUpdateProgress(session);
await fastRenderChart();
if (session.status !== 'Running') {
fastLivePollStop();
await fastSelect(fastCurrentSessionId);
}
}, 2000);
}
function fastLivePollStop() {
if (fastLivePollTimer) {
clearInterval(fastLivePollTimer);
fastLivePollTimer = null;
}
}
async function fastUpdateProgress(session) {
const elapsed = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000);
const progress = Math.min((elapsed / session.durationSec) * 100, 100);
document.getElementById('fast-progress-bar').style.width = `${progress}%`;
const expectedRows = Math.floor(elapsed / (session.samplingMs / 1000)) * session.tagList?.length ?? 0;
document.getElementById('fast-progress-text').textContent =
`${session.rowCount.toLocaleString()} / ~${expectedRows.toLocaleString()} (${progress.toFixed(1)}%)`;
document.getElementById('fast-elapsed-time').textContent =
`경과: ${fastFormatDuration(Math.min(elapsed, session.durationSec))} / ${fastFormatDuration(session.durationSec)}`;
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 유틸
// ═══════════════════════════════════════════════════════════════
function fastFormatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function fastFormatDateTime(dt) {
return new Date(dt).toLocaleString('ko-KR');
}
function fastTagColor(tag) {
const palette = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4',
'#42d4f4','#f032e6','#bfef45','#469990','#fabed4'];
let sum = 0;
for (let i = 0; i < tag.length; i++) sum += tag.charCodeAt(i);
return palette[sum % palette.length];
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 이벤트 리스너
// ═══════════════════════════════════════════════════════════════
paneInit.fast = function() {
fastSessionsLoad();
const bind = (id, fn) => {
const el = document.getElementById(id);
if (el && !el.dataset.fastBound) { el.addEventListener('click', fn); el.dataset.fastBound = '1'; }
};
// DB 접속 버튼
bind('btn-fast-db-connect', fastDbConnect);
// + 신규 버튼 — DB 연결 확인 후 모달 열기
bind('btn-fast-new', async () => {
if (!fastDbConnected) {
alert('데이터베이스 접속을 먼저 완료해주세요. "DB 접속" 버튼을 눌러주세요.');
return;
}
const select = document.getElementById('fast-tag-select');
select.innerHTML = '<option disabled>로딩 중...</option>';
document.getElementById('fast-session-name').value = '';
document.getElementById('fast-retention-days').value = '';
document.getElementById('modal-fast-new').style.display = 'flex';
try {
const res = await fetch('/api/pointbuilder/points');
if (res.ok) {
const data = await res.json();
select.innerHTML = '';
(data.items || []).forEach(p => {
const opt = document.createElement('option');
opt.value = p.tagName || p.TagName;
opt.textContent = p.tagName || p.TagName;
select.appendChild(opt);
});
} else {
select.innerHTML = '<option disabled>태그 목록 로드 실패</option>';
}
} catch (e) {
console.error('[fast] 태그 목록 로드 실패:', e);
select.innerHTML = '<option disabled>태그 목록 로드 실패</option>';
}
});
// btn-fast-start: onclick="fastStart()" 으로 HTML에서 직접 처리
bind('btn-fast-stop', () => {
if (fastCurrentSessionId) fastStop(fastCurrentSessionId);
});
bind('btn-fast-delete', () => {
if (fastCurrentSessionId) fastDelete(fastCurrentSessionId);
});
bind('btn-fast-pin', () => {
if (fastCurrentSessionId) fastPin(fastCurrentSessionId);
});
// Excel Export
bind('btn-fast-export-xlsx', async () => {
if (!fastCurrentSessionId) return;
const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`);
if (!res.ok) return;
const data = await res.json();
// Long → Wide (배열의 배열 형식으로 XLSX.utils.aoa_to_sheet에 전달)
const timeMap = {};
for (const r of data.items) {
if (!timeMap[r.recordedAt]) timeMap[r.recordedAt] = {};
timeMap[r.recordedAt][r.tagName] = r.value;
}
const rows = [['recorded_at', ...data.tagNames]];
for (const t of Object.keys(timeMap).sort()) {
rows.push([new Date(t).toLocaleString('ko-KR'),
...data.tagNames.map(tag => timeMap[t][tag] ?? '')]);
}
const ws = XLSX.utils.aoa_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'fastRecord');
XLSX.writeFile(wb, `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.xlsx`);
});
// CSV Export (서버 스트리밍)
bind('btn-fast-export-csv', async () => {
if (!fastCurrentSessionId) return;
const res = await fetch(`/api/fast/${fastCurrentSessionId}/csv`);
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
});
// 탭 전환 시 세션 목록 갱신
document.querySelectorAll('[href="#pane-fast"]').forEach(a => {
if (!a.dataset.fastTabBound) {
a.addEventListener('show.bs.tab', () => fastSessionsLoad());
a.dataset.fastTabBound = '1';
}
});
};

385
src/Web/wwwroot/js/hist.js Normal file
View File

@@ -0,0 +1,385 @@
/* ─────────────────────────────────────────────────────────────
07 이력 조회
───────────────────────────────────────────────────────────── */
const HIST_TAG_IDS = ['hf-t1','hf-t2','hf-t3','hf-t4','hf-t5','hf-t6','hf-t7','hf-t8'];
// 상태 표시기 업데이트 함수
function histUpdateStatus(state, message) {
const el = document.getElementById('hist-load-status');
if (!el) return;
// 상태 클래스 제거 후 재설정
el.classList.remove('loading', 'success', 'error');
if (state) {
el.classList.add(state);
}
// 아이콘/텍스트 업데이트 - .status-dot span은 유지
const icons = { loading: '⏳', success: '✅', error: '❌' };
const icon = icons[state] || '';
// .status-dot span 찾기
const dot = el.querySelector('.status-dot');
// 텍스트 노드 업데이트 (첫 번째 텍스트 노드)
const textNode = Array.from(el.childNodes).find(n => n.nodeType === Node.TEXT_NODE);
if (textNode) {
textNode.textContent = icon ? `${icon} ${message}` : message;
}
}
// "▼ 옵션 불러오기" 버튼 클릭 시에만 호출 — 탭 진입 시 자동 호출 없음
async function histLoad() {
const el = document.getElementById('hist-load-status');
if (!el) return;
// 로딩 상태 표시
histUpdateStatus('loading', '조회 중...');
try {
const startTime = Date.now();
const d = await api('GET', '/api/history/tagnames');
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const tagNames = d.tagNames || [];
if (tagNames.length === 0) {
histUpdateStatus('error', '조회 데이터 없음 (0개)');
} else {
const opts = '<option value="">— 선택 안 함 —</option>' +
tagNames.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
// 기존 선택값 보존
const selectedValues = HIST_TAG_IDS.map(id => document.getElementById(id).value);
// DOM 업데이트를 requestAnimationFrame으로 지연하여 레이아웃 충돌 방지
requestAnimationFrame(() => {
HIST_TAG_IDS.forEach((id, index) => {
const sel = document.getElementById(id);
sel.innerHTML = opts;
if (selectedValues[index]) sel.value = selectedValues[index];
});
});
histUpdateStatus('success', `옵션 조회 완료 (${tagNames.length}개, ${elapsed}초)`);
}
} catch (e) {
console.error('histLoad:', e);
histUpdateStatus('error', `조회 실패: ${e.message}`);
}
}
async function histQuery() {
const tags = HIST_TAG_IDS.map(id => document.getElementById(id).value).filter(Boolean);
const from = document.getElementById('hf-from').value;
const to = document.getElementById('hf-to').value;
const interval = document.getElementById('hf-interval').value || '5 minutes';
const limit = parseInt(document.getElementById('hf-limit').value) || 500;
if (!tags.length) {
histShowStatus('err', '❌', '태그를 최소 1개 선택하세요', ' 태그 선택란에서 태그를 선택해주세요.');
return;
}
// 간격이 기본값(60초)과 다른 경우 시간 간격 조회 API 사용
if (interval === '1 minute') {
// 기본 간격(1분) 이하인 경우 기존 API 사용
const params = new URLSearchParams();
tags.forEach(t => params.append('tagNames', t));
if (from) params.set('from', new Date(from).toISOString());
if (to) params.set('to', new Date(to).toISOString());
params.set('limit', limit);
// 상태 표시: 조회 시작
histShowStatus('busy', '⏳', '조회 중...', ` ${tags.length}개 태그, 제한: ${limit}`);
try {
const d = await api('GET', `/api/history/query?${params}`);
const rows = d.rows || [];
const tNames = d.tagNames || [];
renderHistoryTable(rows, tNames, interval);
} catch (e) {
histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`);
setGlobal('err', '조회 실패');
console.error('histQuery 오류:', e);
}
} else {
// 사용자 지정 간격인 경우 시간 간격 조회 API 사용
const body = {
tagNames: tags,
from: from ? new Date(from).toISOString() : null,
to: to ? new Date(to).toISOString() : null,
interval: interval,
limit: limit
};
// 상태 표시: 조회 시작
histShowStatus('busy', '⏳', '조회 중...', ` ${tags.length}개 태그, 간격: ${interval}, 제한: ${limit}`);
try {
const d = await api('POST', '/api/text-to-sql/query-history-interval', body);
if (!d.success) {
throw new Error(d.error || '조회 실패');
}
const rows = d.rows || [];
const tNames = d.tagNames || [];
renderHistoryTable(rows, tNames, interval, d.baseIntervalSeconds, d.queryInterval);
} catch (e) {
histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`);
setGlobal('err', '조회 실패');
console.error('histQuery 오류:', e);
}
}
}
/**
* 이력 조회 결과 테이블 렌더링
*/
function renderHistoryTable(rows, tNames, interval, baseIntervalSeconds, queryInterval) {
const tbl = document.getElementById('hist-table');
tbl.classList.remove('hidden');
const info = document.getElementById('hist-result-info');
info.classList.remove('hidden');
let infoText = `${rows.length.toLocaleString()}× ${tNames.length}개 태그`;
if (queryInterval) {
infoText += ` | 집계 간격: ${queryInterval}`;
}
info.textContent = infoText;
if (!rows.length) {
histShowStatus('ok', '✅', '조회 완료 (0건)', ` 조건에 맞는 데이터가 없습니다. | 태그: ${tNames.join(', ') || '전체'}`);
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 이력이 없습니다.</div>';
setGlobal('ok', '0건');
return;
}
// 시간 간격 조회인 경우 TimeBucket 열 사용
const timeColumn = rows[0].timeBucket ? 'timeBucket' : 'recordedAt';
tbl.innerHTML = `
<table>
<thead>
<tr>
<th>${queryInterval ? '집계 시각' : '시각'}</th>
${tNames.map(t => `<th>${esc(t.toUpperCase())}</th>`).join('')}
</tr>
</thead>
<tbody>
${rows.map(r => `
<tr>
<td class="mut" style="white-space:nowrap">${fmtTs(r[timeColumn])}</td>
${tNames.map(t => {
const raw = r.values?.[t] ?? null;
const display = raw != null ? esc(String(fmtVal(raw))) : '<span style="color:var(--t3)">—</span>';
return `<td class="val">${display}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
`;
let detailText = ` 태그: ${tNames.join(', ')}`;
if (queryInterval) {
detailText += ` | 집계 간격: ${queryInterval}`;
}
detailText += ` | 시각 범위: ${document.getElementById('hf-from').value || '전체'} ~ ${document.getElementById('hf-to').value || '전체'}`;
histShowStatus('ok', '✅', `조회 완료 (${rows.length}건)`, detailText);
setGlobal('ok', `${rows.length}`);
}
/**
* 이력 조회 상태 표시 창 업데이트
* @param {string} state - 'pending' | 'busy' | 'ok' | 'err'
* @param {string} icon - 표시할 아이콘
* @param {string} text - 메인 상태 텍스트
* @param {string} detail - 상세 설명
*/
function histShowStatus(state, icon, text, detail) {
const box = document.getElementById('hist-status-box');
const iconEl = document.getElementById('hist-status-icon');
const textEl = document.getElementById('hist-status-text');
const detEl = document.getElementById('hist-status-detail');
box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err');
box.classList.add(state);
iconEl.textContent = icon;
textEl.textContent = text;
detEl.textContent = detail || '';
}
function histReset() {
HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; });
dtClearField('from');
dtClearField('to');
document.getElementById('hf-interval').value = '1 minute';
document.getElementById('hf-limit').value = '500';
document.getElementById('hist-result-info').classList.add('hidden');
document.getElementById('hist-table').classList.add('hidden');
// 상태 표시 창 초기화
histShowStatus('pending', '⏸', '대기 중', '조회 조건을 설정하고 조회 버튼을 눌러주세요.');
}
/* ─────────────────────────────────────────────────────────────
07-2 하이퍼테이블 관리
───────────────────────────────────────────────────────────── */
/**
* 하이퍼테이블 상태 불러오기
*/
async function htLoadStatus() {
try {
const d = await api('GET', '/api/experion/hypertable/status');
// 상태 표시 창 업데이트
const box = document.getElementById('ht-status-box');
const iconEl = document.getElementById('ht-status-icon');
const textEl = document.getElementById('ht-status-text');
const detEl = document.getElementById('ht-status-detail');
box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err');
// 정보 패널 업데이트
const infoPanel = document.getElementById('ht-info-panel');
document.getElementById('ht-info-table').textContent = d.tableName || '-';
document.getElementById('ht-info-records').textContent = d.recordCount != null ? d.recordCount.toLocaleString() : '-';
document.getElementById('ht-info-retention').textContent = d.hasRetentionPolicy ? '설정됨' : '미설정';
document.getElementById('ht-info-compression').textContent = d.hasCompression ? '활성화됨' : '비활성화됨';
if (d.isHypertable) {
box.classList.add('ok');
iconEl.textContent = '✅';
textEl.textContent = '하이퍼테이블 활성화됨';
detEl.textContent = d.statusMessage || 'history_table이 하이퍼테이블로 변환되었습니다.';
infoPanel.classList.remove('hidden');
} else {
box.classList.add('pending');
iconEl.textContent = '⚠️';
textEl.textContent = '일반 테이블';
detEl.textContent = d.statusMessage || 'history_table이 아직 하이퍼테이블로 변환되지 않았습니다.';
infoPanel.classList.add('hidden');
}
} catch (e) {
console.error('htLoadStatus 오류:', e);
const box = document.getElementById('ht-status-box');
const iconEl = document.getElementById('ht-status-icon');
const textEl = document.getElementById('ht-status-text');
const detEl = document.getElementById('ht-status-detail');
box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err');
box.classList.add('err');
iconEl.textContent = '❌';
textEl.textContent = '상태 조회 실패';
detEl.textContent = e.message || '알 수 없는 오류';
document.getElementById('ht-info-panel').classList.add('hidden');
}
}
/**
* 하이퍼테이블 생성
*/
async function htCreate() {
const tableName = document.getElementById('ht-table-name').value || 'history_table';
const retentionEnabled = document.getElementById('ht-auto-retention').checked;
const compressionEnabled = document.getElementById('ht-auto-compression').checked;
const aggregateEnabled = document.getElementById('ht-auto-aggregate').checked;
// 생성 전 확인
const confirmMsg = retentionEnabled || compressionEnabled
? `다음 옵션으로 하이퍼테이블을 생성합니다:\n\n` +
`- 테이블명: ${tableName}\n` +
`- 보관 기간: ${retentionEnabled ? document.getElementById('ht-retention-period').value : '설정 안함'}\n` +
`- 압축: ${compressionEnabled ? '활성화 (' + document.getElementById('ht-compression-period').value + ')' : '비활성화'}\n` +
`- 연속 집계: ${aggregateEnabled ? '생성' : '생성 안함'}\n\n` +
`계속 진행하시겠습니까?`
: `기본 설정으로 하이퍼테이블을 생성합니다:\n\n` +
`- 테이블명: ${tableName}\n` +
`- 보관 기간: 설정 안함\n` +
`- 압축: 설정 안함\n\n` +
`계속 진행하시겠습니까?`;
if (!confirm(confirmMsg)) return;
try {
// 상태 표시 창을 로딩 모드로
const box = document.getElementById('ht-status-box');
const iconEl = document.getElementById('ht-status-icon');
const textEl = document.getElementById('ht-status-text');
const detEl = document.getElementById('ht-status-detail');
box.classList.remove('hidden', 'pending', 'ok', 'err');
box.classList.add('busy');
iconEl.textContent = '⏳';
textEl.textContent = '생성 중...';
detEl.textContent = '하이퍼테이블을 생성하고 있습니다. 잠시 기다려주세요.';
document.getElementById('ht-info-panel').classList.add('hidden');
const body = {
tableName: tableName,
timeColumn: 'recorded_at',
timeInterval: '1 day',
migrateData: true,
setRetentionPolicy: retentionEnabled,
retentionPeriod: retentionEnabled ? document.getElementById('ht-retention-period').value : undefined,
enableCompression: compressionEnabled,
compressionPeriod: compressionEnabled ? document.getElementById('ht-compression-period').value : undefined,
createContinuousAggregate: aggregateEnabled
};
// 불필요한 필드는 제거
Object.keys(body).forEach(key => {
if (body[key] === undefined) delete body[key];
});
await api('POST', '/api/experion/hypertable/create', body);
// 상태 표시: 성공
box.classList.remove('busy');
box.classList.add('ok');
iconEl.textContent = '✅';
textEl.textContent = '생성 완료';
detEl.textContent = '하이퍼테이블이 성공적으로 생성되었습니다. 상태 새로고침 버튼을 눌러주세요.';
// 상태 새로고침
setTimeout(() => htLoadStatus(), 500);
} catch (e) {
console.error('htCreate 오류:', e);
const box = document.getElementById('ht-status-box');
const iconEl = document.getElementById('ht-status-icon');
const textEl = document.getElementById('ht-status-text');
const detEl = document.getElementById('ht-status-detail');
box.classList.remove('busy', 'pending', 'ok', 'err');
box.classList.add('err');
iconEl.textContent = '❌';
textEl.textContent = '생성 실패';
detEl.textContent = e.message || '알 수 없는 오류';
}
}
/**
* 보관 기간 설정 토글
*/
function htToggleRetention() {
const checked = document.getElementById('ht-auto-retention').checked;
const panel = document.getElementById('ht-retention-panel');
panel.classList.toggle('ht-hidden', !checked);
}
/**
* 압축 설정 토글
*/
function htToggleCompression() {
const checked = document.getElementById('ht-auto-compression').checked;
const panel = document.getElementById('ht-compression-panel');
panel.classList.toggle('ht-hidden', !checked);
}

View File

@@ -0,0 +1,397 @@
/* ═══════════════════════════════════════════════════════
14 RAG 관리 (KB Admin)
══════════════════════════════════════════════════════ */
let kbToken = sessionStorage.getItem('kbToken') || '';
let kbExpiresAt = sessionStorage.getItem('kbExpiresAt') || '';
let kbCollections = [];
let kbPollTimer = null;
function kbHeaders(extra) {
const h = { ...(extra || {}) };
if (kbToken) h['X-Kb-Token'] = kbToken;
return h;
}
async function kbFetch(method, path, body, opt) {
const init = { method, headers: kbHeaders({ 'Content-Type': 'application/json' }), ...(opt || {}) };
if (body !== undefined && body !== null) init.body = JSON.stringify(body);
const res = await fetch(path, init);
let data = null;
try { data = await res.json(); } catch { /* ignore */ }
return { ok: res.ok, status: res.status, data };
}
// ── 탭 진입 시 세션 검증 ─────────────────────────────
paneInit.kbadmin = async function() {
if (kbToken) {
const r = await kbFetch('GET', '/api/kb/auth/status');
if (r.ok && r.data && r.data.valid) {
kbShowMain();
} else {
kbShowLogin('세션이 만료되었습니다. 다시 로그인하세요.');
}
} else {
kbShowLogin('');
}
};
function kbShowLogin(msg) {
kbToken = ''; kbExpiresAt = '';
sessionStorage.removeItem('kbToken'); sessionStorage.removeItem('kbExpiresAt');
document.getElementById('kb-login-card').classList.remove('hidden');
document.getElementById('kb-main').classList.add('hidden');
const m = document.getElementById('kb-login-msg');
if (m) m.textContent = msg || '';
kbStopPoll();
}
function kbShowMain() {
document.getElementById('kb-login-card').classList.add('hidden');
document.getElementById('kb-main').classList.remove('hidden');
kbUpdateSessionInfo();
kbLoadCollections().then(() => kbRefresh());
kbStartPoll();
}
function kbUpdateSessionInfo() {
const el = document.getElementById('kb-session-info');
if (!el) return;
if (kbExpiresAt) {
const t = new Date(kbExpiresAt);
el.textContent = `세션 만료: ${t.toLocaleTimeString('ko-KR')}`;
} else {
el.textContent = '세션: --:--';
}
}
async function kbLogin() {
const pw = document.getElementById('kb-pw').value;
const msg = document.getElementById('kb-login-msg');
if (!pw) { msg.textContent = '비밀번호를 입력하세요.'; return; }
msg.textContent = '로그인 중...';
const r = await fetch('/api/kb/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.success) {
msg.textContent = '❌ ' + (data.error || '로그인 실패');
return;
}
kbToken = data.token;
kbExpiresAt = data.expiresAt;
sessionStorage.setItem('kbToken', kbToken);
sessionStorage.setItem('kbExpiresAt', kbExpiresAt || '');
document.getElementById('kb-pw').value = '';
msg.textContent = '';
kbShowMain();
}
async function kbLogout() {
if (kbToken) await kbFetch('POST', '/api/kb/auth/logout');
kbShowLogin('로그아웃되었습니다.');
}
// ── 컬렉션 ─────────────────────────────────────────────
async function kbLoadCollections() {
const r = await kbFetch('GET', '/api/kb/collections');
if (!r.ok || !r.data || !r.data.success) return;
kbCollections = r.data.items || [];
const fSel = document.getElementById('kb-f-coll');
const uSel = document.getElementById('kb-up-coll');
fSel.innerHTML = '<option value="">전체</option>' +
kbCollections.map(c => `<option value="${c.key}">${c.name}</option>`).join('');
uSel.innerHTML = '<option value="">-- 선택 --</option>' +
kbCollections.map(c => `<option value="${c.key}">${c.name}</option>`).join('');
}
// ── 목록 ───────────────────────────────────────────────
async function kbRefresh() {
const coll = document.getElementById('kb-f-coll').value;
const status = document.getElementById('kb-f-status').value;
const q = document.getElementById('kb-f-q').value.trim();
const qs = new URLSearchParams();
if (coll) qs.set('collection', coll);
if (status) qs.set('status', status);
if (q) qs.set('q', q);
qs.set('pageSize', '200');
const r = await kbFetch('GET', '/api/kb/documents?' + qs.toString());
if (!r.ok || !r.data || !r.data.success) {
document.getElementById('kb-doc-table').innerHTML = '<div class="placeholder">조회 실패</div>';
return;
}
kbRenderDocs(r.data.items, r.data.total);
}
function kbRenderDocs(items, total) {
const stats = document.getElementById('kb-doc-stats');
stats.textContent = `${total}`;
const tbl = document.getElementById('kb-doc-table');
if (!items || items.length === 0) {
tbl.innerHTML = '<div class="placeholder">문서 없음</div>';
return;
}
const collMap = Object.fromEntries(kbCollections.map(c => [c.key, c.name]));
const rows = items.map(d => {
const tags = (d.tags || []).map(t => `<span class="kb-tag">${t}</span>`).join(' ');
const dt = d.uploadedAt ? new Date(d.uploadedAt).toLocaleString('ko-KR') : '';
const size = d.fileSize ? kbFmtSize(d.fileSize) : '';
return `<tr>
<td class="mono">${kbShortId(d.id)}</td>
<td>${kbEscape(d.title)}</td>
<td>${collMap[d.collection] || d.collection}</td>
<td>${tags}</td>
<td class="mono">${size}</td>
<td><span class="kb-status kb-st-${d.status}">${d.status}</span>${d.errorMessage ? `<div class="kb-err" title="${kbEscape(d.errorMessage)}">${kbEscape(d.errorMessage.slice(0,60))}…</div>`:''}</td>
<td class="mono">${d.chunkCount || 0}</td>
<td class="mono">${dt}</td>
<td>
<button class="btn-b btn-sm" onclick="kbDownload('${d.id}')">⬇</button>
${(d.chunkCount || 0) > 0 ? `<button class="btn-b btn-sm" onclick="kbShowChunks('${d.id}','${kbEscape(d.title)}')">🔍</button>` : ''}
<button class="btn-b btn-sm" onclick="kbReindex('${d.id}')">↻</button>
<button class="btn-b btn-sm" onclick="kbDisable('${d.id}')">🚫</button>
<button class="btn-b btn-sm" onclick="kbDelete('${d.id}','${kbEscape(d.title)}')">✖</button>
</td>
</tr>`;
}).join('');
tbl.innerHTML = `<table class="tbl kb-doc-tbl"><thead><tr>
<th>ID</th><th>제목</th><th>컬렉션</th><th>태그</th><th>크기</th>
<th>상태</th><th>청크</th><th>업로드</th><th>액션</th>
</tr></thead><tbody>${rows}</tbody></table>`;
}
function kbShortId(id) { return (id || '').replace(/-/g, '').slice(0, 8); }
function kbEscape(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]); }
function kbFmtSize(n) {
if (n < 1024) return n + 'B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + 'K';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + 'M';
return (n / 1024 / 1024 / 1024).toFixed(2) + 'G';
}
// ── 업로드 모달 ────────────────────────────────────────
function kbUploadOpen() {
document.getElementById('kb-up-msg').textContent = '';
document.getElementById('kb-up-title').value = '';
document.getElementById('kb-up-tags').value = '';
document.getElementById('kb-up-file').value = '';
document.getElementById('kb-upload-modal').classList.remove('hidden');
}
function kbUploadClose() {
document.getElementById('kb-upload-modal').classList.add('hidden');
}
async function kbUploadSubmit() {
const coll = document.getElementById('kb-up-coll').value;
const title = document.getElementById('kb-up-title').value.trim();
const tags = document.getElementById('kb-up-tags').value.trim();
const fileInput = document.getElementById('kb-up-file');
const msg = document.getElementById('kb-up-msg');
if (!coll) { msg.textContent = '❌ 컬렉션을 선택하세요.'; return; }
if (!fileInput.files || fileInput.files.length === 0) { msg.textContent = '❌ 파일을 선택하세요.'; return; }
const fd = new FormData();
fd.append('file', fileInput.files[0]);
fd.append('collectionKey', coll);
if (title) fd.append('title', title);
if (tags) fd.append('tags', tags);
msg.textContent = '업로드 중...';
const r = await fetch('/api/kb/upload', { method: 'POST', headers: kbHeaders(), body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.success) {
msg.textContent = '❌ ' + (data.error || ('HTTP ' + r.status));
return;
}
msg.textContent = '✅ 업로드 완료. 인덱싱 진행 중...';
setTimeout(() => kbUploadClose(), 600);
kbRefresh();
}
// ── 청크 미리보기 ─────────────────────────────────────
async function kbShowChunks(id, title) {
const modal = document.getElementById('kb-chunk-modal');
const titleEl = document.getElementById('kb-chunk-title');
const body = document.getElementById('kb-chunk-body');
titleEl.textContent = `🔍 청크 미리보기 — ${title || ''}`;
body.innerHTML = '<div class="placeholder">불러오는 중...</div>';
modal.classList.remove('hidden');
const r = await kbFetch('GET', '/api/kb/documents/' + id + '/chunks?limit=200');
if (!r.ok || !r.data || !r.data.success) {
body.innerHTML = '<div class="placeholder">조회 실패: ' + ((r.data && r.data.error) || r.status) + '</div>';
return;
}
kbRenderChunks(r.data.chunks || []);
}
function kbChunkCloseModal() {
document.getElementById('kb-chunk-modal').classList.add('hidden');
}
function kbRenderChunks(chunks) {
const body = document.getElementById('kb-chunk-body');
if (!chunks.length) {
body.innerHTML = '<div class="placeholder">청크 없음</div>';
return;
}
body.innerHTML = `<div class="kb-chunk-stat">총 ${chunks.length}개 청크</div>` +
chunks.map((c, i) => {
const kind = c.chunk_kind || 'text';
const loc = c.locator
|| [c.sheet, c.page ? 'p' + c.page : '', c.heading_path].filter(Boolean).join(' · ');
const text = c.text || '';
const preview = text.length > 240 ? text.slice(0, 240) + '…' : text;
return `<div class="kb-chunk-card">
<div class="kb-chunk-head" onclick="this.parentElement.classList.toggle('open')">
<span class="kb-chunk-badge kb-chunk-kind-${kbEscape(kind)}">${kbEscape(kind)}</span>
<span class="kb-chunk-locator">${kbEscape(loc) || '#' + (i + 1)}</span>
<span class="kb-chunk-len">${text.length}자</span>
</div>
<div class="kb-chunk-preview">${kbEscape(preview)}</div>
<div class="kb-chunk-full"><pre>${kbEscape(text)}</pre></div>
</div>`;
}).join('');
}
// ── 액션 ───────────────────────────────────────────────
function kbDownload(id) {
window.open('/api/kb/download/' + id, '_blank');
}
async function kbReindex(id) {
if (!confirm('재인덱싱하시겠습니까? (Qdrant 기존 청크 삭제 후 다시 처리)')) return;
const r = await kbFetch('POST', '/api/kb/documents/' + id + '/reindex');
if (!r.ok) alert('실패: ' + (r.data && r.data.error ? r.data.error : r.status));
kbRefresh();
}
async function kbDisable(id) {
if (!confirm('이 문서를 비활성화하시겠습니까?')) return;
const r = await kbFetch('POST', '/api/kb/documents/' + id + '/disable');
if (!r.ok) alert('실패');
kbRefresh();
}
async function kbDelete(id, title) {
if (!confirm(`삭제하시겠습니까?\n${title}\n(Qdrant 청크와 원본 파일도 함께 삭제됩니다)`)) return;
const r = await kbFetch('DELETE', '/api/kb/documents/' + id);
if (!r.ok) alert('실패');
kbRefresh();
}
async function kbBulkDisable() {
const title = prompt('일괄 비활성화할 제목을 정확히 입력하세요:');
if (!title) return;
const r = await kbFetch('POST', '/api/kb/documents/bulk-disable', { title });
if (r.ok && r.data && r.data.success) alert(`${r.data.affected}건 비활성화 완료`);
else alert('실패');
kbRefresh();
}
async function kbPurgeDisabled() {
const ds = prompt('비활성화 후 며칠 지난 문서를 영구삭제할까요? (공백이면 모든 disabled 삭제)', '90');
let body = {};
if (ds && ds.trim()) {
const n = parseInt(ds, 10);
if (isNaN(n) || n < 0) { alert('숫자를 입력하세요.'); return; }
body.olderThanDays = n;
}
if (!confirm('정말 영구삭제하시겠습니까? (되돌릴 수 없습니다)')) return;
const r = await kbFetch('POST', '/api/kb/documents/purge-disabled', body);
if (r.ok && r.data && r.data.success) alert(`${r.data.deleted}건 영구삭제 완료`);
else alert('실패');
kbRefresh();
}
// ── 비밀번호 변경 ──────────────────────────────────────
function kbChangePwOpen() {
document.getElementById('kb-pw-old').value = '';
document.getElementById('kb-pw-new').value = '';
document.getElementById('kb-pw-msg').textContent = '';
document.getElementById('kb-pw-modal').classList.remove('hidden');
}
function kbChangePwClose() {
document.getElementById('kb-pw-modal').classList.add('hidden');
}
async function kbChangePwSubmit() {
const oldPw = document.getElementById('kb-pw-old').value;
const newPw = document.getElementById('kb-pw-new').value;
const msg = document.getElementById('kb-pw-msg');
if (!oldPw || !newPw) { msg.textContent = '❌ 비밀번호를 입력하세요.'; return; }
if (newPw.length < 6) { msg.textContent = '❌ 새 비밀번호는 6자 이상.'; return; }
const r = await kbFetch('POST', '/api/kb/auth/change-password', { oldPassword: oldPw, newPassword: newPw });
if (r.ok && r.data && r.data.success) {
msg.textContent = '✅ 변경 완료. 다시 로그인해 주세요.';
setTimeout(() => { kbChangePwClose(); kbLogout(); }, 800);
} else {
msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : '변경 실패');
}
}
// ── 진행률 폴링 (활성 ingest가 있으면 1초마다 새로고침) ─
function kbStartPoll() {
kbStopPoll();
kbPollTimer = setInterval(async () => {
if (!kbToken) return;
if (document.getElementById('pane-kbadmin').classList.contains('active') === false) return;
const r = await kbFetch('GET', '/api/kb/documents?status=parsing&pageSize=1');
const r2 = await kbFetch('GET', '/api/kb/documents?status=embedding&pageSize=1');
const r3 = await kbFetch('GET', '/api/kb/documents?status=pending&pageSize=1');
const active = [r, r2, r3].some(x => x.ok && x.data && x.data.total > 0);
if (active) kbRefresh();
}, 1500);
}
function kbStopPoll() {
if (kbPollTimer) { clearInterval(kbPollTimer); kbPollTimer = null; }
}
// ── Field Instrument Inference ──────────────────────────────
function kbInferDownload(path) {
const encoded = encodeURIComponent(path);
window.open('/api/kb/instruments/download?path=' + encoded, '_blank');
}
async function kbInferStart() {
const btn = document.getElementById('kb-infer-btn');
const msg = document.getElementById('kb-infer-msg');
const result = document.getElementById('kb-infer-result');
const useLlm = document.getElementById('kb-infer-llm').checked;
btn.disabled = true;
btn.textContent = '⏳ 생성 중...';
msg.textContent = '계기 유추 및 Excel 생성 중 (수십 초 소요)';
result.classList.add('hidden');
try {
const r = await kbFetch('POST', '/api/kb/instruments/infer', {
useLlm,
seedDocId: ''
});
if (!r.ok || !r.data || !r.data.success) {
msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : 'HTTP ' + r.status);
return;
}
const d = r.data;
msg.textContent = '✅ 생성 완료. Excel 다운로드 후 수동으로 KB에 업로드하세요.';
result.classList.remove('hidden');
result.innerHTML = `
<div class="kb-infer-stats">
<span class="kb-stat">계기: ${d.instrumentCount}개</span>
<span class="kb-stat">동력기기: ${d.powerEquipmentCount || 0}개</span>
<span class="kb-stat">미매칭: ${d.unmatchedCount}개</span>
</div>
<p>${d.message || ''}</p>
<div class="btn-row">
<button class="btn-b btn-sm" onclick="kbInferDownload('${d.downloadPath}')">⬇ Excel 다운로드</button>
</div>
`;
} catch (e) {
msg.textContent = '❌ ' + e.message;
} finally {
btn.disabled = false;
btn.textContent = '⚡ 초안 생성';
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
/* ─────────────────────────────────────────────────────────────
05 노드맵 대시보드
───────────────────────────────────────────────────────────── */
let _nmOffset = 0;
let _nmTotal = 0;
let _nmLimit = 100;
// 이름 드롭다운 옵션만 필요할 때 수동으로 불러오는 함수 (버튼 클릭 시)
async function nmLoadNames() {
try {
const n = await api('GET', '/api/nodemap/names');
const nameOpts = '<option value="">— 선택 안 함 —</option>' +
(n.names||[]).map(nm => `<option value="${esc(nm)}">${esc(nm)}</option>`).join('');
['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;
});
} catch (e) { console.error('nmLoadNames:', 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.trim();
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 = '';
});
// 초기화 후 자동 조회 없음
document.getElementById('nm-result-bar').classList.add('hidden');
document.getElementById('nm-table').classList.add('hidden');
}

View File

@@ -0,0 +1,85 @@
/* ── OPC UA 서버 ─────────────────────────────────────────────── */
paneInit.opcsvr = srvLoad;
let _srvPollTimer = null;
async function srvLoad() {
try {
const s = await api('GET', '/api/opcserver/status');
_srvRender(s);
} catch (e) {
srvLog([{ c:'err', t:`상태 조회 실패: ${e.message}` }]);
}
}
async function srvStart() {
srvLog([{ c:'info', t:'OPC UA 서버 시작 중...' }]);
try {
const r = await api('POST', '/api/opcserver/start');
srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]);
await srvLoad();
_srvStartPoll();
} catch (e) {
srvLog([{ c:'err', t:`시작 실패: ${e.message}` }]);
}
}
async function srvStop() {
srvLog([{ c:'info', t:'OPC UA 서버 중지 중...' }]);
try {
const r = await api('POST', '/api/opcserver/stop');
srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]);
_srvStopPoll();
await srvLoad();
} catch (e) {
srvLog([{ c:'err', t:`중지 실패: ${e.message}` }]);
}
}
async function srvRebuild() {
srvLog([{ c:'info', t:'주소 공간 재구성 중...' }]);
try {
const r = await api('POST', '/api/opcserver/rebuild');
srvLog([{ c: r.success ? 'ok' : 'err', t: `재구성 완료 — ${r.nodeCount}개 노드` }]);
await srvLoad();
} catch (e) {
srvLog([{ c:'err', t:`재구성 실패: ${e.message}` }]);
}
}
function _srvRender(s) {
const dot = document.getElementById('srv-dot');
const txt = document.getElementById('srv-status-txt');
const meta = document.getElementById('srv-meta');
const ndot = document.getElementById('opcsvr-dot');
if (s.running) {
dot.className = 'dot grn';
txt.textContent = '● 실행 중';
if (ndot) ndot.className = 'nb grn';
} else {
dot.className = 'dot';
txt.textContent = '○ 중지됨';
if (ndot) ndot.className = 'nb';
}
const started = s.startedAt ? new Date(s.startedAt).toLocaleString('ko-KR') : '—';
meta.innerHTML =
`<span>엔드포인트: <b>${esc(s.endpointUrl || '—')}</b></span>` +
`<span>접속 클라이언트: <b>${s.connectedClientCount}</b></span>` +
`<span>노드 수: <b>${s.nodeCount}</b></span>` +
`<span>시작 시각: <b>${started}</b></span>`;
}
function srvLog(lines) {
log('srv-log', lines);
}
function _srvStartPoll() {
_srvStopPoll();
_srvPollTimer = setInterval(srvLoad, 5000);
}
function _srvStopPoll() {
if (_srvPollTimer) { clearInterval(_srvPollTimer); _srvPollTimer = null; }
}

503
src/Web/wwwroot/js/pb.js Normal file
View File

@@ -0,0 +1,503 @@
/* ─────────────────────────────────────────────────────────────
06 포인트빌더
───────────────────────────────────────────────────────────── */
const PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom'];
let pbPreviewData = [];
function pbCollectGroupData(groupKey) {
const patternInput = document.querySelector(`input[data-group="${groupKey}"][data-field="tagPatterns"]`);
const tagPatterns = patternInput?.value
? patternInput.value.split(',').map(s => s.trim()).filter(Boolean)
: [];
const checkedAttrs = Array.from(
document.querySelectorAll(`input[data-group="${groupKey}"][data-field="attributes"]:checked`)
).map(cb => cb.value);
const customInputs = document.querySelectorAll(`input[data-group="${groupKey}"][data-field="customAttrs"]`);
customInputs.forEach(inp => {
const v = inp.value.trim();
if (v) checkedAttrs.push(v);
});
const dataTypeEl = document.querySelector(`select[data-group="${groupKey}"][data-field="dataType"]`);
const dataType = dataTypeEl?.value || null;
return { tagPatterns, attributes: checkedAttrs, dataType };
}
async function pbBuild() {
const groups = {};
for (const gk of PB_GROUPS) {
const gd = pbCollectGroupData(gk);
if (gd.tagPatterns.length > 0) {
groups[gk] = gd;
}
}
const activeKeys = Object.keys(groups);
if (activeKeys.length === 0) {
const logEl = document.getElementById('pb-build-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll err">⚠️ 태그명 패턴을 최소 1개 입력하세요.</div>';
return;
}
setGlobal('busy', '포인트 빌드 중');
try {
const d = await api('POST', '/api/pointbuilder/build', groups);
const logEl = document.getElementById('pb-build-log');
logEl.classList.remove('hidden');
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(d.message)}</div>`;
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트` : '빌드 실패');
if (d.success) await pbRefresh();
} catch (e) {
const logEl = document.getElementById('pb-build-log');
logEl.classList.remove('hidden');
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
setGlobal('err', '오류');
}
}
async function pbPreview() {
const groups = {};
for (const gk of PB_GROUPS) {
const gd = pbCollectGroupData(gk);
if (gd.tagPatterns.length > 0) {
groups[gk] = gd;
}
}
const activeKeys = Object.keys(groups);
if (activeKeys.length === 0) {
const logEl = document.getElementById('pb-build-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll err">⚠️ 태그명 패턴을 최소 1개 입력하세요.</div>';
return;
}
setGlobal('busy', '미리보기 조회 중');
try {
const d = await api('POST', '/api/pointbuilder/preview', groups);
pbPreviewData = (d.items || []).map((item, idx) => ({ ...item, selected: true, idx }));
document.getElementById('pb-preview-count').textContent = `(${d.count}개)`;
document.getElementById('pb-preview').classList.remove('hidden');
pbRenderPreview(pbPreviewData);
setGlobal('ok', `미리보기: ${d.count}개 포인트`);
} catch (e) {
setGlobal('err', '미리보기 실패');
}
}
function pbRenderPreview(data) {
const el = document.getElementById('pb-preview-table');
const filtered = pbGetFilteredPreview();
const pts = filtered.length > 0 ? filtered : data;
if (pts.length === 0) {
el.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 포인트가 없습니다.</div>';
pbUpdatePreviewCount();
return;
}
el.innerHTML = `
<table>
<thead>
<tr>
<th><input type="checkbox" onchange="pbPreviewToggleAll(this.checked)" title="전체 선택/해제"/></th>
<th>ID</th>
<th>TagName</th>
<th>NodeType</th>
<th>DataType</th>
<th>Group</th>
</tr>
</thead>
<tbody>
${pts.map((p, i) => `
<tr style="${!p.selected ? 'opacity:0.5' : ''}">
<td><input type="checkbox" ${p.selected ? 'checked' : ''} onchange="pbPreviewToggleItem(${p.idx})"/></td>
<td class="mut">${i + 1}</td>
<td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
<td>${esc(p?.name || '')}</td>
<td class="mut">${esc(p?.dataType || '')}</td>
<td><span class="group-badge">${esc(p?.group || '')}</span></td>
</tr>
`).join('')}
</tbody>
</table>
`;
pbUpdatePreviewCount();
}
function pbPreviewToggleItem(idx) {
pbPreviewData[idx].selected = !pbPreviewData[idx].selected;
pbRenderPreview(pbPreviewData);
}
function pbPreviewToggleAll(checked) {
pbPreviewData.forEach((item) => {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (searchVal) {
const filtered = pbGetFilteredPreview();
if (filtered.includes(item)) {
item.selected = checked;
}
} else {
item.selected = checked;
}
});
pbRenderPreview(pbPreviewData);
}
function pbPreviewSelectAll() {
pbPreviewData.forEach(p => p.selected = true);
pbRenderPreview(pbPreviewData);
}
function pbPreviewDeselectAll() {
pbPreviewData.forEach(p => p.selected = false);
pbRenderPreview(pbPreviewData);
}
function pbPreviewInvert() {
pbPreviewData.forEach(p => p.selected = !p.selected);
pbRenderPreview(pbPreviewData);
}
function pbGetFilteredPreview() {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (!searchVal) return [];
return pbPreviewData.filter(p =>
(p.tagName || '').toLowerCase().includes(searchVal) ||
(p.nodeId || '').toLowerCase().includes(searchVal) ||
(p.name || '').toLowerCase().includes(searchVal)
);
}
function pbPreviewFilter() {
pbRenderPreview(pbPreviewData);
}
function pbUpdatePreviewCount() {
const selected = pbPreviewData.filter(p => p.selected).length;
const total = pbPreviewData.length;
document.getElementById('pb-preview-selected').textContent = `선택: ${selected}/${total}`;
}
function pbCancelPreview() {
document.getElementById('pb-preview').classList.add('hidden');
pbPreviewData = [];
}
async function pbApplySelected() {
const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
if (selected.length === 0) {
setGlobal('err', '적용할 포인트를 선택하세요.');
return;
}
setGlobal('busy', `${selected.length}개 포인트 적용 중`);
try {
const d = await api('POST', '/api/pointbuilder/apply', { selectedNodeIds: selected });
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 적용 완료` : '적용 실패');
if (d.success) {
pbCancelPreview();
await pbRefresh();
}
} catch (e) {
setGlobal('err', '적용 오류');
}
}
async function pbAppendSelected() {
const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
if (selected.length === 0) {
setGlobal('err', '추가할 포인트를 선택하세요.');
return;
}
setGlobal('busy', `${selected.length}개 포인트 추가 중`);
try {
const d = await api('POST', '/api/pointbuilder/append', { selectedNodeIds: selected });
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 추가 완료` : '추가 실패');
if (d.success) {
pbCancelPreview();
await pbRefresh();
}
} catch (e) {
setGlobal('err', '추가 오류');
}
}
async function pbRefresh() {
try {
const d = await api('GET', '/api/pointbuilder/points');
document.getElementById('pb-count').textContent = `(${d.total}개)`;
pbRender(d.items || []);
rtStatus();
} catch (e) { console.error('pbRefresh:', e); }
}
function pbRender(points) {
const tbl = document.getElementById('pb-table');
const pts = Array.isArray(points) ? points : [];
if (pts.length === 0) {
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>';
return;
}
tbl.innerHTML = `
<table>
<thead>
<tr>
<th>ID</th><th>TagName</th>
<th>LiveValue</th><th>Timestamp</th><th style="text-align:center">이력 / 삭제</th>
</tr>
</thead>
<tbody>
${pts.map(p => `
<tr>
<td class="mut">${esc(p?.id || '')}</td>
<td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
<td class="val">${p?.liveValue != null ? esc(String(fmtVal(parseEnumPv(p.liveValue)))) : '<span style="color:var(--t3)">—</span>'}</td>
<td class="mut" style="font-size:11px">${p?.liveValue != null ? fmtTs(p.timestamp) : '—'}</td>
<td style="white-space:nowrap;text-align:center">
<label style="font-size:11px;color:var(--t2);margin-right:8px;cursor:pointer" title="체크 시 이 태그의 이력(history_table)도 함께 영구 삭제 (복구 불가)">
<input type="checkbox" id="pb-hist-${p.id}" style="vertical-align:middle"> 이력
</label>
<button class="btn-sm btn-b" style="color:var(--red,#e55)" onclick="pbDelete(${p.id}, '${esc((p?.tagName)||'')}')">✕</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function pbAddManual() {
const nodeId = document.getElementById('pb-manual-nid').value.trim();
if (!nodeId) { alert('Node ID를 입력하세요.'); return; }
try {
const d = await api('POST', '/api/pointbuilder/add', { nodeId });
log('pb-manual-log', [{ c: d.success ? 'ok' : 'err', t: d.success
? `✅ 추가됨: ${d.point?.tagName} (${d.point?.nodeId})`
: '❌ 추가 실패' }]);
if (d.success) {
document.getElementById('pb-manual-nid').value = '';
await pbRefresh();
}
} catch (e) {
log('pb-manual-log', [{ c: 'err', t: '❌ ' + e.message }]);
}
}
async function pbDelete(id, tagName) {
const label = tagName ? `${tagName.toUpperCase()} (#${id})` : `#${id}`;
// 행의 "이력" 체크박스 = 이력 함께 삭제 opt-in (기본 미체크 = 이력 보존)
const purgeHistory = !!document.getElementById('pb-hist-' + id)?.checked;
const msg = purgeHistory
? `포인트 ${label} 삭제 + 이력 영구 삭제\n\n· realtime 포인트 삭제\n· 잔여 포인트 없으면 메타데이터(area/sub_area) 정리\n· ⚠️ 이력(history_table)까지 영구 삭제 — 복구 불가\n\n계속하시겠습니까?`
: `포인트 ${label}를 삭제하시겠습니까?\n\n· realtime 포인트 삭제\n· 잔여 포인트 없으면 메타데이터(area/sub_area) 정리\n· 이력(history)은 보존 ('이력' 체크 시 함께 삭제)`;
if (!confirm(msg)) return;
try {
const d = await api('DELETE', `/api/pointbuilder/${id}?purgeHistory=${purgeHistory}`);
if (d.success) {
const parts = [];
if (d.metadataPurged) parts.push('메타데이터 정리됨');
if (d.historyRowsDeleted > 0) parts.push(`이력 ${d.historyRowsDeleted.toLocaleString()}행 삭제`);
else if (purgeHistory) parts.push('이력 없음');
else parts.push('이력 보존');
alert(`삭제 완료: ${label}\n(${parts.join(' · ')})`);
await pbRefresh();
} else {
alert('삭제 실패: ' + d.message);
}
} catch (e) { alert('삭제 오류: ' + e.message); }
}
/* ── 실시간 구독 제어 ────────────────────────────────────────── */
async function rtStart() {
const body = {
serverHostName: document.getElementById('pb-rt-ip').value.trim(),
port: parseInt(document.getElementById('pb-rt-port').value) || 4840,
clientHostName: document.getElementById('pb-rt-client').value.trim(),
userName: document.getElementById('pb-rt-user').value.trim(),
password: document.getElementById('pb-rt-pw').value
};
setGlobal('busy', '구독 시작 중');
try {
const d = await api('POST', '/api/realtime/start', body);
log('pb-rt-status', [{ c: 'ok', t: '▶ ' + d.message }]);
setGlobal('ok', '구독 중');
} catch (e) {
log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]);
setGlobal('err', '오류');
}
}
async function rtStop() {
setGlobal('busy', '구독 중지 중');
try {
const d = await api('POST', '/api/realtime/stop');
log('pb-rt-status', [{ c: 'inf', t: '■ ' + d.message }]);
setGlobal('ok', '중지됨');
} catch (e) { log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]); }
}
async function rtStatus() {
try {
const d = await api('GET', '/api/realtime/status');
const logEl = document.getElementById('pb-rt-status');
logEl.classList.remove('hidden');
logEl.innerHTML = `<div class="ll ${d.running ? 'ok' : 'inf'}">
${d.running ? '▶' : '■'} ${esc(d.message)} (구독 ${d.subscribedCount}개)
</div>`;
} catch (e) { /* 무시 */ }
}
/* ── 메타데이터 관리 ─────────────────────────────────────────── */
async function metaReload() {
const body = {
serverHostName: document.getElementById('pb-rt-ip').value.trim(),
port: parseInt(document.getElementById('pb-rt-port').value) || 4840,
clientHostName: document.getElementById('pb-rt-client').value.trim(),
userName: document.getElementById('pb-rt-user').value.trim(),
password: document.getElementById('pb-rt-pw').value
};
const logEl = document.getElementById('meta-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll inf">⏳ 메타데이터 갱신 중...</div>';
try {
const d = await api('POST', '/api/tags/metadata/reload', body);
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(d.message)}</div>`;
} catch (e) {
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
async function metaView() {
const viewEl = document.getElementById('meta-view');
viewEl.classList.remove('hidden');
viewEl.innerHTML = '<div class="ll inf">⏳ 조회 중...</div>';
try {
const d = await api('GET', '/api/tags/metadata');
const items = d.items || [];
if (items.length === 0) {
viewEl.innerHTML = '<div class="ll inf">메타데이터가 없습니다.</div>';
return;
}
viewEl.innerHTML = `
<table style="width:100%;font-size:12px">
<thead><tr><th>BaseTag</th><th>Attribute</th><th>Value</th><th>LoadedAt</th></tr></thead>
<tbody>
${items.map(m => `
<tr>
<td style="font-weight:600">${esc(m.baseTag)}</td>
<td>${esc(m.attribute)}</td>
<td>${esc(m.value || '-')}</td>
<td class="mut">${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
viewEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
/* ── Sub-Area (세부 Area) 관리 ───────────────────────────────── */
// area별 선택 가능한 sub_area 옵션 (마지막은 공용 = 두 코드)
const SUBAREA_OPTIONS = {
P6: ['P6-1', 'P6-2', 'P6-1,P6-2'],
P9: ['P9-1', 'P9-2', 'P9-1,P9-2'],
P10: ['P10-1', 'P10-2', 'P10-1,P10-2'],
P1: ['P1-1', 'P1-2', 'P1-1,P1-2'],
P2: ['P2-1', 'P2-2', 'P2-1,P2-2'],
};
function subAreaLabel(code) {
if (!code) return '(미분류)';
return code.includes(',') ? `${code} (공용)` : code;
}
async function subAreaLoad() {
const area = document.getElementById('subarea-area-select').value;
const viewEl = document.getElementById('subarea-view');
viewEl.classList.remove('hidden');
viewEl.innerHTML = '<div class="ll inf">⏳ 조회 중...</div>';
try {
const d = await api('GET', `/api/tags/sub-area?area=${encodeURIComponent(area)}&page=1&pageSize=500`);
const tags = d.tags || [];
if (tags.length === 0) {
viewEl.innerHTML = '<div class="ll inf">태그가 없습니다.</div>';
return;
}
const opts = SUBAREA_OPTIONS[area] || [];
viewEl.innerHTML = `
<div class="mut" style="margin-bottom:6px">총 ${d.total}개 · 미분류는 (미분류)로 두면 NULL 유지</div>
<table style="width:100%;font-size:12px">
<thead><tr><th>BaseTag</th><th>Description</th><th>Sub-Area</th></tr></thead>
<tbody>
${tags.map(t => {
const cur = t.subArea || '';
const optionHtml = ['', ...opts].map(o =>
`<option value="${esc(o)}" ${o === cur ? 'selected' : ''}>${esc(subAreaLabel(o))}</option>`
).join('');
// 현재값이 옵션에 없으면(수동 지정 등) 추가
const extra = (cur && !opts.includes(cur))
? `<option value="${esc(cur)}" selected>${esc(subAreaLabel(cur))}</option>` : '';
return `
<tr>
<td style="font-weight:600">${esc(t.baseTag)}</td>
<td class="mut">${esc(t.description || '-')}</td>
<td>
<select class="inp" style="font-size:12px;padding:2px 6px"
onchange="subAreaUpdate('${esc(t.baseTag)}', this.value)">
${optionHtml}${extra}
</select>
</td>
</tr>`;
}).join('')}
</tbody>
</table>`;
} catch (e) {
viewEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
async function subAreaUpdate(baseTag, subArea) {
const logEl = document.getElementById('subarea-log');
logEl.classList.remove('hidden');
try {
const d = await api('PUT', '/api/tags/sub-area', { baseTag, subArea: subArea || null });
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(baseTag)}${esc(subAreaLabel(subArea))}</div>`;
} catch (e) {
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
async function subAreaSeed(dryRun) {
const msg = dryRun
? 'DryRun으로 분류 결과만 미리 봅니다(저장 안 함). 계속할까요?'
: '실제 seed를 실행합니다(기존 sub_area는 보존, 신규만 추가). 계속할까요?';
if (!confirm(msg)) return;
const logEl = document.getElementById('subarea-log');
logEl.classList.remove('hidden');
logEl.innerHTML = `<div class="ll inf">⏳ Seed ${dryRun ? 'DryRun' : '실행'} 중...</div>`;
try {
const d = await api('POST', '/api/tags/sub-area/seed', { dryRun });
const by = d.bySubArea || {};
const byStr = Object.keys(by).sort().map(k => `${k}=${by[k]}`).join(', ');
logEl.innerHTML = `
<div class="ll ${d.success ? 'ok' : 'err'}">
${d.success ? '✅' : '❌'} ${dryRun ? '[DryRun] ' : ''}단일 ${d.assigned} · 공용 ${d.shared} · 미분류 ${d.unmatched}
</div>
<div class="ll inf">분류별: ${esc(byStr || '-')}</div>`;
subAreaLoad();
} catch (e) {
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}

572
src/Web/wwwroot/js/pid.js Normal file
View File

@@ -0,0 +1,572 @@
/* ─────────────────────────────────────────────────────────────
11 P&ID 추출
───────────────────────────────────────────────────────────── */
let pidCurrentPage = 1;
let pidPageSize = 20;
let pidLastResult = null; // Excel export용
async function pidUpload() {
const fileInput = document.getElementById('pid-file-input');
const file = fileInput.files[0];
const statusEl = document.getElementById('pid-upload-status');
if (!file) {
if (statusEl) statusEl.textContent = '❌ 파일을 선택하세요.';
return;
}
const ext = file.name.toLowerCase().split('.').pop();
if (!['dxf', 'pdf'].includes(ext)) {
if (statusEl) statusEl.textContent = '❌ 지원 형식: .dxf, .pdf';
return;
}
if (statusEl) statusEl.textContent = `📤 전송 중... (${(file.size / 1024).toFixed(0)} KB)`;
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/pid/upload', { method: 'POST', body: formData });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const data = await res.json();
if (statusEl) statusEl.textContent = `✅ 전송 완료: ${data.fileName} (${(data.fileSize / 1024).toFixed(0)} KB)`;
await pidLoadServerFiles(data.fileName);
} catch (e) {
if (statusEl) statusEl.textContent = `❌ 전송 실패: ${e.message}`;
}
}
async function pidLoadServerFiles(selectFileName) {
const sel = document.getElementById('pid-server-file');
if (!sel) return;
try {
const res = await fetch('/api/pid/server-files');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// 응답이 JSON인지 확인
const contentType = res.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await res.text();
// HTML이 반환되면 P&ID 컨트롤러가 비활성화된 것
if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) {
throw new Error('P&ID 기능이 비활성화되어 있습니다. 관리자에게 문의하세요.');
}
throw new Error('예상치 못한 응답 형식');
}
const data = await res.json();
if (!data.files || data.files.length === 0) {
sel.innerHTML = '<option value="">-- 서버에 파일 없음 --</option>';
return;
}
sel.innerHTML = data.files.map(f =>
`<option value="${esc(f.fileName)}">${esc(f.fileName)} (${(f.fileSize / 1024).toFixed(0)} KB)</option>`
).join('');
if (selectFileName) sel.value = selectFileName;
} catch (e) {
sel.innerHTML = `<option value="">오류: ${e.message}</option>`;
}
}
// P&ID 추출 진행 상태 — 탭 전환 시 상태 초기화 방지
let pidExtracting = false;
let pidElapsedInterval = null;
async function pidExtract() {
const fileName = document.getElementById('pid-server-file').value;
const statusEl = document.getElementById('pid-status');
const logEl = document.getElementById('pid-log');
const elapsedEl = document.getElementById('pid-elapsed');
if (!fileName) {
if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.';
return;
}
pidExtracting = true;
if (statusEl) statusEl.textContent = '추출 중...';
if (logEl) {
logEl.style.display = 'block';
logEl.innerHTML = '';
}
// 경과 시간 시계 시작
const startTime = Date.now();
if (elapsedEl) {
elapsedEl.style.display = 'inline';
pidElapsedInterval = setInterval(() => {
const seconds = Math.floor((Date.now() - startTime) / 1000);
const m = String(Math.floor(seconds / 60)).padStart(2, '0');
const s = String(seconds % 60).padStart(2, '0');
elapsedEl.textContent = `${m}:${s}`;
}, 1000);
}
try {
const res = await fetch('/api/pid/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, useImageMode: false })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const data = await res.json();
pidExtracting = false;
const skipMsg = data.skippedDuplicates > 0 ? ` (${data.skippedDuplicates}건 중복 제외)` : '';
if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.totalCount}${skipMsg}`;
log('pid-log', [
{ c: 'ok', t: `✅ 추출 완료: ${data.totalCount}${skipMsg}` },
{ c: 'inf', t: ` 신뢰도 70%+: ${data.confidenceItems}` },
{ c: 'inf', t: ` 신뢰도 50%미만: ${data.lowConfidenceItems}` },
...(data.skippedDuplicates > 0 ? [{ c: 'warn', t: ` 중복 스킵: ${data.skippedDuplicates}` }] : [])
]);
pidCurrentPage = 1;
await pidLoadTable();
pidUpdateStats();
} catch (e) {
pidExtracting = false;
if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`;
log('pid-log', [{ c: 'err', t: `${e.message}` }]);
} finally {
// 경과 시간 시계 종료
if (pidElapsedInterval) {
clearInterval(pidElapsedInterval);
pidElapsedInterval = null;
}
if (elapsedEl) elapsedEl.style.display = 'none';
}
}
async function pidLoadTable(page = 1) {
pidCurrentPage = page;
const container = document.getElementById('pid-table-container');
const tbody = document.getElementById('pid-table-body');
if (!container || !tbody) return;
// tbody만 비우고 로딩 상태 표시 — container.innerHTML을 덮어쓰면 tbody가 DOM에서 떨어져 나가
// 이후 tbody.innerHTML 할당이 화면에 반영되지 않음.
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px">로딩 중...</td></tr>';
try {
const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
pidLastResult = data;
if (!data.items || data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
document.getElementById('pid-pagination').innerHTML = '';
return;
}
tbody.innerHTML = data.items.map(item => `
<tr>
<td>${item.id}</td>
<td><strong>${esc(item.tagName)}</strong></td>
<td>${esc(item.equipmentName) || '-'}</td>
<td>${esc(item.instrumentType) || '-'}</td>
<td>${esc(item.lineNumber) || '-'}</td>
<td>${esc(item.pidDrawingNo) || '-'}</td>
<td style="text-align:center">${(item.confidence * 100).toFixed(1)}%</td>
<td style="text-align:center">
<span class="badge ${item.isActive ? 'ok' : 'warn'}">${item.isActive ? '활성' : '비활성'}</span>
</td>
<td>
${item.experionTagId
? `<span class="badge ok">✅ ${esc(item.experionTagName || '')}</span>`
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id})">매핑</button>`
}
</td>
<td>${item.category
? `<span class="badge ${pidCategoryBadge(item.category)}">${esc(item.category)}</span>`
: '-'}
</td>
<td style="text-align:center">
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.id})" title="삭제">삭제</button>
</td>
</tr>
`).join('');
pidRenderPagination(data.total, page);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
}
}
function pidRenderPagination(total, currentPage) {
const pagination = document.getElementById('pid-pagination');
if (!pagination) return;
const totalPages = Math.ceil(total / pidPageSize);
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = '';
const start = Math.max(1, currentPage - 3);
const end = Math.min(totalPages, currentPage + 3);
if (currentPage > 1) {
html += `<button class="btn-sm" onclick="pidLoadTable(${currentPage - 1})"></button>`;
}
for (let i = start; i <= end; i++) {
html += `<button class="btn-sm ${i === currentPage ? 'btn-a' : 'btn-b'}"
onclick="pidLoadTable(${i})">${i}</button>`;
}
if (currentPage < totalPages) {
html += `<button class="btn-sm" onclick="pidLoadTable(${currentPage + 1})"></button>`;
}
pagination.innerHTML = html;
}
async function pidDeleteRow(id) {
if (!confirm('정말 이 레코드를 삭제하시겠습니까?')) return;
try {
const res = await fetch(`/api/pid/${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || res.statusText);
}
await pidLoadTable(pidCurrentPage);
pidUpdateStats();
} catch (e) {
alert(`삭제 실패: ${e.message}`);
}
}
function pidUpdateStats() {
if (!pidLastResult || !pidLastResult.items) return;
const total = pidLastResult.total || 0;
const highConf = pidLastResult.items.filter(i => i.confidence >= 0.7).length;
const mapped = pidLastResult.items.filter(i => i.experionTagId).length;
const elTotal = document.getElementById('pid-stat-total');
const elHigh = document.getElementById('pid-stat-high');
const elMapped = document.getElementById('pid-stat-mapped');
if (elTotal) elTotal.textContent = total;
if (elHigh) elHigh.textContent = highConf;
if (elMapped) elMapped.textContent = mapped;
}
function pidClearLog() {
const logEl = document.getElementById('pid-log');
if (logEl) logEl.innerHTML = '';
}
async function pidAnalyzeConnections() {
const fileName = document.getElementById('pid-server-file').value;
const statusEl = document.getElementById('pid-status');
const logEl = document.getElementById('pid-log');
if (!fileName) {
if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.';
return;
}
if (statusEl) statusEl.textContent = '연결 분석 중...';
if (logEl) {
logEl.style.display = 'block';
logEl.innerHTML = '<div class="ll inf">⏳ 연결 분석 시작...</div>';
}
try {
const res = await fetch('/api/pid/connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const data = await res.json();
if (statusEl) statusEl.textContent = `✅ 연결 분석 완료: ${data.connectionCount}`;
log('pid-log', [
{ c: 'ok', t: `✅ 연결 분석 완료: ${data.connectionCount}개 from→to 연결` }
]);
pidCurrentPage = 1;
await pidLoadTable();
pidUpdateStats();
} catch (e) {
if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`;
log('pid-log', [{ c: 'err', t: `${e.message}` }]);
}
}
async function pidDeleteAll() {
if (!confirm('P&ID 추출 데이터를 전부 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.')) return;
const statusEl = document.getElementById('pid-status');
const logEl = document.getElementById('pid-log');
if (statusEl) statusEl.textContent = '전체 삭제 중...';
if (logEl) {
logEl.style.display = 'block';
logEl.innerHTML = '<div class="ll inf">⏳ 삭제 중...</div>';
}
try {
const res = await fetch('/api/pid/all', { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (statusEl) statusEl.textContent = `🗑 ${data.message}`;
log('pid-log', [{ c: 'warn', t: `🗑 전체 삭제 완료: ${data.deletedCount}` }]);
pidCurrentPage = 1;
await pidLoadTable();
pidUpdateStats();
} catch (e) {
if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`;
log('pid-log', [{ c: 'err', t: `${e.message}` }]);
}
}
/* ── Prefix 분류 정의 ─────────────────────────────────────── */
let pidPrefixPanelVisible = false;
function pidTogglePrefixPanel() {
const panel = document.getElementById('pid-prefix-panel');
const toggle = document.getElementById('pid-prefix-toggle');
pidPrefixPanelVisible = !pidPrefixPanelVisible;
if (panel) panel.style.display = pidPrefixPanelVisible ? 'block' : 'none';
if (toggle) toggle.textContent = pidPrefixPanelVisible ? '▲' : '▼';
if (pidPrefixPanelVisible) pidRefreshPrefixRules();
}
const CATEGORY_META = {
instrument: { label: 'Instrument', badge: 'ok' },
power_equipment: { label: 'Power Equipment', badge: 'warn' },
storage_equipment: { label: 'Storage Equipment', badge: 'inf' },
process_equipment: { label: 'Process Equipment', badge: '' },
utility_equipment: { label: 'Utility Equipment', badge: 'warn' },
pipings: { label: 'Pipings', badge: 'ok' }
};
const CATEGORY_ORDER = ['instrument', 'power_equipment', 'storage_equipment', 'process_equipment', 'utility_equipment', 'pipings'];
function pidCategoryBadge(cat) {
return CATEGORY_META[cat] ? CATEGORY_META[cat].badge : '';
}
async function pidRefreshPrefixRules() {
const container = document.getElementById('pid-prefix-groups');
if (!container) return;
container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">로딩 중...</div>';
try {
const res = await fetch('/api/pid/prefix-rules');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">규칙이 없습니다. 아래에서 추가하세요.</div>';
return;
}
const grouped = {};
for (const r of items) {
if (!grouped[r.category]) grouped[r.category] = [];
grouped[r.category].push(r);
}
let html = '';
for (const cat of CATEGORY_ORDER) {
const rules = grouped[cat];
if (!rules) continue;
const meta = CATEGORY_META[cat] || { label: cat, badge: '' };
rules.sort((a, b) => a.sortOrder - b.sortOrder);
html += `<div class="pid-cat-group">
<div class="pid-cat-header">
<span class="badge ${meta.badge}">${meta.label}</span>
<span class="pid-cat-count">${rules.length}</span>
<span class="pid-cat-add" style="margin-left:auto">
<input class="inp pid-cat-prefix-input" placeholder="예: FT" style="width:90px;font-family:var(--mono)" />
<input class="inp pid-cat-desc-input" placeholder="설명 (선택)" style="width:160px" />
<input class="inp pid-cat-order-input" type="number" value="10" style="width:50px" />
<button class="btn-a btn-sm" onclick="pidAddPrefixRule('${cat}')">추가</button>
</span>
</div>
<div class="pid-cat-body">`;
for (const r of rules) {
html += `<div class="pid-cat-row">
<input class="inp pid-cat-prefix-input" value="${esc(r.prefix)}" style="width:80px;font-family:var(--mono)" />
<input class="inp pid-cat-desc-input" value="${esc(r.description) || ''}" style="flex:1;min-width:0" />
<input class="inp pid-cat-order-input" type="number" value="${r.sortOrder}" style="width:44px" />
<span class="pid-cat-actions">
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.id}, this)">수정</button>
<button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.id}, '${esc(r.prefix)}')">삭제</button>
</span>
</div>`;
}
html += `</div></div>`;
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = `<div style="text-align:center;padding:12px;color:var(--red)">오류: ${e.message}</div>`;
}
}
async function pidAddPrefixRule(category) {
const row = event.target.closest('.pid-cat-add');
const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
const desc = row.querySelector('.pid-cat-desc-input').value.trim();
const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10;
if (!prefix) { alert('Prefix를 입력하세요.'); return; }
try {
const res = await fetch('/api/pid/prefix-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, category, description: desc, sortOrder: order })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
row.querySelector('.pid-cat-prefix-input').value = '';
row.querySelector('.pid-cat-desc-input').value = '';
await pidRefreshPrefixRules();
} catch (e) {
alert(`추가 실패: ${e.message}`);
}
}
async function pidUpdatePrefixRule(id, btn) {
const row = btn.closest('.pid-cat-row');
const group = btn.closest('.pid-cat-group');
const catIdx = [...group.parentElement.children].indexOf(group);
const cat = CATEGORY_ORDER[catIdx];
const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
const desc = row.querySelector('.pid-cat-desc-input').value.trim();
const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10;
if (!prefix) { alert('Prefix를 입력하세요.'); return; }
try {
const res = await fetch(`/api/pid/prefix-rules/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, category: cat, description: desc, sortOrder: order })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
await pidRefreshPrefixRules();
} catch (e) {
alert(`수정 실패: ${e.message}`);
}
}
async function pidDeletePrefixRule(id, prefix) {
if (!confirm(`"${prefix}" 규칙을 삭제하시겠습니까?`)) return;
try {
const res = await fetch(`/api/pid/prefix-rules/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await pidRefreshPrefixRules();
} catch (e) {
alert(`삭제 실패: ${e.message}`);
}
}
async function pidApplyCategories() {
if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return;
try {
const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
alert(`${data.applied}건 category 적용 완료`);
await pidLoadTable(pidCurrentPage);
pidUpdateStats();
} catch (e) {
alert(`재적용 실패: ${e.message}`);
}
}
function pidOpenMapping(id) {
// 매핑 모달 열기 (추후 구현)
console.log('pidOpenMapping:', id);
}
// 탭 진입 시 초기화
paneInit.pid = async function() {
pidCurrentPage = 1;
pidLastResult = null;
if (document.getElementById('pid-file-input')) document.getElementById('pid-file-input').value = '';
if (!pidExtracting) {
const st = document.getElementById('pid-status');
if (st) st.textContent = '대기 중...';
const elapsedEl = document.getElementById('pid-elapsed');
if (elapsedEl) elapsedEl.style.display = 'none';
if (pidElapsedInterval) {
clearInterval(pidElapsedInterval);
pidElapsedInterval = null;
}
const tb = document.getElementById('pid-table-body');
if (tb) tb.innerHTML = '';
const pg = document.getElementById('pid-pagination');
if (pg) pg.innerHTML = '';
const stTot = document.getElementById('pid-stat-total');
if (stTot) stTot.textContent = '0';
const stHi = document.getElementById('pid-stat-high');
if (stHi) stHi.textContent = '0';
const stMap = document.getElementById('pid-stat-mapped');
if (stMap) stMap.textContent = '0';
await pidLoadTable(1);
}
// Export buttons — attach once
const bind = (id, fn) => {
const el = document.getElementById(id);
if (el && !el.dataset.pidBound) { el.addEventListener('click', fn); el.dataset.pidBound = '1'; }
};
bind('btn-pid-export-csv', async () => {
if (!pidLastResult) return;
const res = await fetch('/api/pid/export/csv');
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pid-export-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
});
bind('btn-pid-export-excel', async () => {
if (!pidLastResult) return;
const res = await fetch('/api/pid/export/excel');
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pid-export-${new Date().toISOString().slice(0,10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
});
};

735
src/Web/wwwroot/js/t2s.js Normal file
View File

@@ -0,0 +1,735 @@
/* ── Text-to-SQL Export Variables ────────────────────────────────────────── */
let _t2sLastResult = null; // Excel export용 — 마지막으로 렌더된 결과 보관
/* ── Text-to-SQL Dashboard ───────────────────────────────────── */
paneInit.t2s = t2sInitMode;
// MCP 모드 설정 (legacy | mcp)
let t2sMode = 'mcp';
/**
* t2sInitMode - 현재 t2sMode에 맞게 UI 초기화 (탭 진입 시 호출)
*/
function t2sInitMode() {
const parseBtn = document.getElementById('t2s-parse-btn');
const executeBtn = document.getElementById('t2s-execute-btn');
const analyzeBtn = document.getElementById('t2s-analyze-btn');
const logBox = document.getElementById('t2s-log');
if (t2sMode === 'mcp') {
if (parseBtn) parseBtn.classList.add('hidden');
if (analyzeBtn) analyzeBtn.classList.add('hidden');
if (logBox) logBox.classList.add('hidden');
} else {
if (parseBtn) parseBtn.classList.remove('hidden');
if (executeBtn) executeBtn.classList.remove('hidden');
if (analyzeBtn) analyzeBtn.classList.remove('hidden');
if (logBox) logBox.classList.remove('hidden');
}
}
/**
* toggleMcpMode - MCP 모드 토글
*/
function toggleMcpMode() {
t2sMode = t2sMode === 'legacy' ? 'mcp' : 'legacy';
// 버튼 표시/숨김 처리
const parseBtn = document.getElementById('t2s-parse-btn');
const executeBtn = document.getElementById('t2s-execute-btn');
const analyzeBtn = document.getElementById('t2s-analyze-btn');
const chatContainer = document.getElementById('t2s-chat-container');
const logBox = document.getElementById('t2s-log');
if (t2sMode === 'mcp') {
// MCP 모드: 변환(Parse) · 분석 버튼 숨김, 실행 버튼은 유지
if (parseBtn) parseBtn.classList.add('hidden');
if (analyzeBtn) analyzeBtn.classList.add('hidden');
if (logBox) logBox.classList.add('hidden');
setGlobal('ok', 'MCP 모드');
} else {
// Legacy 모드: 모든 기능 표시
if (parseBtn) parseBtn.classList.remove('hidden');
if (executeBtn) executeBtn.classList.remove('hidden');
if (analyzeBtn) analyzeBtn.classList.remove('hidden');
if (logBox) logBox.classList.remove('hidden');
setGlobal('ok', 'Legacy 모드');
}
}
/**
* t2sParse - 자연어 쿼리를 SQL로 변환
* MCP 모드: LLM이 SQL 생성 + 즉시 실행
* Legacy 모드: C# 파서로 SQL 생성
*/
async function t2sParse() {
const input = document.getElementById('t2s-query').value.trim();
if (!input) {
alert('자연어 쿼리를 입력해주세요.');
return;
}
const sqlTextarea = document.getElementById('t2s-sql');
const resultContainer = document.getElementById('t2s-results');
sqlTextarea.disabled = true;
if (t2sMode === 'mcp') {
sqlTextarea.value = 'LLM이 SQL 생성 중...';
resultContainer.innerHTML = '<div class="t2s-loading">MCP 조회 중...</div>';
try {
const res = await api('POST', '/api/text-to-sql/query-nl', { query: input });
console.log('[t2sParse] MCP 응답:', res);
if (res.success) {
const d = (typeof res.data === 'object' && res.data !== null) ? res.data : {};
console.log('[t2sParse] 데이터 파싱:', d, 'type:', typeof d);
console.log('[t2sParse] d.data:', d.data, 'd.columns:', d.columns, 'd.count:', d.count);
// d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인
const dataObj = d.data || (Array.isArray(d) ? d : null);
console.log('[t2sParse] dataObj:', dataObj);
if (d.success === false) {
sqlTextarea.value = `오류: ${d.error || 'SQL 생성 실패'}`;
resultContainer.innerHTML = `<div class="t2s-error">MCP 오류: ${esc(d.error || 'SQL 생성 실패')}</div>`;
} else {
sqlTextarea.value = d.sql || '(SQL 없음)';
// d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인
const rows = (dataObj && Array.isArray(dataObj.rows)) ? dataObj.rows : (Array.isArray(dataObj) ? dataObj : []);
const columns = (dataObj && Array.isArray(dataObj.columns)) ? dataObj.columns : [];
const totalCount = (dataObj && typeof dataObj.count === 'number') ? dataObj.count : (Array.isArray(dataObj) ? dataObj.length : 0);
console.log('[t2sParse] 최종 rows.length:', rows.length, 'columns:', columns);
if (rows.length === 0) {
resultContainer.innerHTML = '<div class="t2s-empty">조회 결과가 없습니다. (SQL은 생성됨)</div>';
} else {
t2sRenderTable({ rows, columns, totalCount });
}
}
} else {
sqlTextarea.value = `오류: ${res.error || '알 수 없는 오류'}`;
resultContainer.innerHTML = '';
}
} catch (err) {
sqlTextarea.value = `연결 오류: ${err.message}`;
resultContainer.innerHTML = '';
}
} else {
sqlTextarea.value = '변환 중...';
try {
const res = await api('POST', '/api/text-to-sql/parse', { query: input });
if (res.success) {
sqlTextarea.value = res.sql || 'SQL 생성 실패';
} else {
sqlTextarea.value = `오류: ${res.error || '알 수 없는 오류'}`;
}
} catch (err) {
sqlTextarea.value = `연결 오류: ${err.message}`;
}
}
sqlTextarea.disabled = false;
}
/**
* t2sPivot - tagname 컬럼이 있으면 wide format으로 변환 (query_with_nl 서버 로직과 동일)
*/
function t2sPivot(columns, rows) {
console.log('[t2sPivot] 입력: columns:', columns, 'rows.length:', rows.length);
if (!columns.includes('tagname') || !rows.length) return { columns, rows };
const timeCol = columns.find(c => !['tagname', 'value', 'livevalue', 'avg_val'].includes(c));
const valCol = ['value', 'avg_val'].find(c => columns.includes(c)) || columns[columns.length - 1];
if (!timeCol) return { columns, rows };
const tagNames = [...new Set(rows.map(r => r['tagname']))].sort();
const pivoted = {};
for (const row of rows) {
const key = String(row[timeCol]);
if (!pivoted[key]) pivoted[key] = { [timeCol]: row[timeCol] };
pivoted[key][row['tagname']] = row[valCol];
}
const result = { columns: [timeCol, ...tagNames], rows: Object.values(pivoted) };
console.log('[t2sPivot] 결과: columns:', result.columns, 'rows.length:', result.rows.length);
return result;
}
/**
* t2sExecute - SQL 실행
* MCP 모드에서는 /api/text-to-sql/execute-mcp 엔드포인트를 사용하며 pivot 적용
*/
async function t2sExecute() {
const sql = document.getElementById('t2s-sql').value.trim();
if (!sql) {
alert('SQL을 입력해주세요.');
return;
}
const limitInput = document.getElementById('t2s-limit');
const limit = limitInput.value ? parseInt(limitInput.value) : 1000;
const resultContainer = document.getElementById('t2s-results');
resultContainer.innerHTML = '<div class="t2s-loading">실행 중...</div>';
try {
if (t2sMode === 'mcp') {
const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql, limit });
console.log('[t2sExecute] MCP execute 응답:', res);
if (res.success) {
const d = res.data || {};
console.log('[t2sExecute] d.data:', d.data, 'd.columns:', d.columns);
const { columns, rows } = t2sPivot(d.columns || [], d.data || []);
console.log('[t2sExecute] pivot 후 rows.length:', rows.length);
t2sRenderTable({ rows, columns, totalCount: rows.length });
} else {
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.error || '알 수 없는 오류'}</div>`;
}
} else {
const res = await api('POST', '/api/text-to-sql/execute', { sql, limit });
if (res.success) {
t2sRenderTable(res);
} else {
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.error || '알 수 없는 오류'}</div>`;
}
}
} catch (err) {
resultContainer.innerHTML = `<div class="t2s-error">연결 오류: ${err.message}</div>`;
}
}
/**
* t2sRenderTable - 쿼리 결과를 테이블로 렌더링
*/
function t2sRenderTable(result) {
const container = document.getElementById('t2s-results');
console.log('[t2sRenderTable] 입력 결과:', result);
// 백엔드 응답: columns, rows, totalCount (소문자)
const rows = result.rows || [];
const columns = result.columns || [];
const totalCount = result.totalCount || 0;
console.log('[t2sRenderTable] rows.length:', rows.length, 'columns:', columns);
// ── 추가: 결과 저장 (export용) ──
_t2sLastResult = rows.length > 0 ? { columns, rows } : null;
if (!rows || rows.length === 0) {
console.log('[t2sRenderTable] 빈 결과 - 결과 없음 표시');
container.innerHTML = '<div class="t2s-empty">결과가 없습니다.</div>';
return;
}
// 컬럼이 비어있으면 첫 행에서 추출
const colNames = columns.length > 0 ? columns : Object.keys(rows[0]);
let html = '<div class="t2s-result-info">총 <b>' + totalCount + '</b>개 결과<button class="btn-excel" onclick="t2sExportExcel()">⬇ Excel</button></div>';
html += '<table class="t2s-table">';
// Header
html += '<thead><tr>';
colNames.forEach(col => {
html += `<th>${esc(col)}</th>`;
});
html += '</tr></thead>';
// Body
html += '<tbody>';
rows.forEach(row => {
html += '<tr>';
colNames.forEach(col => {
const val = row[col];
const isTimeCol = /recorded_at|timestamp|bucket|time/i.test(col);
const display = val !== null && val !== undefined
? esc(String(isTimeCol ? fmtTs(val) : fmtVal(val)))
: '<span class="t2s-null">NULL</span>';
html += `<td>${display}</td>`;
});
html += '</tr>';
});
html += '</tbody>';
html += '</table>';
container.innerHTML = html;
}
/**
* t2sExportExcel — 마지막 쿼리 결과를 .xlsx로 다운로드
*/
function t2sExportExcel() {
if (!_t2sLastResult) return;
if (typeof XLSX === 'undefined') {
alert('Excel 라이브러리 로드 실패');
return;
}
const { columns, rows } = _t2sLastResult;
// 1. 헤더 행 + 데이터 행 배열 구성
const sheetData = [
columns, // 첫 행 = 컬럼 헤더
...rows.map(row => columns.map(col => {
const v = row[col];
if (v == null) return '';
const isTimeCol = /recorded_at|timestamp|bucket|time/i.test(col);
if (isTimeCol) return fmtTs(v);
const n = Number(v);
return Number.isFinite(n) ? n : String(v);
}))
];
// 2. 워크시트 생성
const ws = XLSX.utils.aoa_to_sheet(sheetData);
// 3. 컬럼 너비 자동 조정 (최대 30자)
ws['!cols'] = columns.map((col, i) => {
const maxLen = Math.max(
col.length,
...rows.map(r => String(r[col] ?? '').length)
);
return { wch: Math.min(maxLen + 2, 30) };
});
// 4. 워크북 생성 및 다운로드
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'QueryResult');
const now = new Date();
const ts = now.toISOString().replace(/[:.]/g, '-').substring(0, 19);
XLSX.writeFile(wb, `query_result_${ts}.xlsx`);
}
/**
* t2sAnalyze - 태그 분석 (시계열 분석) (Legacy 모드용)
*/
async function t2sAnalyze() {
const tagNames = document.getElementById('t2s-tags').value.trim();
if (!tagNames) {
alert('분석할 태그 이름을 입력해주세요.');
return;
}
const interval = document.getElementById('t2s-interval').value;
const limit = document.getElementById('t2s-limit-analyze').value || '100';
const dateFrom = document.getElementById('t2s-date-from').value;
const dateTo = document.getElementById('t2s-date-to').value;
const resultContainer = document.getElementById('t2s-analysis-results');
resultContainer.innerHTML = '<div class="t2s-loading">분석 중...</div>';
try {
const res = await api('POST', '/api/text-to-sql/analyze', {
tagNames: tagNames.split(',').map(t => t.trim()).filter(t => t),
interval: interval,
from: dateFrom || undefined,
to: dateTo || undefined
});
if (res.success) {
t2sRenderAnalysis(res);
} else {
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.error || '알 수 없는 오류'}</div>`;
}
} catch (err) {
resultContainer.innerHTML = `<div class="t2s-error">연결 오류: ${err.message}</div>`;
}
}
/**
* t2sRenderAnalysis - 분석 결과를 렌더링
*/
function t2sRenderAnalysis(result) {
const container = document.getElementById('t2s-analysis-results');
if (!result.tags || result.tags.length === 0) {
container.innerHTML = '<div class="t2s-empty">분석 결과가 없습니다.</div>';
return;
}
let html = '<div class="t2s-result-info">총 <b>' + result.tags.length + '</b>개 태그 분석</div>';
html += '<div class="t2s-analysis-grid">';
result.tags.forEach(tag => {
html += '<div class="t2s-tag-card">';
html += `<h4>${esc(tag.tagName.toUpperCase())}</h4>`;
html += '<div class="t2s-tag-stats">';
html += `<div class="t2s-stat-row"><span>평균:</span><span class="t2s-value">${tag.mean?.toFixed(2) || 'N/A'}</span></div>`;
html += `<div class="t2s-stat-row"><span>최대:</span><span class="t2s-value t2s-max">${tag.max?.toFixed(2) || 'N/A'}</span></div>`;
html += `<div class="t2s-stat-row"><span>최소:</span><span class="t2s-value t2s-min">${tag.min?.toFixed(2) || 'N/A'}</span></div>`;
html += `<div class="t2s-stat-row"><span>표준편차:</span><span class="t2s-value">${tag.stdDev?.toFixed(2) || 'N/A'}</span></div>`;
html += '</div>';
html += '</div>';
});
html += '</div>';
container.innerHTML = html;
}
/**
* t2sSetQuery - 제안 쿼리 설정
*/
function t2sSetQuery(query) {
document.getElementById('t2s-query').value = query;
}
/* ── 채팅 기능 ────────────────────────────────────────────────── */
/**
* t2sChatSend - 채팅 메시지 전송
*/
async function t2sChatSend() {
const input = document.getElementById('t2s-chat-input');
const message = input.value.trim();
if (!message) return;
// 입력 필드 비활성화
input.disabled = true;
document.getElementById('t2s-chat-send-btn').disabled = true;
// 사용자 메시지 추가
t2sAddChatMessage('user', message);
input.value = '';
try {
if (t2sMode === 'mcp') {
// MCP 모드: 자연어 → LLM SQL 생성 → 실행
t2sAddChatMessage('system', '<span class="t2s-typing">LLM이 SQL 생성 중...</span>');
const executeRes = await api('POST', '/api/text-to-sql/query-nl', { query: message });
// 로딩 메시지 제거
const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]');
loadMsgs.forEach(el => el.remove());
if (!executeRes.success) {
t2sAddChatMessage('system', `<span class="t2s-error">실패: ${executeRes.error || '알 수 없는 오류'}</span>`);
} else {
const d = executeRes.data || {};
if (d.sql) {
document.getElementById('t2s-sql').value = d.sql;
t2sAddChatMessage('system', `✅ SQL 생성:<br><pre class="t2s-chat-sql">${esc(d.sql)}</pre>`);
}
if (d.success === false) {
t2sAddChatMessage('system', `<span class="t2s-error">실행 오류: ${d.error || '알 수 없는 오류'}</span>`);
} else {
t2sRenderTable({ rows: d.data || [], columns: d.columns || [], totalCount: d.count || 0 });
t2sAddChatMessage('system', `✅ <b>${d.count || 0}</b>개 결과 조회 완료`);
}
}
} else {
// Legacy 모드: 2-hop 구조 (Parse → Execute)
const loadingId = 't2s-chat-loading-' + Date.now();
t2sAddChatMessage('system', '<span class="t2s-typing">변환 중...</span>', loadingId);
const parseRes = await api('POST', '/api/text-to-sql/parse', { query: message });
// 로딩 메시지 제거
const loadingEl = document.getElementById(loadingId);
if (loadingEl) loadingEl.remove();
if (!parseRes.success || !parseRes.sql) {
t2sAddChatMessage('system', `<span class="t2s-error">SQL 변환 실패: ${parseRes.error || '알 수 없는 오류'}</span>`);
input.disabled = false;
document.getElementById('t2s-chat-send-btn').disabled = false;
input.focus();
return;
}
// SQL 텍스트박스에도 반영
document.getElementById('t2s-sql').value = parseRes.sql;
// 시스템 메시지: 변환된 SQL 표시
t2sAddChatMessage('system', `✅ SQL 변환 완료:<br><pre class="t2s-chat-sql">${esc(parseRes.sql)}</pre>`);
// 2. SQL 자동 실행
t2sAddChatMessage('system', '<span class="t2s-typing">쿼리 실행 중...</span>');
const limitInput = document.getElementById('t2s-limit');
const limit = limitInput.value ? parseInt(limitInput.value) : 1000;
const executeRes = await api('POST', '/api/text-to-sql/execute', { sql: parseRes.sql, limit });
// 로딩 메시지 제거
const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]');
loadMsgs.forEach(el => el.remove());
if (!executeRes.success) {
t2sAddChatMessage('system', `<span class="t2s-error">쿼리 실행 실패: ${executeRes.error || '알 수 없는 오류'}</span>`);
} else {
// 결과 테이블 업데이트
t2sRenderTable(executeRes);
// 결과 수 표시
const totalCount = executeRes.totalCount || 0;
t2sAddChatMessage('system', `✅ <b>${totalCount}</b>개 결과 조회 완료`);
}
}
} catch (err) {
// 로딩 메시지 제거
const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]');
loadMsgs.forEach(el => el.remove());
t2sAddChatMessage('system', `<span class="t2s-error">연결 오류: ${err.message}</span>`);
} finally {
// 입력 필드 활성화
input.disabled = false;
document.getElementById('t2s-chat-send-btn').disabled = false;
input.focus();
}
}
/**
* t2sAddChatMessage - 채팅 메시지 추가
*/
function t2sAddChatMessage(type, content, id) {
const container = document.getElementById('t2s-chat-messages');
const msgId = id || 't2s-msg-' + Date.now();
const div = document.createElement('div');
div.className = `t2s-chat-msg ${type}`;
div.id = msgId;
const bubble = document.createElement('div');
bubble.className = 't2s-chat-bubble';
bubble.innerHTML = content;
div.appendChild(bubble);
container.appendChild(div);
// 스크롤 맨 아래로
const chatContainer = document.querySelector('.t2s-chat-container');
chatContainer.scrollTop = chatContainer.scrollHeight;
return msgId;
}
/* ─── Text-to-SQL MCP 도구 관련 함수 ───────────────────────── */
/**
* loadMcpTools - MCP 도구 목록 로드
*/
async function loadMcpTools() {
try {
const res = await api('GET', '/api/text-to-sql/tools');
if (res.success && res.tools && res.tools.length > 0) {
renderToolsChips(res.tools);
return res.tools;
}
} catch (e) {
console.error('MCP 도구 로드 실패:', e);
}
return [];
}
/**
* renderToolsChips - 도구 목록을 추천 쿼리 칩 형태로 렌더링
*/
function renderToolsChips(tools) {
const container = document.getElementById('t2s-tools-container');
if (!container) return;
const chipColors = ['bg-a', 'bg-b', 'bg-c', 'bg-d'];
container.innerHTML = tools.map((tool, idx) => {
const colorClass = chipColors[idx % chipColors.length];
return `<button class="t2s-chip chip-${colorClass}" onclick="callTool('${tool.name}')">
${esc(tool.description || tool.name)}
</button>`;
}).join('');
}
/**
* callTool - MCP 도구 직접 호출 (MCP 모드)
*/
async function callTool(toolName) {
const input = document.getElementById('t2s-query').value.trim();
if (!input) {
alert(toolName === 'query_pv_history' ? '태그 이름을 입력해주세요.' : '쿼리를 입력해주세요.');
return;
}
const resultContainer = document.getElementById('t2s-results');
resultContainer.innerHTML = '<div class="t2s-loading">도구 호출 중...</div>';
try {
const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql: input });
if (res.success) {
t2sRenderTable(res);
} else {
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.error || '알 수 없는 오류'}</div>`;
}
} catch (err) {
resultContainer.innerHTML = `<div class="t2s-error">연결 오류: ${err.message}</div>`;
}
}
/* ── API 대화 기능 (새 페이지) ────────────────────────────────── */
/**
* apiChatSend - API 대화 메시지 전송
*/
async function apiChatSend() {
const input = document.getElementById('api-chat-input');
const message = input.value.trim();
if (!message) return;
// 입력 필드 비활성화
input.disabled = true;
document.getElementById('api-chat-send-btn').disabled = true;
// 사용자 메시지 추가
apiAddChatMessage('user', message);
input.value = '';
// 로딩 메시지 추가
const loadingId = 'api-chat-loading-' + Date.now();
apiAddChatMessage('system', '<span class="t2s-typing">처리 중...</span>', loadingId);
try {
// 1. 자연어 쿼리를 SQL로 변환
const parseRes = await api('POST', '/api/text-to-sql/parse', { query: message });
// 로딩 메시지 제거
const loadingEl = document.getElementById(loadingId);
if (loadingEl) loadingEl.remove();
if (!parseRes.success || !parseRes.sql) {
apiAddChatMessage('system', `<span class="t2s-error">SQL 변환 실패: ${parseRes.error || '알 수 없는 오류'}</span>`);
input.disabled = false;
document.getElementById('api-chat-send-btn').disabled = false;
input.focus();
return;
}
// SQL 표시
apiAddChatMessage('assistant', `📝 변환된 SQL:<br><pre class="api-chat-sql">${esc(parseRes.sql)}</pre>`);
// 2. SQL 자동 실행
apiAddChatMessage('assistant', '<span class="t2s-typing">쿼리 실행 중...</span>');
const executeRes = await api('POST', '/api/text-to-sql/execute', { sql: parseRes.sql, limit: 1000 });
// 로딩 메시지 제거
const loadMsgs = document.querySelectorAll('[id^="api-chat-loading-"]');
loadMsgs.forEach(el => el.remove());
if (!executeRes.success) {
apiAddChatMessage('system', `<span class="t2s-error">쿼리 실행 실패: ${executeRes.error || '알 수 없는 오류'}</span>`);
} else {
// 결과를 응답 창에 표시
apiRenderResponse(executeRes);
// 결과 수 표시
const totalCount = executeRes.totalCount || 0;
apiAddChatMessage('assistant', `✅ <b>${totalCount}</b>개 결과 조회 완료`);
}
} catch (err) {
// 로딩 메시지 제거
const loadMsgs = document.querySelectorAll('[id^="api-chat-loading-"]');
loadMsgs.forEach(el => el.remove());
apiAddChatMessage('system', `<span class="t2s-error">연결 오류: ${err.message}</span>`);
} finally {
// 입력 필드 활성화
input.disabled = false;
document.getElementById('api-chat-send-btn').disabled = false;
input.focus();
}
}
/**
* apiAddChatMessage - API 채팅 메시지 추가
*/
function apiAddChatMessage(type, content, id) {
const container = document.getElementById('api-chat-messages');
const msgId = id || 'api-msg-' + Date.now();
const div = document.createElement('div');
div.className = `api-chat-msg ${type}`;
div.id = msgId;
const bubble = document.createElement('div');
bubble.className = 'api-chat-bubble';
bubble.innerHTML = content;
div.appendChild(bubble);
container.appendChild(div);
// 스크롤 맨 아래로
const chatContainer = document.querySelector('.api-chat-messages');
chatContainer.scrollTop = chatContainer.scrollHeight;
return msgId;
}
/**
* t2sChatClear - 채팅 초기화
*/
function t2sChatClear() {
const container = document.getElementById('t2s-chat-messages');
container.innerHTML = `
<div class="t2s-chat-msg system">
<div class="t2s-chat-bubble">
<strong>시스템:</strong><br/>
자연어 질의를 입력하면 SQL로 변환하고 결과를 조회합니다.<br/>
예: "FICQ-6101.PV 최근 1시간 평균", "최대값 조회", "추세 분석"
</div>
</div>
`;
}
/* ─── Text-to-SQL MCP 도구 관련 함수 ───────────────────────── */
/**
* apiChatClear - API 채팅 초기화
*/
function apiChatClear() {
const container = document.getElementById('api-chat-messages');
container.innerHTML = `
<div class="api-chat-msg system">
<div class="api-chat-bubble">
<strong>시스템:</strong><br/>
API와 대화할 수 있습니다. 자연어로 질의를 입력하면 SQL로 변환하고 결과를 조회합니다.<br/>
예: "FICQ-6101.PV 최근 1시간 평균", "최대값 조회", "추세 분석"
</div>
</div>
`;
// 응답 창도 초기화
document.getElementById('api-response-content').innerHTML = '<span class="placeholder">응답이 여기에 표시됩니다</span>';
}
/**
* apiRenderResponse - API 응답을 응답 창에 표시
*/
function apiRenderResponse(data) {
const container = document.getElementById('api-response-content');
if (!data.rows || data.rows.length === 0) {
container.innerHTML = '<span class="placeholder">조회된 결과가 없습니다</span>';
return;
}
// 테이블 생성
let html = '<table class="api-response-table"><thead><tr>';
// 헤더 생성
const columns = Object.keys(data.rows[0]);
columns.forEach(col => {
html += `<th>${esc(col)}</th>`;
});
html += '</tr></thead><tbody>';
// 데이터 행 생성
data.rows.forEach(row => {
html += '<tr>';
columns.forEach(col => {
const value = row[col];
html += `<td>${value !== null && value !== undefined ? esc(String(value)) : ''}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
html += `<p style="margin-top:8px;font-size:12px;color:var(--t2)">총 ${data.totalCount || 0}개 결과</p>`;
container.innerHTML = html;
}

View File

@@ -0,0 +1,80 @@
/* ─────────────────────────────────────────────────────────────
15 OPC UA Write
───────────────────────────────────────────────────────────── */
async function wrWriteTag() {
const nodeId = document.getElementById('wr-nodeid').value.trim();
const value = parseFloat(document.getElementById('wr-value').value);
if (!nodeId) return log('wr-log', [{ c: 'err', t: '❌ Node ID를 입력하세요.' }]);
if (isNaN(value)) return log('wr-log', [{ c: 'err', t: '❌ 유효한 숫자를 입력하세요.' }]);
try {
const d = await api('POST', '/api/points/write', { nodeId, value });
log('wr-log', [
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Write ' + esc(nodeId) + ' = ' + value + ' → ' + esc(d.status) },
...(d.error ? [{ c: 'err', t: ' Error: ' + esc(d.error) }] : [])
]);
} catch (e) {
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);
}
}
async function wrSetMode() {
const nodeId = document.getElementById('wr-mode-nodeid').value.trim();
const mode = document.getElementById('wr-mode').value;
if (!nodeId) return log('wr-log', [{ c: 'err', t: '❌ Node ID를 입력하세요.' }]);
try {
const d = await api('POST', '/api/points/mode', { nodeId, mode });
log('wr-log', [
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Mode ' + esc(nodeId) + ' → ' + mode + ' (enum=' + d.enumValue + ') ' + esc(d.status) },
...(d.error ? [{ c: 'err', t: ' Error: ' + esc(d.error) }] : [])
]);
} catch (e) {
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);
}
}
async function wrControlOp() {
const tagName = document.getElementById('wr-ctrl-tag').value.trim();
const opValue = parseFloat(document.getElementById('wr-ctrl-op').value);
const restoreAuto = document.getElementById('wr-ctrl-restore').checked;
if (!tagName) return log('wr-log', [{ c: 'err', t: '❌ 태그명을 입력하세요.' }]);
if (isNaN(opValue)) return log('wr-log', [{ c: 'err', t: '❌ 유효한 OP 값을 입력하세요.' }]);
try {
const d = await api('POST', '/api/points/control', { tagName, opValue, restoreAuto });
const lines = [];
if (d.steps) {
for (const s of d.steps) {
lines.push({ c: s.success ? 'ok' : 'err', t: (s.success ? '✅ ' : '❌ ') + s.step + ': ' + esc(s.nodeId) + ' → ' + esc(s.status) + (s.value !== undefined ? ' (value=' + s.value + ')' : '') });
if (s.error) lines.push({ c: 'err', t: ' Error: ' + esc(s.error) });
}
}
lines.unshift({ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + '통합 제어 ' + esc(tagName) + ' OP=' + opValue + (d.error ? ' — ' + esc(d.error) : '') });
log('wr-log', lines);
} catch (e) {
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);
}
}
async function wrReadTag() {
const nodeId = document.getElementById('wr-read-nodeid').value.trim();
if (!nodeId) return;
try {
const d = await api('POST', '/api/points/read', { nodeId });
const box = document.getElementById('wr-read-result');
if (d.success) {
box.innerHTML = `
<div class="kv"><span class="kk">Node ID</span><span class="kv2">${esc(d.nodeId)}</span></div>
<div class="kv"><span class="kk">Value</span><span class="kv2 ok">${esc(d.value)}</span></div>
<div class="kv"><span class="kk">Status</span><span class="kv2">${esc(d.statusCode)}</span></div>
<div class="kv"><span class="kk">Timestamp</span><span class="kv2">${d.timestamp ? new Date(d.timestamp).toLocaleString('ko-KR') : '-'}</span></div>
`;
} else {
box.innerHTML = `<div class="kv"><span class="kk">Error</span><span class="kv2 err">${esc(d.error || 'Unknown')}</span></div>`;
}
} catch (e) {
document.getElementById('wr-read-result').innerHTML = `<div class="kv"><span class="kk">Error</span><span class="kv2 err">${esc(e.message)}</span></div>`;
}
}

View File

@@ -0,0 +1,36 @@
<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>

View File

@@ -0,0 +1,51 @@
<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=sinamserver: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 class="card">
<div class="card-cap">LLM 설정</div>
<div class="row-inp">
<input id="llm-model" class="inp flex1" placeholder="모델명" />
<button class="btn-b" onclick="llmLoadConfig()">🔄 불러오기</button>
<button class="btn-a" onclick="llmSaveModelConfig()">💾 저장</button>
</div>
<div id="llm-status" class="kv-box" style="margin-top:8px"></div>
</div>
<div id="conn-log" class="logbox hidden"></div>
<div id="browse-wrap" class="bwrap hidden"></div>

View File

@@ -0,0 +1,82 @@
<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=sinamserver: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">
서버 설정은 위 크롤링 설정을 그대로 사용합니다 &nbsp;·&nbsp;
노드 수에 따라 수 분이 소요될 수 있습니다 &nbsp;·&nbsp;
결과는 <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>

View File

@@ -0,0 +1,50 @@
<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>

View File

@@ -0,0 +1,37 @@
<header class="pane-hdr">
<div>
<h1>문서 탐색기</h1>
<p>프로젝트 폴더의 문서를 직접 보고 편집합니다. 뷰어: txt · md · pdf · 그 외 다운로드.</p>
</div>
<div class="pane-tag">DOCS</div>
</header>
<div class="docs-layout">
<!-- 좌: 파일 트리 -->
<aside class="docs-tree-panel">
<div class="docs-tree-toolbar">
<button class="btn-b btn-sm" onclick="docsRefresh()" title="새로고침"></button>
<input id="docs-filter" class="inp docs-filter" placeholder="이름 필터…" oninput="docsApplyFilter()"/>
<button class="btn-b btn-sm docs-admin-only" id="docs-mkdir-btn" onclick="docsMkdirPrompt()" title="새 폴더" hidden>📁+</button>
<button class="btn-b btn-sm docs-admin-only" id="docs-upload-btn" onclick="docsUploadPrompt()" title="업로드" hidden></button>
<input type="file" id="docs-upload-input" hidden onchange="docsUploadDo()"/>
</div>
<div class="docs-root-line" title="탐색 루트"><span id="docs-root-label" class="mono"></span></div>
<div id="docs-tree" class="docs-tree"></div>
<div class="docs-admin-bar">
<span id="docs-admin-state" class="docs-admin-state">🔒 읽기 전용</span>
<button class="btn-b btn-sm" id="docs-unlock-btn" onclick="docsUnlock()">관리 잠금해제</button>
</div>
</aside>
<!-- 우: 뷰어 -->
<div class="docs-viewer-panel">
<div class="docs-viewer-bar">
<span id="docs-cur-path" class="docs-cur-path mono">파일을 선택하세요</span>
<div class="docs-viewer-actions" id="docs-viewer-actions"></div>
</div>
<div id="docs-viewer" class="docs-viewer">
<div class="docs-empty">← 왼쪽에서 문서를 선택하세요</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,78 @@
<header class="pane-hdr">
<div>
<h1>이벤트 히스토리</h1>
<p>디지털 포인트 상태 변경 이벤트를 조회합니다. (event_history_table)</p>
</div>
<div class="pane-tag">EVENT / DIGITAL</div>
</header>
<!-- 조회 조건 카드 -->
<div class="card">
<div class="card-cap">조회 조건</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:13px">
<span>태그 필터</span>
<button class="btn-b btn-sm" onclick="evtLoadTags()">▼ 태그 목록 불러오기</button>
<span id="evt-tag-status" class="hist-status"></span>
</div>
<div class="fg">
<select id="ef-tag" class="inp">
<option value="">— 전체 태그 —</option>
</select>
</div>
<div class="cols-4">
<div class="fg">
<label>이벤트 타입</label>
<select id="ef-event-type" class="inp">
<option value="">전체</option>
<option value="TRIP">TRIP</option>
<option value="RUN">RUN</option>
<option value="ALARM">ALARM</option>
<option value="NORMAL">NORMAL</option>
<option value="CHANGE">CHANGE</option>
</select>
</div>
<div class="fg">
<label>Area <em>(예: P6)</em></label>
<input id="ef-area" class="inp" type="text" placeholder="비워두면 전체"/>
</div>
<div class="fg">
<label>Section <em>(예: 1-2차)</em></label>
<input id="ef-section" class="inp" type="text" placeholder="비워두면 전체"/>
</div>
<div class="fg">
<label>최대 행 수</label>
<input id="ef-limit" class="inp" type="number" value="500" min="10" max="5000"/>
</div>
</div>
<div class="cols-2">
<div class="fg">
<label>시작 시간</label>
<input type="hidden" id="hf-evt-from"/>
<div class="dt-display inp" id="dtp-evt-from-display" onclick="dtOpen('evt-from')">— 선택 안 함 —</div>
</div>
<div class="fg">
<label>종료 시간</label>
<input type="hidden" id="hf-evt-to"/>
<div class="dt-display inp" id="dtp-evt-to-display" onclick="dtOpen('evt-to')">— 선택 안 함 —</div>
</div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="evtQuery()">🔍 이벤트 조회</button>
<button class="btn-b" onclick="evtSummary()">📊 구간 요약</button>
<button class="btn-b" onclick="evtReset()">초기화</button>
</div>
</div>
<!-- 요약 결과 카드 -->
<div id="evt-summary-card" class="card hidden">
<div class="card-cap">구간별 이벤트 요약</div>
<div id="evt-summary-content"></div>
</div>
<!-- 조회 결과 -->
<div id="evt-result-info" class="nm-result-info hidden" style="margin:8px 0"></div>
<div id="evt-table" class="tbl-wrap hidden"></div>

View File

@@ -0,0 +1,44 @@
<header class="pane-hdr">
<div>
<h1>fastRecord</h1>
<p>고속 샘플링으로 실시간 데이터를 수집하고 트렌드를 분석합니다.</p>
</div>
<div class="pane-tag">FAST / RECORD</div>
</header>
<!-- 세션 목록 (가로 카드) -->
<div class="card" style="margin-bottom:12px">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span>세션 목록</span>
<div style="display:flex;gap:6px;align-items:center">
<span id="fast-db-status" style="font-size:11px;color:var(--t3)">DB 미연결</span>
<button id="btn-fast-db-connect" class="btn-b btn-sm">DB 접속</button>
<button id="btn-fast-new" class="btn-a btn-sm">+ 신규</button>
</div>
</div>
<div id="fast-session-list" style="display:flex;flex-wrap:wrap;gap:8px;padding:8px 4px;min-height:52px"></div>
</div>
<!-- 차트 카드 -->
<div class="card">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span id="fast-session-title">세션 상세</span>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button id="btn-fast-stop" class="btn-b btn-sm" style="display:none">■ 중지</button>
<button id="btn-fast-export-xlsx" class="btn-a btn-sm" style="display:none">Excel</button>
<button id="btn-fast-export-csv" class="btn-b btn-sm" style="display:none">CSV</button>
<button id="btn-fast-pin" class="btn-b btn-sm" style="display:none">고정</button>
<button id="btn-fast-delete" class="btn-b btn-sm" style="display:none;color:var(--red,#e55)">삭제</button>
</div>
</div>
<!-- 진행률 바 -->
<div style="height:6px;background:var(--s3);border-radius:3px;margin-bottom:4px">
<div id="fast-progress-bar" style="height:100%;width:0%;background:#4caf50;border-radius:3px;transition:width .5s"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--t2);margin-bottom:10px">
<span id="fast-progress-text">0 / 0 (0%)</span>
<span id="fast-elapsed-time">경과: 0s</span>
</div>
<!-- uPlot 차트 -->
<div id="fast-chart-container" style="min-height:380px"></div>
</div>

View File

@@ -0,0 +1,148 @@
<header class="pane-hdr">
<div>
<h1>이력 조회</h1>
<p>history_table 의 시계열 데이터를 조회합니다.</p>
</div>
<div class="pane-tag">HISTORY / TREND</div>
</header>
<div class="card">
<div class="card-cap">조회 조건</div>
<div class="fg">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>태그 선택 <em>(최대 8개, OR 조건)</em></span>
<button class="btn-b btn-sm" onclick="histLoad()">▼ 옵션 불러오기</button>
<span id="hist-load-status" class="hist-status">대기 중<span class="status-dot"></span></span>
</div>
<div class="pb-name-grid">
<select id="hf-t1" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t2" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t3" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t4" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t5" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t6" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t7" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t8" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
</div>
</div>
<div class="cols-4">
<div class="fg">
<label>시작 시간</label>
<input type="hidden" id="hf-from"/>
<div class="dt-display inp" id="dtp-from-display" onclick="dtOpen('from')">— 선택 안 함 —</div>
</div>
<div class="fg">
<label>종료 시간</label>
<input type="hidden" id="hf-to"/>
<div class="dt-display inp" id="dtp-to-display" onclick="dtOpen('to')">— 선택 안 함 —</div>
</div>
<div class="fg">
<label>조회 간격</label>
<select id="hf-interval" class="inp">
<option value="1 minute">원시 데이터 (기본)</option>
<option value="5 minutes">5분 집계</option>
<option value="10 minutes">10분 집계</option>
<option value="30 minutes">30분 집계</option>
<option value="1 hour">1시간 집계</option>
<option value="1 day">1일 집계</option>
</select>
</div>
<div class="fg">
<label>최대 행 수</label>
<input id="hf-limit" class="inp" type="number" value="500" min="10" max="5000"/>
</div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="histQuery()">🔍 조회</button>
<button class="btn-b" onclick="histReset()">초기화</button>
</div>
</div>
<!-- 하이퍼테이블 관리 -->
<div class="card" id="ht-manage-card">
<div class="card-cap">하이퍼테이블 관리</div>
<div class="fg">
<label>history_table이 현재 하이퍼테이블 상태입니다. 아래 옵션을 설정하여 수동으로 생성할 수 있습니다.</label>
</div>
<div class="fg">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="ht-auto-retention" onchange="htToggleRetention()"/>
보관 기간 설정
</label>
<div id="ht-retention-panel" class="ht-hidden" style="margin-top:8px;padding-left:20px">
<div class="cols-2">
<div>
<label>보관 기간</label>
<input id="ht-retention-period" class="inp" type="text" value="90 days" placeholder="예: 90 days"/>
</div>
<div>
<label>테이블명</label>
<input id="ht-table-name" class="inp" type="text" value="history_table" placeholder="테이블명"/>
</div>
</div>
</div>
</div>
<div class="fg" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="ht-auto-compression" onchange="htToggleCompression()"/>
압축 활성화
</label>
<div id="ht-compression-panel" class="ht-hidden" style="margin-top:8px;padding-left:20px">
<div>
<label>압축 구간</label>
<input id="ht-compression-period" class="inp" type="text" value="1 day" placeholder="예: 1 day"/>
</div>
</div>
</div>
<div class="fg" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="ht-auto-aggregate"/>
연속 집계 생성 (선택사항)
</label>
</div>
<div class="btn-row" style="margin-top:16px">
<button class="btn-a" onclick="htCreate()">🔧 하이퍼테이블 생성</button>
<button class="btn-b" onclick="htLoadStatus()">🔄 상태 새로고침</button>
</div>
</div>
<!-- 하이퍼테이블 상태 표시 -->
<div id="ht-status-box" class="ht-status-box hidden">
<div class="ht-status-header">
<span class="ht-status-icon" id="ht-status-icon"></span>
<span class="ht-status-text" id="ht-status-text">로딩 중</span>
</div>
<div class="ht-status-detail" id="ht-status-detail"></div>
<div class="ht-info-panel" id="ht-info-panel">
<div class="ht-info-grid">
<div class="ht-info-item">
<span class="ht-info-label">테이블명</span>
<span class="ht-info-value" id="ht-info-table">-</span>
</div>
<div class="ht-info-item">
<span class="ht-info-label">레코드 수</span>
<span class="ht-info-value" id="ht-info-records">-</span>
</div>
<div class="ht-info-item">
<span class="ht-info-label">보관 정책</span>
<span class="ht-info-value" id="ht-info-retention">-</span>
</div>
<div class="ht-info-item">
<span class="ht-info-label">압축</span>
<span class="ht-info-value" id="ht-info-compression">-</span>
</div>
</div>
</div>
</div>
<!-- 상태 표시 창 -->
<div id="hist-status-box" class="hist-status-box hidden">
<div class="hist-status-header">
<span class="hist-status-icon" id="hist-status-icon"></span>
<span class="hist-status-text" id="hist-status-text">대기 중</span>
</div>
<div class="hist-status-detail" id="hist-status-detail"></div>
</div>
<div id="hist-result-info" class="nm-result-info hidden" style="margin:8px 0"></div>
<div id="hist-table" class="tbl-wrap hidden"></div>

View File

@@ -0,0 +1,158 @@
<header class="pane-hdr">
<div>
<h1>RAG 관리</h1>
<p>지식 베이스 문서 업로드 / 인덱싱 / 관리 — 관리자 비밀번호 필요.</p>
</div>
<div class="pane-tag">KB / RAG</div>
</header>
<!-- 로그인 카드 -->
<div id="kb-login-card" class="card kb-login-card">
<div class="card-cap">🔒 관리자 로그인</div>
<div class="fg" style="max-width:360px">
<label>비밀번호</label>
<input id="kb-pw" class="inp" type="password" placeholder="관리자 비밀번호"
onkeydown="if(event.key==='Enter')kbLogin()"/>
</div>
<div class="btn-row">
<button class="btn-a btn-sm" onclick="kbLogin()">로그인</button>
<span id="kb-login-msg" class="kb-msg"></span>
</div>
</div>
<!-- 메인 패널 (로그인 후) -->
<div id="kb-main" class="kb-main hidden">
<div class="kb-topbar">
<div class="kb-session">
<span class="dot grn"></span>
<span class="mono" id="kb-session-info">세션: --:--</span>
<button class="btn-b btn-sm" onclick="kbChangePwOpen()">비밀번호 변경</button>
<button class="btn-b btn-sm" onclick="kbLogout()">로그아웃</button>
</div>
<div class="kb-actions">
<button class="btn-a btn-sm" onclick="kbUploadOpen()">📁 파일 업로드</button>
<button class="btn-b btn-sm" onclick="kbRefresh()">🔄 새로고침</button>
</div>
</div>
<!-- Field Instrument 초안 생성 -->
<div class="card kb-infer-card" style="margin-top:12px">
<div class="card-cap">Field Instrument 자동 유추</div>
<div class="kb-infer-body">
<p class="kb-infer-desc">DCS 태그 ficq-6101 → FT-6101, FIC-6101, FCV-6101 등 현장 계기 자동 유추 후 Excel 초안 생성.</p>
<div class="kb-infer-options">
<label class="kb-toggle" title="Phase C에서 활성화 예정입니다">
<input type="checkbox" id="kb-infer-llm" disabled/>
<span>LLM description 보강 (Phase C에서 활성화)</span>
</label>
</div>
<div class="btn-row">
<button class="btn-a btn-sm" id="kb-infer-btn" onclick="kbInferStart()">초안 생성</button>
<span id="kb-infer-msg" class="kb-msg"></span>
</div>
<div id="kb-infer-result" class="kb-infer-result hidden"></div>
</div>
</div>
<!-- 필터 -->
<div class="card kb-filters">
<div class="cols-3">
<div class="fg">
<label>컬렉션</label>
<select id="kb-f-coll" class="inp" onchange="kbRefresh()">
<option value="">전체</option>
</select>
</div>
<div class="fg">
<label>상태</label>
<select id="kb-f-status" class="inp" onchange="kbRefresh()">
<option value="">전체</option>
<option value="pending">pending</option>
<option value="parsing">parsing</option>
<option value="embedding">embedding</option>
<option value="indexed">indexed</option>
<option value="failed">failed</option>
<option value="disabled">disabled</option>
</select>
</div>
<div class="fg">
<label>제목 검색</label>
<input id="kb-f-q" class="inp" placeholder="제목 일부..."
onkeydown="if(event.key==='Enter')kbRefresh()"/>
</div>
</div>
</div>
<!-- 목록 -->
<div id="kb-doc-stats" class="kb-stats"></div>
<div id="kb-doc-table" class="tbl-wrap"></div>
<div class="btn-row" style="margin-top:8px">
<button class="btn-b btn-sm" onclick="kbBulkDisable()">🚫 동일 제목 일괄 비활성화</button>
<button class="btn-b btn-sm" onclick="kbPurgeDisabled()">🗑 비활성화 영구삭제</button>
</div>
</div>
<!-- 업로드 모달 -->
<div id="kb-upload-modal" class="kb-modal hidden" onclick="if(event.target===this)kbUploadClose()">
<div class="kb-modal-body">
<div class="kb-modal-title">📁 KB 문서 업로드</div>
<div class="fg">
<label>컬렉션 <em>*</em></label>
<select id="kb-up-coll" class="inp">
<option value="">-- 선택 --</option>
</select>
</div>
<div class="fg">
<label>제목 <em>(비워두면 파일명 사용)</em></label>
<input id="kb-up-title" class="inp" placeholder="제목"/>
</div>
<div class="fg">
<label>태그 <em>(콤마 분리, 예: unit-a, P-6201)</em></label>
<input id="kb-up-tags" class="inp" placeholder="unit-a, P-6201"/>
</div>
<div class="fg">
<label>파일</label>
<input id="kb-up-file" class="inp" type="file"/>
</div>
<div id="kb-up-msg" class="kb-msg"></div>
<div class="btn-row">
<button class="btn-a btn-sm" onclick="kbUploadSubmit()">업로드</button>
<button class="btn-b btn-sm" onclick="kbUploadClose()">취소</button>
</div>
</div>
</div>
<!-- 청크 미리보기 모달 -->
<div id="kb-chunk-modal" class="kb-modal hidden" onclick="if(event.target===this)kbChunkCloseModal()">
<div class="kb-modal-body kb-chunk-modal-body">
<div class="kb-modal-title" id="kb-chunk-title">🔍 청크 미리보기</div>
<div id="kb-chunk-body" class="kb-chunk-body">
<div class="placeholder">불러오는 중...</div>
</div>
<div class="btn-row">
<button class="btn-b btn-sm" onclick="kbChunkCloseModal()">닫기</button>
</div>
</div>
</div>
<!-- 비밀번호 변경 모달 -->
<div id="kb-pw-modal" class="kb-modal hidden" onclick="if(event.target===this)kbChangePwClose()">
<div class="kb-modal-body">
<div class="kb-modal-title">🔐 비밀번호 변경</div>
<div class="fg">
<label>현재 비밀번호</label>
<input id="kb-pw-old" class="inp" type="password"/>
</div>
<div class="fg">
<label>새 비밀번호 <em>(6자 이상)</em></label>
<input id="kb-pw-new" class="inp" type="password"/>
</div>
<div id="kb-pw-msg" class="kb-msg"></div>
<div class="btn-row">
<button class="btn-a btn-sm" onclick="kbChangePwSubmit()">변경</button>
<button class="btn-b btn-sm" onclick="kbChangePwClose()">취소</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,93 @@
<header class="pane-hdr">
<div>
<h1>로컬 LLM 채팅</h1>
<p>로컬 Ollama 서버에 연결하여 LLM과 대화합니다.</p>
</div>
<div class="pane-tag">LLM / CHAT</div>
</header>
<div class="llm-layout">
<div class="llm-sidebar">
<div class="llm-sidebar-header">
<span class="llm-sidebar-title">대화 목록</span>
<button class="btn-a btn-sm" onclick="llmNewSession()" title="새 대화"></button>
</div>
<div id="llm-session-list" class="llm-session-list">
<div class="llm-empty">대화가 없습니다. + 버튼을 눌러 새 대화를 시작하세요.</div>
</div>
<div class="llm-sidebar-footer">
<button class="btn-b btn-sm" onclick="llmExportAll()" style="width:100%">📋 전체 내보내기</button>
</div>
</div>
<div class="llm-main">
<div class="llm-header">
<div class="llm-header-left">
<div class="fg" style="margin:0;width:100px">
<label>LLM</label>
<select id="llm-type-select" class="inp" onchange="llmOnTypeChange()">
<option value="ollama">Ollama</option>
<option value="vllm">vLLM</option>
</select>
</div>
<div class="fg" style="margin:0;width:160px">
<label>모델</label>
<select id="llm-model-select" class="inp" onchange="llmSaveSessionMeta()">
<option value="">-- 모델을 선택하세요 --</option>
</select>
</div>
<button class="btn-b btn-sm" onclick="llmLoadModels()">🔄 갱신</button>
<label id="llm-tools-row" class="ck" style="margin:0;font-size:13px;display:none">
<input type="checkbox" id="llm-use-tools" onchange="llmToggleTools()" checked>
MCP 도구
</label>
<label id="llm-agent-row" class="ck" style="margin:0;font-size:13px;display:none" title="복합 태스크를 단계별로 계획하고 도구를 자율 호출합니다">
<input type="checkbox" id="llm-agent-mode" onchange="llmToggleAgentMode()">
🤖 에이전트 모드
</label>
<span id="llm-conn-status" class="llm-conn-dot" title="연결 상태"></span>
</div>
<div class="llm-header-right">
<button class="btn-b btn-sm" onclick="llmClearSession()">🗑 초기화</button>
<button class="btn-b btn-sm" onclick="llmToggleSettings()">⚙ 설정</button>
</div>
</div>
<div id="llm-settings-panel" class="llm-settings hidden">
<div class="cols-2">
<div class="fg"><label>Ollama Host</label>
<input id="llm-host" class="inp" value="localhost"/></div>
<div class="fg"><label>Port</label>
<input id="llm-port" class="inp" type="number" value="11434"/></div>
</div>
<div class="fg"><label>시스템 프롬프트</label>
<textarea id="llm-system-prompt" class="ta" rows="3"
placeholder="예: 너는 산업 자동화 분야의 전문가입니다."></textarea>
</div>
<div class="btn-row">
<button class="btn-a btn-sm" onclick="llmSaveConfig()">💾 설정 저장</button>
<button class="btn-b btn-sm" onclick="llmTestConnection()">🔌 연결 테스트</button>
</div>
</div>
<div id="llm-messages" class="llm-messages">
<div class="llm-welcome">
<div class="llm-welcome-icon">💬</div>
<div class="llm-welcome-text">새 대화를 시작하세요</div>
<div class="llm-welcome-hint">모델을 선택하고 메시지를 입력하세요</div>
</div>
</div>
<div class="llm-input-area">
<div class="llm-input-box">
<textarea id="llm-input" class="llm-textarea" rows="1"
placeholder="메시지를 입력하세요... (Shift+Enter: 줄바꿈, Enter: 전송)"
onkeydown="llmInputKeydown(event)"></textarea>
<div class="llm-input-btns">
<button id="llm-send-btn" class="btn-a btn-sm" onclick="llmSend()">전송</button>
<button id="llm-stop-btn" class="btn-b btn-sm" onclick="llmStop()" style="display:none">중단</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,73 @@
<header class="pane-hdr">
<div>
<h1>노드맵 대시보드</h1>
<p>node_map_master 테이블을 조회합니다.</p>
</div>
<div class="pane-tag">NODE MAP / MASTER</div>
</header>
<!-- 필터 카드 -->
<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>데이터 타입 <em>(직접 입력)</em></label>
<input id="nf-dtype" class="inp" placeholder="예: Double, Int32"/>
</div>
</div>
<!-- 이름 OR 조건 선택 (최대 4개) — 불러오기 버튼으로 옵션 채움 -->
<div class="fg nm-name-row">
<label style="display:flex;align-items:center;gap:8px">
이름 선택 <em>(OR 조건, 최대 4개)</em>
<button class="btn-b btn-sm" onclick="nmLoadNames()" style="margin-left:4px">▼ 옵션 불러오기</button>
</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>

View File

@@ -0,0 +1,25 @@
<header class="pane-hdr">
<div>
<h1>OPC UA 서버</h1>
<p class="sub">ExperionCrawler를 OPC UA 서버로 동작시켜 외부 클라이언트에 실시간 값을 제공합니다.</p>
</div>
</header>
<!-- 상태 카드 -->
<div class="srv-status-card" id="srv-status-card">
<div class="srv-status-row">
<span class="dot" id="srv-dot"></span>
<span id="srv-status-txt" class="srv-label">상태 조회 중...</span>
</div>
<div class="srv-meta" id="srv-meta"></div>
</div>
<!-- 버튼 행 -->
<div class="row-btns" style="margin-top:12px">
<button class="btn-a" onclick="srvStart()">▶ 서버 시작</button>
<button class="btn-b" onclick="srvStop()">■ 서버 중지</button>
<button class="btn-b" onclick="srvRebuild()">↺ 주소공간 재구성</button>
<button class="btn-b" onclick="srvLoad()">↻ 상태 새로고침</button>
</div>
<div id="srv-log" class="log-box hidden" style="margin-top:16px"></div>

View File

@@ -0,0 +1,311 @@
<header class="pane-hdr">
<div>
<h1>포인트빌더</h1>
<p>node_map_master 에서 실시간 모니터링할 포인트를 선택해 realtime_table 을 구성합니다.</p>
</div>
<div class="pane-tag">REALTIME / BUILD</div>
</header>
<!-- 빌더 카드 -->
<div class="cols-2">
<div class="card">
<div class="card-cap">조건으로 테이블 작성</div>
<div id="pb-build-log" class="logbox hidden" style="margin-bottom:10px"></div>
<!-- 컨트롤러 포인트 #1 -->
<div class="pb-group-card" id="pb-group-controller1">
<div class="pb-group-header">
<span class="card-sub-cap">컨트롤러 포인트 #1</span>
</div>
<div class="fg">
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
<input class="pb-pattern-input inp" data-group="controller1" data-field="tagPatterns" placeholder="예: %ctl-61%.pv, %ctl-62%.sp"/>
</div>
<div class="pb-attr-checkboxes">
<label><input type="checkbox" data-group="controller1" data-field="attributes" value="pv"/> pv</label>
<label><input type="checkbox" data-group="controller1" data-field="attributes" value="op"/> op</label>
<label><input type="checkbox" data-group="controller1" data-field="attributes" value="sp"/> sp</label>
<label><input type="checkbox" data-group="controller1" data-field="attributes" value="md"/> md</label>
</div>
<div class="pb-custom-attr-inputs">
<input class="inp" data-group="controller1" data-field="customAttrs" placeholder="추가 속성 1"/>
<input class="inp" data-group="controller1" data-field="customAttrs" placeholder="추가 속성 2"/>
</div>
<div class="fg">
<select class="pb-datatype-select inp" data-group="controller1" data-field="dataType">
<option value="">전체</option>
<option value="Double">Double</option>
<option value="i=7594">i=7594</option>
<option value="Boolean">Boolean</option>
<option value="String">String</option>
<option value="Int16">Int16</option>
<option value="Int32">Int32</option>
<option value="UInt16">UInt16</option>
<option value="UInt32">UInt32</option>
<option value="Float">Float</option>
<option value="DateTime">DateTime</option>
</select>
</div>
</div>
<!-- 아날로그 모니터링 포인트 #2 -->
<div class="pb-group-card" id="pb-group-analogmon1">
<div class="pb-group-header">
<span class="card-sub-cap">아날로그 모니터링 포인트 #2</span>
</div>
<div class="fg">
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
<input class="pb-pattern-input inp" data-group="analogmon1" data-field="tagPatterns" placeholder="예: %ti-61%.pv, %pi-62%.pv"/>
</div>
<div class="pb-attr-checkboxes">
<label><input type="checkbox" data-group="analogmon1" data-field="attributes" value="pv"/> pv</label>
<label><input type="checkbox" data-group="analogmon1" data-field="attributes" value="op"/> op</label>
<label><input type="checkbox" data-group="analogmon1" data-field="attributes" value="sp"/> sp</label>
<label><input type="checkbox" data-group="analogmon1" data-field="attributes" value="md"/> md</label>
</div>
<div class="pb-custom-attr-inputs">
<input class="inp" data-group="analogmon1" data-field="customAttrs" placeholder="추가 속성 1"/>
<input class="inp" data-group="analogmon1" data-field="customAttrs" placeholder="추가 속성 2"/>
</div>
<div class="fg">
<select class="pb-datatype-select inp" data-group="analogmon1" data-field="dataType">
<option value="">전체</option>
<option value="Double">Double</option>
<option value="i=7594">i=7594</option>
<option value="Boolean">Boolean</option>
<option value="String">String</option>
<option value="Int16">Int16</option>
<option value="Int32">Int32</option>
<option value="UInt16">UInt16</option>
<option value="UInt32">UInt32</option>
<option value="Float">Float</option>
<option value="DateTime">DateTime</option>
</select>
</div>
</div>
<!-- 디지털 포인트 #1 -->
<div class="pb-group-card" id="pb-group-digital1">
<div class="pb-group-header">
<span class="card-sub-cap">디지털 포인트 #1</span>
</div>
<div class="fg">
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
<input class="pb-pattern-input inp" data-group="digital1" data-field="tagPatterns" placeholder="예: %ys-61%.pv, %yt-62%.pv"/>
</div>
<div class="pb-attr-checkboxes">
<label><input type="checkbox" data-group="digital1" data-field="attributes" value="pv"/> pv</label>
<label><input type="checkbox" data-group="digital1" data-field="attributes" value="op"/> op</label>
<label><input type="checkbox" data-group="digital1" data-field="attributes" value="sp"/> sp</label>
<label><input type="checkbox" data-group="digital1" data-field="attributes" value="md"/> md</label>
</div>
<div class="pb-custom-attr-inputs">
<input class="inp" data-group="digital1" data-field="customAttrs" placeholder="추가 속성 1"/>
<input class="inp" data-group="digital1" data-field="customAttrs" placeholder="추가 속성 2"/>
</div>
<div class="fg">
<select class="pb-datatype-select inp" data-group="digital1" data-field="dataType">
<option value="">전체</option>
<option value="Double">Double</option>
<option value="i=7594">i=7594</option>
<option value="Boolean">Boolean</option>
<option value="String">String</option>
<option value="Int16">Int16</option>
<option value="Int32">Int32</option>
<option value="UInt16">UInt16</option>
<option value="UInt32">UInt32</option>
<option value="Float">Float</option>
<option value="DateTime">DateTime</option>
</select>
</div>
</div>
<!-- 디지털 포인트 #2 -->
<div class="pb-group-card" id="pb-group-digital2">
<div class="pb-group-header">
<span class="card-sub-cap">디지털 포인트 #2</span>
</div>
<div class="fg">
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
<input class="pb-pattern-input inp" data-group="digital2" data-field="tagPatterns" placeholder="예: %ys-63%.pv, %yt-64%.pv"/>
</div>
<div class="pb-attr-checkboxes">
<label><input type="checkbox" data-group="digital2" data-field="attributes" value="pv"/> pv</label>
<label><input type="checkbox" data-group="digital2" data-field="attributes" value="op"/> op</label>
<label><input type="checkbox" data-group="digital2" data-field="attributes" value="sp"/> sp</label>
<label><input type="checkbox" data-group="digital2" data-field="attributes" value="md"/> md</label>
</div>
<div class="pb-custom-attr-inputs">
<input class="inp" data-group="digital2" data-field="customAttrs" placeholder="추가 속성 1"/>
<input class="inp" data-group="digital2" data-field="customAttrs" placeholder="추가 속성 2"/>
</div>
<div class="fg">
<select class="pb-datatype-select inp" data-group="digital2" data-field="dataType">
<option value="">전체</option>
<option value="Double">Double</option>
<option value="i=7594">i=7594</option>
<option value="Boolean">Boolean</option>
<option value="String">String</option>
<option value="Int16">Int16</option>
<option value="Int32">Int32</option>
<option value="UInt16">UInt16</option>
<option value="UInt32">UInt32</option>
<option value="Float">Float</option>
<option value="DateTime">DateTime</option>
</select>
</div>
</div>
<!-- 사용자 정의 -->
<div class="pb-group-card" id="pb-group-custom">
<div class="pb-group-header">
<span class="card-sub-cap">사용자 정의</span>
</div>
<div class="fg">
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
<input class="pb-pattern-input inp" data-group="custom" data-field="tagPatterns" placeholder="예: %custom%.pv"/>
</div>
<div class="pb-attr-checkboxes">
<label><input type="checkbox" data-group="custom" data-field="attributes" value="pv"/> pv</label>
<label><input type="checkbox" data-group="custom" data-field="attributes" value="op"/> op</label>
<label><input type="checkbox" data-group="custom" data-field="attributes" value="sp"/> sp</label>
<label><input type="checkbox" data-group="custom" data-field="attributes" value="md"/> md</label>
</div>
<div class="pb-custom-attr-inputs">
<input class="inp" data-group="custom" data-field="customAttrs" placeholder="추가 속성 1"/>
<input class="inp" data-group="custom" data-field="customAttrs" placeholder="추가 속성 2"/>
</div>
<div class="fg">
<select class="pb-datatype-select inp" data-group="custom" data-field="dataType">
<option value="">전체</option>
<option value="Double">Double</option>
<option value="i=7594">i=7594</option>
<option value="Boolean">Boolean</option>
<option value="String">String</option>
<option value="Int16">Int16</option>
<option value="Int32">Int32</option>
<option value="UInt16">UInt16</option>
<option value="UInt32">UInt32</option>
<option value="Float">Float</option>
<option value="DateTime">DateTime</option>
</select>
</div>
</div>
<div class="btn-row">
<button class="btn-b" onclick="pbPreview()">🔍 미리보기</button>
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
<button class="btn-b" onclick="pbRefresh()">📋 테이블 조회</button>
</div>
<div id="pb-preview" class="pb-preview hidden">
<div class="pb-preview-header">
<span>미리보기 결과 <span id="pb-preview-count" class="mut">(0개)</span></span>
<div class="pb-preview-actions">
<button class="btn-sm btn-b" onclick="pbPreviewSelectAll()">전체 선택</button>
<button class="btn-sm btn-b" onclick="pbPreviewDeselectAll()">전체 해제</button>
<button class="btn-sm btn-b" onclick="pbPreviewInvert()">역전</button>
<span id="pb-preview-selected" class="mut">선택: 0/0</span>
</div>
</div>
<div class="fg" style="margin-bottom:8px">
<input class="inp" id="pb-preview-search" placeholder="태그명으로 검색..." oninput="pbPreviewFilter()"/>
</div>
<div id="pb-preview-table" class="tbl-wrap" style="max-height:420px;overflow:auto"></div>
<div class="btn-row" style="margin-top:10px;margin-bottom:0">
<button class="btn-b" onclick="pbCancelPreview()">취소</button>
<button class="btn-a" id="pb-apply-btn" onclick="pbApplySelected()">✓ 선택된 포인트 적용하기</button>
<button class="btn-a" onclick="pbAppendSelected()"> 기존 데이터에 추가하기</button>
</div>
</div>
</div>
<div class="card">
<div class="card-cap">수동 포인트 추가</div>
<div class="fg">
<label>Node ID 직접 입력</label>
<input id="pb-manual-nid" class="inp" placeholder="ns=1;s=tagname.pv..."/>
</div>
<button class="btn-b" onclick="pbAddManual()"> 추가</button>
<div id="pb-manual-log" class="logbox hidden" style="margin-top:10px"></div>
<div class="card-cap" style="margin-top:20px">실시간 구독 제어</div>
<div class="cols-2" style="gap:8px;margin-bottom:10px">
<div class="fg">
<label>서버 IP</label>
<input id="pb-rt-ip" class="inp" value="192.168.0.20"/>
</div>
<div class="fg">
<label>포트</label>
<input id="pb-rt-port" class="inp" type="number" value="4840"/>
</div>
<div class="fg">
<label>클라이언트 호스트</label>
<input id="pb-rt-client" class="inp" value="dbsvr"/>
</div>
<div class="fg">
<label>계정</label>
<input id="pb-rt-user" class="inp" value="mngr"/>
</div>
<div class="fg" style="grid-column:1/-1">
<label>비밀번호</label>
<input id="pb-rt-pw" class="inp" type="password" value="mngr"/>
</div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="rtStart()">▶ 구독 시작</button>
<button class="btn-b" onclick="rtStop()">■ 구독 중지</button>
<button class="btn-b btn-sm" onclick="rtStatus()">상태 확인</button>
</div>
<div id="pb-rt-status" class="logbox hidden" style="margin-top:8px"></div>
</div>
<!-- 메타데이터 관리 -->
<div class="card" style="margin-top:18px">
<div class="card-cap">메타데이터 관리</div>
<p style="color:var(--t2);font-size:13px;margin-bottom:12px">
태그의 desc, area, state descriptor 정보를 OPC UA에서 조회하여 저장합니다.
</p>
<div class="btn-row">
<button class="btn-a" onclick="metaReload()">🔄 메타데이터 갱신</button>
<button class="btn-b" onclick="metaView()">📋 메타데이터 조회</button>
</div>
<div id="meta-log" class="logbox hidden" style="margin-top:8px"></div>
<div id="meta-view" class="hidden" style="margin-top:10px;max-height:300px;overflow:auto"></div>
</div>
<!-- Sub-Area 관리 -->
<div class="card" style="margin-top:18px">
<div class="card-cap">Sub-Area (세부 Area) 관리</div>
<p style="color:var(--t2);font-size:13px;margin-bottom:12px">
area(P6 등)를 Column 단위 sub_area(P6-1/P6-2)로 분류합니다. 공용 설비는 <code>P6-1,P6-2</code>처럼
두 코드가 함께 부여되어 어느 쪽 필터에도 잡힙니다. Seed는 번호 prefix 규칙 + pid_equipment 공용 검출을 사용합니다.
</p>
<div class="btn-row" style="align-items:center;gap:8px">
<label style="color:var(--t2);font-size:13px">Area:</label>
<select id="subarea-area-select" class="inp" style="max-width:220px">
<option value="P6">P6 (6차 플랜트)</option>
<option value="P9">P9 (9차 플랜트)</option>
<option value="P10">P10 (10차 플랜트)</option>
<option value="P1">P1 (1차 플랜트)</option>
<option value="P2">P2 (2차 플랜트)</option>
</select>
<button class="btn-b" onclick="subAreaLoad()">📋 조회</button>
<button class="btn-b" onclick="subAreaSeed(true)">🧪 Seed DryRun</button>
<button class="btn-a" onclick="subAreaSeed(false)">⚙️ Seed 실행</button>
</div>
<div id="subarea-log" class="logbox hidden" style="margin-top:8px"></div>
<div id="subarea-view" class="hidden" style="margin-top:10px;max-height:360px;overflow:auto"></div>
</div>
</div>
<!-- 포인트 목록 -->
<div class="card" style="margin-top:0">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span>포인트 목록 <span id="pb-count" class="mut">(0개)</span></span>
<button class="btn-b btn-sm" onclick="pbRefresh()">↻ 새로 고침</button>
</div>
<div id="pb-table" class="tbl-wrap">
<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>
</div>
</div>

View File

@@ -0,0 +1,112 @@
<header class="pane-hdr">
<div>
<h1>P&ID 추출</h1>
<p>DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출합니다.</p>
</div>
<div class="pane-tag">P&ID / AI</div>
</header>
<!-- Step 1: PC → 서버 전송 -->
<div class="card" style="margin-bottom:12px">
<div class="card-cap">① PC → 서버 파일 전송</div>
<div class="fg" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" class="inp" id="pid-file-input" accept=".dxf,.pdf" style="flex:1;min-width:200px"/>
<button class="btn-a" onclick="pidUpload()" style="white-space:nowrap">📤 서버로 전송</button>
</div>
<div style="margin-top:6px;font-size:12px;color:var(--t2)">
<span id="pid-upload-status">파일을 선택하고 서버로 전송하세요.</span>
</div>
</div>
<!-- Step 2: 서버 파일 선택 → 추출 -->
<div class="card" style="margin-bottom:12px">
<div class="card-cap">② 서버 파일 선택 → 추출</div>
<div class="fg" style="margin-bottom:8px">
<select class="inp" id="pid-server-file">
<option value="">-- 서버 파일 목록 로드 필요 --</option>
</select>
</div>
<div class="btn-row">
<button class="btn-b" onclick="pidLoadServerFiles()">🔄 목록 갱신</button>
<button class="btn-a" onclick="pidExtract()">🚀 추출 시작</button>
<button class="btn-b" onclick="pidAnalyzeConnections()">🔗 연결 분석</button>
<button class="btn-b" onclick="pidClearLog()">로그 지우기</button>
<button class="btn-b" style="margin-left:auto;border-color:var(--red,#e55);color:var(--red,#e55)" onclick="pidDeleteAll()">🗑 전체 삭제</button>
</div>
<div style="margin-top:6px;font-size:12px;color:var(--t2);display:flex;align-items:center;gap:8px">
<span id="pid-status">대기 중...</span>
<span id="pid-elapsed" style="font-family:var(--mono);display:none"></span>
</div>
<div id="pid-log" class="log-box" style="margin-top:8px;max-height:120px;overflow-y:auto;display:none"></div>
</div>
<!-- Prefix 분류 정의 (접이식) -->
<div class="card" style="margin-bottom:12px">
<div class="card-cap" onclick="pidTogglePrefixPanel()" style="cursor:pointer;user-select:none">
<span>Prefix 분류 정의 <span id="pid-prefix-toggle"></span></span>
<span style="font-weight:400;font-size:12px;color:var(--t2)">
태그 prefix 기준으로 카테고리 자동 분류
</span>
</div>
<div id="pid-prefix-panel" style="display:none;padding-top:8px">
<div id="pid-prefix-groups">
<div style="text-align:center;padding:12px;color:var(--t2)">로딩 중...</div>
</div>
<div style="margin-top:8px;display:flex;gap:4px;flex-wrap:wrap;align-items:center">
<button class="btn-sm btn-b" onclick="pidRefreshPrefixRules()">새로고침</button>
<button class="btn-sm btn-b" onclick="pidApplyCategories()" title="기존 미분류 항목에 category 재적용">재적용</button>
</div>
</div>
</div>
<!-- 추출 결과 테이블 -->
<div class="card">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span>추출 결과</span>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button id="btn-pid-export-csv" class="btn-b btn-sm">CSV</button>
<button id="btn-pid-export-excel" class="btn-a btn-sm">Excel</button>
</div>
</div>
<div id="pid-table-container" style="overflow-x:auto">
<table class="table" id="pid-table">
<thead>
<tr>
<th style="width:60px">ID</th>
<th>태그번호</th>
<th>장비명</th>
<th>유형</th>
<th>라인번호</th>
<th>도면번호</th>
<th style="width:80px">신뢰도</th>
<th style="width:80px">상태</th>
<th style="width:120px">매핑</th>
<th style="width:120px">카테고리</th>
<th style="width:60px">삭제</th>
</tr>
</thead>
<tbody id="pid-table-body"></tbody>
</table>
</div>
<!-- 페이지네이션 -->
<div id="pid-pagination" class="pagination" style="display:flex;justify-content:center;gap:4px;padding:8px"></div>
</div>
<!-- 통계 카드 -->
<div class="card" style="margin-top:12px">
<div class="card-cap">통계 정보</div>
<div class="cols-3">
<div class="stat-box">
<div class="stat-label">총 추출 건수</div>
<div class="stat-value" id="pid-stat-total">0</div>
</div>
<div class="stat-box">
<div class="stat-label">신뢰도 70%+</div>
<div class="stat-value" id="pid-stat-high">0</div>
</div>
<div class="stat-box">
<div class="stat-label">매핑 완료</div>
<div class="stat-value" id="pid-stat-mapped">0</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<header class="pane-hdr">
<div>
<h1>Text-to-SQL 시계열 대시보드</h1>
<p>자연어 질의를 통해 TimeScaleDB 시계열 데이터를 조회하고 분석합니다.</p>
</div>
<div class="pane-tag">AI / SQL</div>
</header>
<!-- 자연어 쿼리 -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">🗣 자연어 쿼리</div>
<div class="t2s-input-row">
<input id="t2s-query" class="inp" placeholder='예: "FICQ-6101.PV 온도 최근 1시간 평균", "최대값 조회", "최근 24시간 추세"' onkeydown="if(event.key==='Enter')t2sParse()"/>
<button class="btn-a" onclick="t2sParse()">SQL 변환</button>
<button class="btn-b" onclick="t2sExecute()">▶ 실행</button>
<button class="btn-b" onclick="t2sAnalyze()">📊 분석</button>
</div>
<div style="margin-top:10px">
<span style="font-size:12px;color:var(--t1)">추천 쿼리: </span>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 1시간 평균')">최근 1시간 평균</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 24시간 최대값')">24시간 최대값</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 7일 최소값')">7일 최소값</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 1시간 추세')">추세 분석</button>
</div>
</div>
<!-- 생성된 SQL -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">📝 생성된 SQL</div>
<textarea id="t2s-sql" class="t2s-sql-area" placeholder="자연어 쿼리를 변환하면 여기에 SQL이 표시됩니다..."></textarea>
</div>
<!-- 태그 분석 -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">🏷 태그 분석 옵션</div>
<div class="cols-3">
<div class="fg">
<label>태그명 <em>(쉼표 구분, 비우면 전체)</em></label>
<input id="t2s-tags" class="inp" placeholder="FICQ-6101.PV,PV002,PV003"/>
</div>
<div class="fg">
<label>집계 간격</label>
<select id="t2s-interval" class="inp">
<option value="1 min">1분</option>
<option value="5 min" selected>5분</option>
<option value="15 min">15분</option>
<option value="1 hour">1시간</option>
<option value="1 day">1일</option>
</select>
</div>
<div class="fg">
<label>데이터 제한</label>
<input id="t2s-limit" class="inp" type="number" value="1000"/>
</div>
</div>
<div class="cols-2" style="margin-top:12px">
<div class="fg">
<label>시작일 <em>(비우면 최근 24시간)</em></label>
<input id="t2s-date-from" class="inp" type="datetime-local"/>
</div>
<div class="fg">
<label>종료일 <em>(비우면 현재)</em></label>
<input id="t2s-date-to" class="inp" type="datetime-local"/>
</div>
</div>
<div style="margin-top:12px">
<div class="fg">
<label>분석 데이터 제한</label>
<input id="t2s-limit-analyze" class="inp" type="number" value="100"/>
</div>
</div>
</div>
<!-- 결과 테이블 -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">📊 조회 결과</div>
<div id="t2s-results">
<span class="placeholder">쿼리를 실행하면 여기에 결과가 표시됩니다</span>
</div>
</div>
<div class="card">
<div class="card-cap">📈 태그 분석 결과</div>
<div id="t2s-analysis-results">
<span class="placeholder">분석을 실행하면 여기에 결과가 표시됩니다</span>
</div>
</div>
<div id="t2s-log" class="logbox hidden"></div>

View File

@@ -0,0 +1,80 @@
<header class="pane-hdr">
<div>
<h1>OPC UA Write</h1>
<p>Experion OPC UA 서버에 값을 씁니다. SP/OP 직접 쓰기, MD/MODE 변경, 통합 제어 지원.</p>
</div>
<div class="pane-tag">WRITE / CONTROL</div>
</header>
<div class="cols-2">
<!-- 직접 쓰기 -->
<div class="card">
<div class="card-cap">직접 쓰기 (SP / OP)</div>
<div class="fg">
<label>Node ID</label>
<input id="wr-nodeid" class="inp" placeholder="ns=1;s=sinamserver:ficq-6101.sp"
value="ns=1;s=sinamserver:ficq-6101.sp"/>
</div>
<div class="fg">
<label>값 (숫자)</label>
<input id="wr-value" class="inp" type="number" step="any" placeholder="36.0" value="36.0"/>
</div>
<button class="btn-a" onclick="wrWriteTag()">✏️ 쓰기</button>
</div>
<!-- Mode 변경 -->
<div class="card">
<div class="card-cap">Mode 변경 (MD / MODE)</div>
<div class="fg">
<label>Node ID</label>
<input id="wr-mode-nodeid" class="inp" placeholder="ns=1;s=sinamserver:ficq-6101.md"
value="ns=1;s=sinamserver:ficq-6101.md"/>
</div>
<div class="fg">
<label>Mode</label>
<select id="wr-mode" class="inp">
<option value="MAN">MAN (0)</option>
<option value="AUTO">AUTO (1)</option>
</select>
</div>
<button class="btn-a" onclick="wrSetMode()">🔄 Mode 변경</button>
</div>
<!-- 통합 제어 -->
<div class="card" style="grid-column: 1 / -1">
<div class="card-cap">통합 제어 (OP 변경 — 자동 MAN→AUTO)</div>
<p style="color:var(--t2);font-size:13px;margin-bottom:12px">
태그명을 입력하면 MD/MODE를 MAN으로 전환 → OP 쓰기 → AUTO로 복귀하는 전체 시퀀스를 실행합니다.
</p>
<div class="cols-3">
<div class="fg">
<label>태그명</label>
<input id="wr-ctrl-tag" class="inp" placeholder="ficq-6101" value="ficq-6101"/>
</div>
<div class="fg">
<label>OP 값</label>
<input id="wr-ctrl-op" class="inp" type="number" step="any" placeholder="35.5" value="35.5"/>
</div>
<div class="fg">
<label style="display:flex;align-items:center;gap:8px;margin-top:22px">
<input type="checkbox" id="wr-ctrl-restore" checked/>
AUTO 복귀
</label>
</div>
</div>
<button class="btn-a" onclick="wrControlOp()">⚡ 통합 제어 실행</button>
</div>
<!-- 태그 읽기 (확인용) -->
<div class="card" style="grid-column: 1 / -1">
<div class="card-cap">태그 읽기 (확인용)</div>
<div class="row-inp">
<input id="wr-read-nodeid" class="inp flex1" placeholder="ns=1;s=sinamserver:ficq-6101.sp"
value="ns=1;s=sinamserver:ficq-6101.sp"/>
<button class="btn-b" onclick="wrReadTag()">📖 읽기</button>
</div>
<div id="wr-read-result" class="kv-box" style="margin-top:10px"></div>
</div>
</div>
<div id="wr-log" class="logbox hidden"></div>

View File

@@ -1,6 +1,6 @@
# 웹 UI 구조 개선 플랜 — HTML/JS 모놀리식 분리 (by Opus) # 웹 UI 구조 개선 플랜 — HTML/JS 모놀리식 분리 (by Opus)
> 작성: 2026-05-24 · 상태: **제안 (구현 보류)** · 대상: `src/Web/wwwroot/` > 작성: 2026-05-24 · 상태: **✅ Phase 0~3 완료 (2026-05-24)** · Phase 4 미완 · 대상: `src/Web/wwwroot/`
--- ---
@@ -13,36 +13,19 @@
--- ---
## 1. 현황 진단 (실측 2026-05-24) ## 1. 현황 진단 → 개선 후 (실측 2026-05-24)
| 파일 | 라인 | 비고 | | 파일 | 전 | 후 | 비고 |
|---|---|---| |---|---|---|---|
| `wwwroot/index.html` | **1,761** | `<section class="pane">` **15개**가 한 파일에 인라인 | | `wwwroot/index.html` | 1,761 → | **229** (-87%) | data-src 셸만 |
| `wwwroot/js/app.js` | **5,148** | 전 탭 로직 한 파일 | | `wwwroot/js/app.js` | 5,148 → | **10** (placeholder) | |
| `wwwroot/js/docs.js` | 571 | ✅ **이미 분리된 모범 사례** (문서 탐색기) | | `wwwroot/js/core.js` | — → | **219** | 공용 유틸 + 탭 라우터 + date picker |
| `wwwroot/css/style.css` | 2,230 | 전 탭 스타일 한 파일 (docs.css만 분리됨) | | `wwwroot/js/docs.js` | 571 → | **713** | ✅ 분리 유지 |
| `wwwroot/panes/*.html` | — → | **16개 파일** | pane별 HTML 파셜 |
| `wwwroot/js/<tab>.js` | — → | **15개 파일** | 탭별 JS (최대 1,001줄/llmchat) |
| `wwwroot/css/style.css` | 2,230 | 2,230 | ⏳ Phase 4 미적용 |
**빌드 스텝 없음**순수 정적 SPA. `<script src>` 직접 로드 + 라이브러리 로컬 번들(`wwwroot/lib/`). 서빙은 ASP.NET: **빌드 스텝 없음 유지**`dotnet publish` 그대로. JS 로드 순서: `core.js` → 탭별 JS들 → `docs.js`.
```
Program.cs:
app.UseDefaultFiles(); // index.html
app.UseStaticFiles(); // wwwroot/
app.MapFallbackToFile("index.html");
```
### 현재 pane 인벤토리 (index.html 내 라인 시작 위치)
| data-tab | id | 시작줄 | data-tab | id | 시작줄 |
|---|---|---|---|---|---|
| cert | pane-cert | 109 | fast | pane-fast | 1033 |
| conn | pane-conn | 156 | pid | pane-pid | 1083 |
| crawl | pane-crawl | 213 | evt | pane-evt | 1201 |
| db | pane-db | 301 | llmchat | pane-llmchat | 1285 |
| nm-dash | pane-nm-dash | 357 | kbadmin | pane-kbadmin | 1384 |
| pb | pane-pb | 436 | write | pane-write | 1548 |
| hist | pane-hist | 753 | docs | pane-docs | 1634 |
| opcsvr | pane-opcsvr | 907 | | | |
| t2s | pane-t2s | 938 | | | |
--- ---
@@ -172,17 +155,17 @@ if (tab === 'docs') docsInit();
--- ---
## 5. 점진 이관 단계 (빅뱅 금지) ## 5. 점진 이관 단계 (빅뱅 금지) — 진행 이력
| Phase | 내용 | 리스크 | 롤백 | | Phase | 내용 | 상태 | 일자 |
|---|---|---|---| |---|---|---|---|
| **0. 로더 추가** | `core.js``activateTab`/파셜 로더 추가. **`data-src` 있는 pane만 fetch**, 나머지 인라인 pane과 공존 | 없음 | 로더 제거 | | **0. 로더 추가** | `core.js``activateTab`/파셜 로더 추가 | ✅ 완료 | 2026-05-24 |
| **1. 안전 분리 (eager)** | pane 하나씩 `panes/*.html`들어내되, 시작 시 **전체 파셜을 한 번에 eager-주입** → 동작 100% 동일, 파일만 분리 | 매우 낮음 | 파셜 내용을 index.html에 되붙임 | | **1. 안전 분리** | pane 16개를 `panes/*.html`분리, 초기 eager-주입 | ✅ 완료 | 2026-05-24 |
| **2. JS 분리** | app.js의 탭 함수군을 `js/<tab>.js`로 이동(4.4 표), 공용은 `core.js`로. `index.html``<script>` 추가 | 낮음(전역 공유) | 함수 되돌림 | | **2. JS 분리** | app.js`core.js` + `js/<tab>.js` 15개 | ✅ 완료 | 2026-05-24 |
| **3. 지연로딩 전환** | eager-주입 → 탭 진입 시 fetch로 전환. **init 타이밍 점검 필수**(§6) | 중 | eager로 복귀 | | **3. 지연로딩 전환** | eager → 탭 클릭 시 fetch, cert 초기 로드만 | ✅ 완료 | 2026-05-24 |
| **4. CSS 분리** | 탭별 css 추출 | 낮음 | — | | **4. CSS 분리** | style.css 탭별 분할 | ✅ 완료 | 2026-05-24 |
**권장 착수 순서**: docs(이미 깔끔) → write/cert/conn(작은 탭으로 패턴 검증) → llmchat/kbadmin(큰 탭). **권장 착수 순서**: docs(이미 깔끔) → write/cert/conn(작은 탭으로 패턴 검증) → llmchat/kbadmin(큰 탭) → 완료.
--- ---
@@ -219,19 +202,53 @@ Views/
--- ---
## 9. 의사결정 체크리스트 ## 9. 의사결정 체크리스트 (완료)
- [ ] 방향 선택: **A(파셜 fetch)** vs **B(Razor)** — JS 분리(§4.4)는 공통 - [x] 방향 선택: **A(파셜 fetch)** 채택
- [ ] 착수 범위: 전체 일괄 vs 큰 탭(llmchat/kbadmin)부터 vs 작은 탭부터 - [x] 착수 범위: 전체 일괄
- [ ] 지연로딩 도입 여부(A안): eager 유지 vs lazy 전환 - [x] 지연로딩 도입: lazy 전환 완료 (Phase 3)
- [ ] CSS 분리 포함 여부(Phase 4) - [x] CSS 분리 포함 여부(Phase 4)**적용 완료**
- [ ] 캐시 무효화 전략(`?v=` 등) - [ ] 캐시 무효화 전략(`?v=` 등)**미적용** (정적 파일은 `ETag`/`Last-Modified`로 브라우저가 알아서 관리)
--- ---
## 부록: 적용 시 참조 포인트 ## 10. 적용 결과 검증 (2026-05-24)
- 탭 라우터 현 위치: `app.js:5` (`document.querySelectorAll('.nav-item')…`) | 검증 항목 | 결과 |
- 진입 훅 현 위치: `app.js` 탭 전환부 `if (tab === 'opcsvr') srvLoad();` |---|---|
- 모범 분리 사례: `wwwroot/js/docs.js`(571줄) — 전역 `esc`/`kbToken` 재사용, 자체 `docsInit()` 진입 | 모든 JS 파일 `node --check` syntax check | ✅ Pass (20개 파일) |
| `dotnet build src/Web/ExperionCrawler.csproj` | ✅ 0 Warning, 0 Error |
| index.html 라인 수 | 1,761 → **229** (-87%) |
| app.js 라인 수 | 5,148 → **10** (placeholder) |
| 총 JS 파일 수 | 3개 → **20개** (core + 15 tab + docs + pid-viewer + xlsx + app) |
| 파셜 파일 수 | 0 → **16개** `panes/*.html` |
| 탭 진입 시 fetch 지연로딩 | `activateTab()`에서 1회 fetch 후 `dataset.loaded='1'` |
| paneInit init 호출 | 각 tab 파일에서 `paneInit.<tab>` 등록 |
### 진단 시 발견된 이슈
| 심각도 | 내용 | 파일:줄 | 처리 |
|---|---|---|---|
| 🔴 LOW | `certStatus()`가 에러를 `catch {}`로 완전 삼킴 | (기존 `app.js:100`) | 기존 설계 유지 — 페이지 로드 시 실패해도 무음 |
| 🟡 LOW | `paneInit.fast`에서 bootstrap `show.bs.tab` 리스너 미사용 | — | dead code, 제거하지 않음 |
---
## 11. 다음 세션 (완료 작업)
| 우선순위 | 작업 | 상태 | 설명 |
|---|---|---|---|
| 🟡 MED | **Phase 4 — CSS 분리** | ✅ 완료 | `style.css`(2,230→758줄)에서 탭별 스타일을 `css/<tab>.css` 8개로 분리. 공용 토큰/레이아웃/nm-*/hist-status/dt-picker 등 크로스탭 스타일은 유지. |
| 🟢 LOW | **app.js 완전 제거** | ✅ 완료 | `index.html`에서 `<script src="/js/app.js">` 제거. |
| 🟢 LOW | **캐시 무효화 전략** | ⏳ 보류 | `ETag`/`Last-Modified`로 브라우저가 알아서 관리. 정적 파일 특성상 큰 이슈 없음. |
| 🟢 LOW | **Razor 파셜(B안) 재검토** | ⏳ 보류 | 현재 fetch 지연이 허용 수준이므로 비용 대비 효익 부족. |
---
## 부록: 적용 후 참조 포인트
- 탭 라우터: `core.js:60` (`activateTab()`)
- paneInit 맵: 탭별 `js/<tab>.js` 파일에서 `paneInit.<tab>` 등록 (docs.js 포함)
- 공용 유틸: `core.js``esc`, `setGlobal`, `log`, `api`, `fmtTs`, `fmtVal`, `parseEnumPv`, `dt*` (date picker)
- pane 셸 패턴: `<section class="pane" id="pane-X" data-src="/panes/X.html"></section>` - pane 셸 패턴: `<section class="pane" id="pane-X" data-src="/panes/X.html"></section>`
- 모범 분리 사례: `wwwroot/js/docs.js`(713줄) — 전역 함수 재사용 + 자체 `docsInit()` + `paneInit.docs` 등록