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:
49
AGENTS.md
49
AGENTS.md
@@ -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 참조.
|
||||||
|
|||||||
@@ -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) 히스토리 조회.
|
||||||
|
|||||||
50
src/Web/wwwroot/css/evt.css
Normal file
50
src/Web/wwwroot/css/evt.css
Normal 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); }
|
||||||
100
src/Web/wwwroot/css/hist.css
Normal file
100
src/Web/wwwroot/css/hist.css
Normal 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;
|
||||||
|
}
|
||||||
109
src/Web/wwwroot/css/kbadmin.css
Normal file
109
src/Web/wwwroot/css/kbadmin.css
Normal 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; }
|
||||||
501
src/Web/wwwroot/css/llmchat.css
Normal file
501
src/Web/wwwroot/css/llmchat.css
Normal 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); }
|
||||||
|
}
|
||||||
14
src/Web/wwwroot/css/opcsvr.css
Normal file
14
src/Web/wwwroot/css/opcsvr.css
Normal 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
106
src/Web/wwwroot/css/pb.css
Normal 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
236
src/Web/wwwroot/css/pid.css
Normal 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
437
src/Web/wwwroot/css/t2s.css
Normal 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
53
src/Web/wwwroot/js/cert.js
Normal file
53
src/Web/wwwroot/js/cert.js
Normal 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
156
src/Web/wwwroot/js/conn.js
Normal 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
219
src/Web/wwwroot/js/core.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGlobal(state, text) {
|
||||||
|
document.getElementById('g-dot').className = `dot ${state}`;
|
||||||
|
document.getElementById('g-txt').textContent = text.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(id, lines) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
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
125
src/Web/wwwroot/js/crawl.js
Normal 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
102
src/Web/wwwroot/js/db.js
Normal 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', '조회 실패'); }
|
||||||
|
}
|
||||||
@@ -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
152
src/Web/wwwroot/js/evt.js
Normal 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
469
src/Web/wwwroot/js/fast.js
Normal 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
385
src/Web/wwwroot/js/hist.js
Normal 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);
|
||||||
|
}
|
||||||
397
src/Web/wwwroot/js/kbadmin.js
Normal file
397
src/Web/wwwroot/js/kbadmin.js
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 = '⚡ 초안 생성';
|
||||||
|
}
|
||||||
|
}
|
||||||
1001
src/Web/wwwroot/js/llmchat.js
Normal file
1001
src/Web/wwwroot/js/llmchat.js
Normal file
File diff suppressed because it is too large
Load Diff
120
src/Web/wwwroot/js/nm-dash.js
Normal file
120
src/Web/wwwroot/js/nm-dash.js
Normal 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');
|
||||||
|
}
|
||||||
85
src/Web/wwwroot/js/opcsvr.js
Normal file
85
src/Web/wwwroot/js/opcsvr.js
Normal 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
503
src/Web/wwwroot/js/pb.js
Normal 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
572
src/Web/wwwroot/js/pid.js
Normal 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
735
src/Web/wwwroot/js/t2s.js
Normal 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;
|
||||||
|
}
|
||||||
80
src/Web/wwwroot/js/write.js
Normal file
80
src/Web/wwwroot/js/write.js
Normal 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Web/wwwroot/panes/cert.html
Normal file
36
src/Web/wwwroot/panes/cert.html
Normal 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>
|
||||||
51
src/Web/wwwroot/panes/conn.html
Normal file
51
src/Web/wwwroot/panes/conn.html
Normal 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>
|
||||||
82
src/Web/wwwroot/panes/crawl.html
Normal file
82
src/Web/wwwroot/panes/crawl.html
Normal 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">
|
||||||
|
서버 설정은 위 크롤링 설정을 그대로 사용합니다 ·
|
||||||
|
노드 수에 따라 수 분이 소요될 수 있습니다 ·
|
||||||
|
결과는 <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>
|
||||||
50
src/Web/wwwroot/panes/db.html
Normal file
50
src/Web/wwwroot/panes/db.html
Normal 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>
|
||||||
37
src/Web/wwwroot/panes/docs.html
Normal file
37
src/Web/wwwroot/panes/docs.html
Normal 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>
|
||||||
78
src/Web/wwwroot/panes/evt.html
Normal file
78
src/Web/wwwroot/panes/evt.html
Normal 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>
|
||||||
44
src/Web/wwwroot/panes/fast.html
Normal file
44
src/Web/wwwroot/panes/fast.html
Normal 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>
|
||||||
148
src/Web/wwwroot/panes/hist.html
Normal file
148
src/Web/wwwroot/panes/hist.html
Normal 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>
|
||||||
158
src/Web/wwwroot/panes/kbadmin.html
Normal file
158
src/Web/wwwroot/panes/kbadmin.html
Normal 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>
|
||||||
93
src/Web/wwwroot/panes/llmchat.html
Normal file
93
src/Web/wwwroot/panes/llmchat.html
Normal 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>
|
||||||
73
src/Web/wwwroot/panes/nm-dash.html
Normal file
73
src/Web/wwwroot/panes/nm-dash.html
Normal 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>
|
||||||
25
src/Web/wwwroot/panes/opcsvr.html
Normal file
25
src/Web/wwwroot/panes/opcsvr.html
Normal 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>
|
||||||
311
src/Web/wwwroot/panes/pb.html
Normal file
311
src/Web/wwwroot/panes/pb.html
Normal 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>
|
||||||
112
src/Web/wwwroot/panes/pid.html
Normal file
112
src/Web/wwwroot/panes/pid.html
Normal 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>
|
||||||
89
src/Web/wwwroot/panes/t2s.html
Normal file
89
src/Web/wwwroot/panes/t2s.html
Normal 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>
|
||||||
80
src/Web/wwwroot/panes/write.html
Normal file
80
src/Web/wwwroot/panes/write.html
Normal 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>
|
||||||
@@ -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` 등록
|
||||||
|
|||||||
Reference in New Issue
Block a user