diff --git a/itsm/main.py b/itsm/main.py index d049e0e2..c77fadd5 100644 --- a/itsm/main.py +++ b/itsm/main.py @@ -46,6 +46,7 @@ from routers import ( jmeter, public_checklist, customer_portal, + onboarding, groupware, siem, topology, @@ -264,6 +265,7 @@ app.include_router(public_checklist.router) # 추가 기능 app.include_router(customer_portal.router) # 고객 셀프서비스 포털 +app.include_router(onboarding.router) # 온보딩 가이드 챗봇 app.include_router(groupware.router) # 그룹웨어 전자결재 연동 app.include_router(siem.router) # SIEM 보안 이벤트 연동 app.include_router(topology.router) # 네트워크 토폴로지 시각화 diff --git a/itsm/routers/onboarding.py b/itsm/routers/onboarding.py new file mode 100644 index 00000000..04832d1c --- /dev/null +++ b/itsm/routers/onboarding.py @@ -0,0 +1,369 @@ +""" +GUARDiA ITSM 온보딩 가이드 API + +설치 완료 후 자동 시작되어 사용자를 단계별로 안내. + +엔드포인트: + GET /api/onboarding/status — 현재 온보딩 상태 + POST /api/onboarding/step — 단계 업데이트 + POST /api/onboarding/message — 현재 화면 기반 봇 응답 + POST /api/onboarding/complete — 온보딩 완료 처리 + POST /api/onboarding/reset — 온보딩 초기화 (재시작) + GET /api/onboarding/steps — 전체 단계 목록 +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import User + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/onboarding", tags=["onboarding"]) + +# ── 온보딩 단계 정의 ───────────────────────────────────────────────────────── + +ONBOARDING_STEPS = [ + { + "id": "welcome", + "order": 0, + "title": "환영합니다!", + "icon": "👋", + "view": None, # 어느 화면에서나 + "target": None, # 하이라이트할 요소 + "message": ( + "안녕하세요! GUARDiA ITSM에 오신 것을 환영합니다.\n\n" + "설치가 완료되었습니다. 지금부터 **5단계** 가이드로\n" + "첫 번째 프로젝트를 등록하고 소스코드 관리까지 완료해 드리겠습니다.\n\n" + "준비가 되셨으면 **시작하기**를 눌러주세요." + ), + "actions": [{"label": "시작하기 →", "action": "next"}], + }, + { + "id": "change_password", + "order": 1, + "title": "비밀번호 변경", + "icon": "🔑", + "view": "/change-password", + "target": "#current-password", + "message": ( + "보안을 위해 **초기 비밀번호를 변경**해 주세요.\n\n" + "초기 비밀번호: `1111`\n\n" + "현재 비밀번호에 `1111`을 입력하고,\n" + "새로운 안전한 비밀번호로 변경해 주세요.\n\n" + "💡 **강력한 비밀번호 조건**\n" + "- 8자 이상\n- 대문자 + 숫자 + 특수문자 포함" + ), + "actions": [ + {"label": "비밀번호 변경 페이지로", "action": "navigate", "path": "/change-password"}, + {"label": "이미 변경했어요", "action": "next"}, + ], + }, + { + "id": "explore_dashboard", + "order": 2, + "title": "대시보드 둘러보기", + "icon": "📊", + "view": "/", + "target": ".dash-tab-nav", + "message": ( + "이곳이 GUARDiA **통합 대시보드**입니다.\n\n" + "4가지 탭으로 구성되어 있어요:\n" + "- 📊 **운영 현황** — SR·SLA·이슈 현황\n" + "- 🖥️ **인프라** — 서버·CMDB 현황\n" + "- 🔒 **보안** — 취약점·패치 현황\n" + "- 🤖 **AI 인사이트** — 예측·이상탐지\n\n" + "각 탭을 클릭해서 살펴보세요!" + ), + "actions": [ + {"label": "대시보드로 이동", "action": "navigate", "path": "/"}, + {"label": "다음 단계 →", "action": "next"}, + ], + }, + { + "id": "create_project", + "order": 3, + "title": "첫 프로젝트 등록", + "icon": "🏗️", + "view": "/si", + "target": ".btn-primary", + "message": ( + "**PMS(프로젝트 관리)**에서 첫 번째 프로젝트를 등록해보세요.\n\n" + "**1.** 좌측 메뉴 → **PMS 프로젝트** 클릭\n" + "**2.** 우측 상단 **+ 프로젝트 생성** 버튼 클릭\n" + "**3.** 아래 정보를 입력하세요:\n\n" + "```\n프로젝트 코드: PRJ-2026-001\n프로젝트명: 우리 회사 정보화사업\n담당 PM: admin\n```\n\n" + "등록하면 WBS, 산출물, 보고서 기능을 바로 사용할 수 있어요." + ), + "actions": [ + {"label": "프로젝트 관리로 이동", "action": "navigate", "path": "/si"}, + {"label": "프로젝트 등록 완료", "action": "next"}, + ], + }, + { + "id": "register_server", + "order": 4, + "title": "관리 서버 등록", + "icon": "🖥️", + "view": "/", + "target": "[data-view='cmdb']", + "message": ( + "**CMDB**에 관리할 서버를 등록해 주세요.\n\n" + "**1.** 좌측 메뉴 → **인프라 → CMDB** 클릭\n" + "**2.** **서버 등록** 버튼 클릭\n" + "**3.** 아래 정보를 입력하세요:\n\n" + "```\n서버명: web-server-01\nIP: 10.0.0.1\nOS: CentOS 7\nSSH 계정: opsagent\n```\n\n" + "⚠️ 보안: SSH 비밀번호는 AES-256-GCM으로 암호화 저장됩니다.\n\n" + "서버 등록 후 메신저 봇으로 즉시 제어할 수 있어요." + ), + "actions": [ + {"label": "CMDB로 이동", "action": "navigate", "path": "/?view=cmdb"}, + {"label": "나중에 등록", "action": "next"}, + {"label": "서버 등록 완료", "action": "next"}, + ], + }, + { + "id": "register_source", + "order": 5, + "title": "소스 코드 등록", + "icon": "📦", + "view": None, + "target": None, + "message": ( + "**Gitea**에 소스 코드를 등록하세요.\n\n" + "Gitea는 설치 시 자동으로 구성되었습니다.\n\n" + "**1.** Gitea 접속: `http://[서버IP]:3000`\n" + "**2.** 계정: `gitadmin / Gitea@guardia!`\n" + "**3.** guardia 조직 → GUARDiA 저장소에 소스 Push:\n\n" + "```bash\ngit remote add guardia \\\n http://gitadmin@[서버IP]:3000/guardia/GUARDiA.git\ngit push guardia main\n```\n\n" + "등록 후 Jenkins CI/CD와 자동 연동됩니다." + ), + "actions": [ + {"label": "Gitea 열기", "action": "external", "url": "http://localhost:3000"}, + {"label": "소스 등록 완료", "action": "next"}, + ], + }, + { + "id": "setup_messenger", + "order": 6, + "title": "메신저 봇 연결", + "icon": "💬", + "view": None, + "target": None, + "message": ( + "**메신저 봇**을 연결하면 채팅으로 인프라를 제어할 수 있어요.\n\n" + "지원 플랫폼:\n" + "- 카카오워크\n- 네이버웍스\n- 슬랙\n\n" + "**설정 방법:**\n" + "`.env` 파일에 아래를 추가:\n\n" + "```\nMESSENGER_BASE_URL=http://메신저서버:8002\nBOT_SEND_API=http://봇API주소\n```\n\n" + "설정 후 `/sr 테스트 메시지`로 동작을 확인하세요.\n\n" + "📌 봇 명령어 25개: `/sr`, `/deploy`, `/rca`, `/scan` 등" + ), + "actions": [ + {"label": "나중에 설정", "action": "next"}, + {"label": "설정 완료", "action": "next"}, + ], + }, + { + "id": "complete", + "order": 7, + "title": "설정 완료!", + "icon": "🎉", + "view": None, + "target": None, + "message": ( + "**축하합니다!** GUARDiA ITSM 초기 설정이 완료되었습니다.\n\n" + "✅ 비밀번호 변경\n" + "✅ 프로젝트 등록\n" + "✅ 서버 등록\n" + "✅ 소스 코드 등록\n" + "✅ 메신저 봇 연결\n\n" + "이제 GUARDiA의 모든 기능을 사용할 수 있습니다!\n\n" + "도움이 필요하면 우측 하단 **?** 버튼을 클릭하거나\n" + "봇 명령어 `/help`를 입력하세요." + ), + "actions": [ + {"label": "완료 — 시작하기!", "action": "complete"}, + ], + }, +] + +# ── 온보딩 상태 (운영 시 DB 테이블로 이전) ───────────────────────────────── +# 사용자별 상태 저장 +_onboarding_state: dict[str, dict] = {} + + +def _get_user_state(username: str) -> dict: + if username not in _onboarding_state: + _onboarding_state[username] = { + "username": username, + "current_step": 0, + "completed": False, + "dismissed": False, + "started_at": datetime.utcnow().isoformat(), + "completed_at": None, + "step_history": [], + } + return _onboarding_state[username] + + +# ── 스키마 ─────────────────────────────────────────────────────────────────── + +class StepUpdateRequest(BaseModel): + step_id: str + action: str = "complete" # complete | skip | back + + +class MessageRequest(BaseModel): + current_view: str = "/" + current_step: Optional[str] = None + user_message: Optional[str] = None + + +# ── 엔드포인트 ─────────────────────────────────────────────────────────────── + +@router.get("/status") +async def get_onboarding_status( + cu: User = Depends(get_current_user), +): + """현재 온보딩 상태 반환.""" + state = _get_user_state(cu.username) + step_idx = state["current_step"] + current = ONBOARDING_STEPS[step_idx] if step_idx < len(ONBOARDING_STEPS) else None + + return { + "username": cu.username, + "current_step": step_idx, + "total_steps": len(ONBOARDING_STEPS), + "completed": state["completed"], + "dismissed": state["dismissed"], + "show_bot": not state["completed"] and not state["dismissed"], + "current": current, + "progress_pct": round(step_idx / len(ONBOARDING_STEPS) * 100, 0), + } + + +@router.get("/steps") +async def get_steps(_u: User = Depends(get_current_user)): + """전체 온보딩 단계 목록.""" + return {"steps": ONBOARDING_STEPS, "total": len(ONBOARDING_STEPS)} + + +@router.post("/step") +async def update_step( + body: StepUpdateRequest, + cu: User = Depends(get_current_user), +): + """단계 업데이트 (완료/건너뜀/뒤로).""" + state = _get_user_state(cu.username) + + current_idx = state["current_step"] + + if body.action in ("complete", "next", "skip"): + if current_idx < len(ONBOARDING_STEPS) - 1: + state["current_step"] = current_idx + 1 + state["step_history"].append({ + "step_id": body.step_id, + "action": body.action, + "at": datetime.utcnow().isoformat(), + }) + elif body.action == "back" and current_idx > 0: + state["current_step"] = current_idx - 1 + + new_idx = state["current_step"] + new_step = ONBOARDING_STEPS[new_idx] if new_idx < len(ONBOARDING_STEPS) else None + + return { + "current_step": new_idx, + "step": new_step, + "completed": state["completed"], + } + + +@router.post("/complete") +async def complete_onboarding(cu: User = Depends(get_current_user)): + """온보딩 완료 처리.""" + state = _get_user_state(cu.username) + state["completed"] = True + state["current_step"] = len(ONBOARDING_STEPS) - 1 + state["completed_at"] = datetime.utcnow().isoformat() + return {"message": "온보딩 완료!", "completed": True} + + +@router.post("/dismiss") +async def dismiss_onboarding(cu: User = Depends(get_current_user)): + """온보딩 임시 숨기기.""" + state = _get_user_state(cu.username) + state["dismissed"] = True + return {"dismissed": True} + + +@router.post("/reset") +async def reset_onboarding(cu: User = Depends(get_current_user)): + """온보딩 초기화 (재시작).""" + _onboarding_state.pop(cu.username, None) + return {"message": "온보딩 초기화 완료 — 새로고침하면 다시 시작됩니다."} + + +@router.post("/message") +async def get_bot_message( + body: MessageRequest, + cu: User = Depends(get_current_user), +): + """현재 화면 + 사용자 메시지 기반 봇 응답 (Ollama 연동).""" + state = _get_user_state(cu.username) + + # 현재 단계 메시지 반환 (기본) + step_idx = state["current_step"] + current = ONBOARDING_STEPS[step_idx] if step_idx < len(ONBOARDING_STEPS) else None + + # 사용자 질문이 있으면 Ollama로 답변 + if body.user_message: + try: + from core.llm_client import get_llm_client + context = ( + f"당신은 GUARDiA ITSM 설치 가이드 AI입니다. " + f"현재 사용자는 '{current['title'] if current else '설치 완료'}' 단계에 있습니다. " + f"현재 화면: {body.current_view}\n" + f"사용자 질문: {body.user_message}\n\n" + f"간결하고 친절하게 한국어로 답변하세요. " + f"GUARDiA API나 기능에 대해 모르면 솔직히 말하세요." + ) + client = get_llm_client() + resp = await client.chat(context) + return { + "type": "llm_answer", + "message": resp.content.strip()[:500], + "current": current, + } + except Exception as e: + logger.debug("Ollama 응답 실패: %s", e) + + # 화면별 컨텍스트 힌트 + view_hints = { + "/": "대시보드 화면입니다. 좌측 메뉴에서 다양한 기능에 접근할 수 있어요.", + "/change-password":"비밀번호 변경 화면입니다. 현재 비밀번호 1111을 새 비밀번호로 변경하세요.", + "/si": "PMS 프로젝트 관리 화면입니다. + 버튼으로 새 프로젝트를 만드세요.", + "/incidents": "인시던트 관리 화면입니다. 장애 발생 시 여기서 처리하세요.", + "/agents": "AI 에이전트 현황입니다. Ollama 연결 상태를 확인할 수 있어요.", + "/license": "라이선스 관리 화면입니다. 현재 체험판 라이선스가 적용되어 있어요.", + } + hint = view_hints.get(body.current_view, "") + + return { + "type": "step_guide", + "message": current["message"] if current else "온보딩이 완료되었습니다!", + "hint": hint, + "current": current, + "step_idx":step_idx, + "progress":round(step_idx / len(ONBOARDING_STEPS) * 100, 0), + } diff --git a/itsm/static/index.html b/itsm/static/index.html index 5769b467..7ce4b8b2 100644 --- a/itsm/static/index.html +++ b/itsm/static/index.html @@ -786,5 +786,7 @@ + + diff --git a/itsm/static/onboarding.js b/itsm/static/onboarding.js new file mode 100644 index 00000000..6198f14e --- /dev/null +++ b/itsm/static/onboarding.js @@ -0,0 +1,630 @@ +/** + * GUARDiA ITSM 온보딩 가이드 챗봇 + * 설치 완료 후 자동 실행 — 로그인부터 프로젝트 등록까지 단계별 안내 + */ +(function GUARDiAOnboarding() { + 'use strict'; + + // ── 상태 ────────────────────────────────────────────────── + let state = { + visible: false, + minimized: false, + currentStep: null, + totalSteps: 8, + messages: [], + isTyping: false, + spotlightEl: null, + }; + + let _token = null; + let _pollId = null; + + // ── 초기화 ──────────────────────────────────────────────── + function init() { + // 토큰 확인 (로그인 상태만) + _token = localStorage.getItem('access_token'); + if (!_token) return; + + // 온보딩 상태 조회 + fetchStatus().then(status => { + if (!status) return; + if (status.show_bot) { + buildUI(); + show(); + loadStep(status.current); + // 화면 변화 감지 + watchNavigation(); + } else { + // 완료됐어도 우측하단 도움말 버튼만 남김 + buildHelpButton(); + } + }); + } + + // ── API ─────────────────────────────────────────────────── + async function api(method, path, body) { + const opts = { + method, + headers: { + 'Authorization': 'Bearer ' + _token, + 'Content-Type': 'application/json', + }, + }; + if (body) opts.body = JSON.stringify(body); + try { + const r = await fetch(path, opts); + return r.ok ? r.json() : null; + } catch { return null; } + } + + async function fetchStatus() { + return api('GET', '/api/onboarding/status'); + } + + async function postStep(stepId, action) { + return api('POST', '/api/onboarding/step', { step_id: stepId, action }); + } + + async function postMessage(userMessage) { + return api('POST', '/api/onboarding/message', { + current_view: location.pathname, + current_step: state.currentStep?.id, + user_message: userMessage, + }); + } + + async function completeOnboarding() { + await api('POST', '/api/onboarding/complete'); + } + + async function dismissOnboarding() { + await api('POST', '/api/onboarding/dismiss'); + } + + // ── UI 빌드 ─────────────────────────────────────────────── + function buildUI() { + if (document.getElementById('grd-onboarding')) return; + + const panel = document.createElement('div'); + panel.id = 'grd-onboarding'; + panel.innerHTML = ` +
+
🤖
+
+
GUARDiA 가이드
+
초기 설정 안내
+
+
+ + +
+
+ + +
+
+
+
1 / 8 단계
+ + +
+ + +
+ + +
+ `; + + // 스타일 + const style = document.createElement('style'); + style.textContent = ` + #grd-onboarding { + position: fixed; + right: 0; top: 50%; + transform: translateY(-50%); + width: 360px; + max-height: 85vh; + background: #1e2333; + border-left: 3px solid #818cf8; + border-radius: 16px 0 0 16px; + box-shadow: -8px 0 40px rgba(0,0,0,.4); + display: flex; flex-direction: column; + z-index: 9999; + font-family: 'Noto Sans KR', Arial, sans-serif; + font-size: 13px; color: #e2e8f0; + transition: all .3s cubic-bezier(.4,0,.2,1); + overflow: hidden; + } + #grd-onboarding.minimized { + height: 52px; max-height: 52px; + border-radius: 16px 0 0 16px; + } + #grd-onboarding.minimized #grd-ob-messages, + #grd-onboarding.minimized #grd-ob-input-area, + #grd-onboarding.minimized #grd-ob-progress-bar, + #grd-onboarding.minimized #grd-ob-progress-label { display: none; } + + #grd-ob-header { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; + background: #252b3b; + cursor: pointer; flex-shrink: 0; + } + .grd-ob-avatar { + width: 36px; height: 36px; border-radius: 50%; + background: linear-gradient(135deg,#818cf8,#6366f1); + display: flex; align-items: center; justify-content: center; + font-size: 18px; flex-shrink: 0; + animation: pulse 2s ease infinite; + } + @keyframes pulse { + 0%,100%{box-shadow:0 0 0 0 rgba(129,140,248,.4)} + 50%{box-shadow:0 0 0 8px rgba(129,140,248,0)} + } + .grd-ob-header-info { flex: 1; } + .grd-ob-title { font-weight: 700; font-size: 14px; color: #fff; } + .grd-ob-subtitle { font-size: 11px; color: #818cf8; margin-top: 1px; } + .grd-ob-header-actions { display: flex; gap: 4px; } + .grd-ob-btn-icon { + width: 26px; height: 26px; border-radius: 6px; + background: rgba(255,255,255,.08); + color: #94a3b8; border: none; cursor: pointer; + font-size: 13px; display: flex; align-items: center; justify-content: center; + transition: all .15s; + } + .grd-ob-btn-icon:hover { background: rgba(255,255,255,.15); color: #fff; } + + #grd-ob-progress-bar { + height: 4px; background: rgba(255,255,255,.08); + flex-shrink: 0; + } + #grd-ob-progress-fill { + height: 100%; background: linear-gradient(90deg,#818cf8,#6ee7b7); + transition: width .5s ease; + } + #grd-ob-progress-label { + font-size: 10px; color: #64748b; + text-align: right; padding: 3px 12px 0; + flex-shrink: 0; + } + + #grd-ob-messages { + flex: 1; overflow-y: auto; padding: 14px 12px; + display: flex; flex-direction: column; gap: 10px; + scrollbar-width: thin; + } + .grd-ob-msg { + display: flex; gap: 8px; align-items: flex-start; + } + .grd-ob-msg.user { flex-direction: row-reverse; } + .grd-ob-msg-avatar { + width: 28px; height: 28px; border-radius: 50%; + background: linear-gradient(135deg,#818cf8,#6366f1); + display: flex; align-items: center; justify-content: center; + font-size: 14px; flex-shrink: 0; + } + .grd-ob-msg.user .grd-ob-msg-avatar { + background: rgba(99,102,241,.25); + } + .grd-ob-msg-bubble { + background: #252b3b; + border-radius: 4px 14px 14px 14px; + padding: 10px 13px; + max-width: 270px; + line-height: 1.6; + white-space: pre-line; + } + .grd-ob-msg.user .grd-ob-msg-bubble { + background: #4f46e5; + border-radius: 14px 4px 14px 14px; + } + .grd-ob-msg-bubble strong { color: #a5b4fc; } + .grd-ob-msg-bubble code { + background: rgba(255,255,255,.1); + padding: 2px 5px; border-radius: 4px; + font-family: monospace; font-size: 12px; + } + .grd-ob-actions { + display: flex; flex-wrap: wrap; gap: 6px; + margin-top: 8px; padding-left: 36px; + } + .grd-ob-action-btn { + padding: 7px 14px; border-radius: 20px; + background: rgba(129,140,248,.18); + color: #818cf8; border: 1px solid rgba(129,140,248,.3); + font-size: 12px; font-weight: 600; cursor: pointer; + transition: all .15s; white-space: nowrap; + } + .grd-ob-action-btn:hover { + background: rgba(129,140,248,.35); + color: #fff; + } + .grd-ob-action-btn.primary { + background: #4f46e5; color: #fff; border-color: transparent; + } + .grd-ob-action-btn.primary:hover { background: #4338ca; } + + .grd-ob-typing { + display: flex; gap: 4px; padding: 10px 14px; + background: #252b3b; border-radius: 4px 14px 14px 14px; + width: fit-content; + } + .grd-ob-typing span { + width: 6px; height: 6px; border-radius: 50%; + background: #818cf8; animation: typing .8s ease infinite; + } + .grd-ob-typing span:nth-child(2) { animation-delay: .2s; } + .grd-ob-typing span:nth-child(3) { animation-delay: .4s; } + @keyframes typing { 0%,60%,100%{opacity:.3} 30%{opacity:1} } + + #grd-ob-input-area { + display: flex; gap: 8px; padding: 10px 12px; + border-top: 1px solid rgba(255,255,255,.07); + flex-shrink: 0; + } + #grd-ob-input { + flex: 1; background: rgba(255,255,255,.06); + border: 1px solid rgba(255,255,255,.1); + border-radius: 8px; color: #e2e8f0; + padding: 8px 12px; font-size: 13px; outline: none; + font-family: inherit; + } + #grd-ob-input:focus { border-color: #818cf8; } + #grd-ob-send { + width: 34px; height: 34px; border-radius: 8px; + background: #4f46e5; color: #fff; border: none; + cursor: pointer; font-size: 14px; + display: flex; align-items: center; justify-content: center; + transition: background .15s; + } + #grd-ob-send:hover { background: #4338ca; } + + /* 스포트라이트 */ + .grd-spotlight { + position: fixed; z-index: 9998; + border: 2px solid #818cf8; + border-radius: 8px; + box-shadow: 0 0 0 9999px rgba(0,0,0,.5), 0 0 24px rgba(129,140,248,.6); + pointer-events: none; + transition: all .3s ease; + animation: spotlight-pulse 2s ease infinite; + } + @keyframes spotlight-pulse { + 0%,100%{box-shadow:0 0 0 9999px rgba(0,0,0,.5),0 0 24px rgba(129,140,248,.4)} + 50%{box-shadow:0 0 0 9999px rgba(0,0,0,.5),0 0 40px rgba(129,140,248,.8)} + } + + /* 도움말 버튼 (온보딩 완료 후) */ + #grd-help-btn { + position: fixed; right: 20px; bottom: 20px; + width: 48px; height: 48px; border-radius: 50%; + background: linear-gradient(135deg,#818cf8,#6366f1); + color: #fff; border: none; cursor: pointer; + font-size: 22px; z-index: 9000; + box-shadow: 0 4px 16px rgba(129,140,248,.4); + display: flex; align-items: center; justify-content: center; + transition: transform .2s; + } + #grd-help-btn:hover { transform: scale(1.1); } + + @media (max-width: 768px) { + #grd-onboarding { + width: 100%; right: 0; top: auto; bottom: 0; + transform: none; border-radius: 16px 16px 0 0; + border-left: none; border-top: 3px solid #818cf8; + max-height: 65vh; + } + } + `; + + document.head.appendChild(style); + document.body.appendChild(panel); + + // 이벤트 연결 + document.getElementById('grd-ob-minimize').onclick = toggleMinimize; + document.getElementById('grd-ob-close').onclick = closeBotConfirm; + document.getElementById('grd-ob-header').ondblclick = toggleMinimize; + document.getElementById('grd-ob-send').onclick = sendUserMessage; + document.getElementById('grd-ob-input').onkeydown = e => { + if (e.key === 'Enter') sendUserMessage(); + }; + } + + function buildHelpButton() { + if (document.getElementById('grd-help-btn')) return; + const btn = document.createElement('button'); + btn.id = 'grd-help-btn'; + btn.textContent = '?'; + btn.title = 'GUARDiA 도움말'; + btn.onclick = () => { + _onboarding_state_dismissed = false; + api('POST', '/api/onboarding/reset').then(() => location.reload()); + }; + document.body.appendChild(btn); + } + + // ── 메시지 렌더링 ───────────────────────────────────────── + function renderMessage(text, isUser = false, actions = []) { + const msgArea = document.getElementById('grd-ob-messages'); + if (!msgArea) return; + + const msg = document.createElement('div'); + msg.className = 'grd-ob-msg' + (isUser ? ' user' : ''); + + const avi = document.createElement('div'); + avi.className = 'grd-ob-msg-avatar'; + avi.textContent = isUser ? '👤' : '🤖'; + + const bubble = document.createElement('div'); + bubble.className = 'grd-ob-msg-bubble'; + // 마크다운 간단 렌더링 + bubble.innerHTML = text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/^```[\s\S]*?```$/gm, m => `${m.replace(/```\w*\n?/g,'').trim()}`) + .replace(/\n/g, '
'); + + msg.appendChild(avi); + msg.appendChild(bubble); + msgArea.appendChild(msg); + + // 액션 버튼 + if (actions && actions.length > 0) { + const actDiv = document.createElement('div'); + actDiv.className = 'grd-ob-actions'; + actions.forEach((act, i) => { + const btn = document.createElement('button'); + btn.className = 'grd-ob-action-btn' + (i === 0 ? ' primary' : ''); + btn.textContent = act.label; + btn.onclick = () => handleAction(act); + actDiv.appendChild(btn); + }); + msgArea.appendChild(actDiv); + } + + msgArea.scrollTop = msgArea.scrollHeight; + } + + function showTyping() { + const msgArea = document.getElementById('grd-ob-messages'); + if (!msgArea) return; + const el = document.createElement('div'); + el.className = 'grd-ob-msg'; + el.id = 'grd-ob-typing'; + el.innerHTML = ` +
🤖
+
+ +
`; + msgArea.appendChild(el); + msgArea.scrollTop = msgArea.scrollHeight; + } + + function hideTyping() { + document.getElementById('grd-ob-typing')?.remove(); + } + + // ── 단계 로드 ───────────────────────────────────────────── + function loadStep(step) { + if (!step) return; + state.currentStep = step; + + // 헤더 업데이트 + const sub = document.getElementById('grd-ob-subtitle'); + if (sub) sub.textContent = `${step.icon} ${step.title}`; + + // 진행 바 업데이트 + const pct = Math.round(step.order / 7 * 100); + const fill = document.getElementById('grd-ob-progress-fill'); + const label = document.getElementById('grd-ob-progress-label'); + if (fill) fill.style.width = pct + '%'; + if (label) label.textContent = `${step.order + 1} / 8 단계`; + + // 타이핑 효과 후 메시지 표시 + showTyping(); + setTimeout(() => { + hideTyping(); + renderMessage(step.message, false, step.actions); + // 스포트라이트 + if (step.target) spotlightElement(step.target); + }, 800); + + // 화면 이동 힌트 + if (step.view && location.pathname !== step.view.split('?')[0]) { + setTimeout(() => { + renderMessage(`💡 현재 화면: **${location.pathname}**\n이 단계는 **${step.view}** 화면에서 진행됩니다.`, false, [ + { label: `${step.view} 이동`, action: 'navigate', path: step.view } + ]); + }, 1500); + } + } + + // ── 액션 처리 ───────────────────────────────────────────── + async function handleAction(act) { + const step = state.currentStep; + + switch (act.action) { + case 'next': + case 'complete_step': + if (step) { + showTyping(); + const result = await postStep(step.id, 'complete'); + hideTyping(); + if (result?.step) loadStep(result.step); + } + break; + + case 'navigate': + if (act.path) { + if (act.path.startsWith('http')) { + window.open(act.path, '_blank'); + } else if (act.path.includes('?view=')) { + const viewName = act.path.split('?view=')[1]; + // GUARDiA SPA 뷰 전환 + const navItem = document.querySelector(`[data-view="${viewName}"]`); + if (navItem) navItem.click(); + } else { + location.href = act.path; + } + } + break; + + case 'external': + window.open(act.url || act.path, '_blank'); + break; + + case 'complete': + await completeOnboarding(); + renderMessage('🎉 모든 설정이 완료되었습니다!\n이제 GUARDiA의 모든 기능을 사용하세요.\n\n우측 하단 **?** 버튼으로 언제든 가이드를 다시 볼 수 있습니다.', false, []); + setTimeout(() => { + hide(); + buildHelpButton(); + }, 3000); + break; + + case 'skip': + if (step) { + const result = await postStep(step.id, 'skip'); + if (result?.step) loadStep(result.step); + } + break; + } + } + + // ── 사용자 메시지 전송 ──────────────────────────────────── + async function sendUserMessage() { + const input = document.getElementById('grd-ob-input'); + if (!input) return; + const text = input.value.trim(); + if (!text) return; + + input.value = ''; + renderMessage(text, true); + + showTyping(); + const resp = await postMessage(text); + hideTyping(); + + if (resp?.message) { + renderMessage(resp.message, false); + } + } + + // ── 스포트라이트 ────────────────────────────────────────── + function spotlightElement(selector) { + // 기존 스포트라이트 제거 + clearSpotlight(); + + const el = document.querySelector(selector); + if (!el) return; + + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setTimeout(() => { + const rect = el.getBoundingClientRect(); + const pad = 6; + const spot = document.createElement('div'); + spot.className = 'grd-spotlight'; + spot.id = 'grd-spotlight'; + spot.style.cssText = ` + top: ${rect.top - pad + window.scrollY}px; + left: ${rect.left - pad}px; + width: ${rect.width + pad * 2}px; + height: ${rect.height + pad * 2}px; + `; + document.body.appendChild(spot); + + // 8초 후 자동 제거 + setTimeout(clearSpotlight, 8000); + }, 400); + } + + function clearSpotlight() { + document.getElementById('grd-spotlight')?.remove(); + } + + // ── 화면 변화 감지 ──────────────────────────────────────── + function watchNavigation() { + // SPA 네비게이션 감지 (hash/pushState) + let lastPath = location.pathname; + const observer = new MutationObserver(() => { + if (location.pathname !== lastPath) { + lastPath = location.pathname; + onViewChange(location.pathname); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + // GUARDiA nav-item 클릭 감지 + document.addEventListener('click', e => { + const navItem = e.target.closest('[data-view]'); + if (navItem) { + setTimeout(() => onViewChange(location.pathname, navItem.dataset.view), 300); + } + }); + } + + function onViewChange(path, viewId) { + clearSpotlight(); + const step = state.currentStep; + if (!step || !step.view) return; + + const stepView = step.view.split('?')[0]; + if (path === stepView || (viewId && step.target?.includes(viewId))) { + // 현재 단계의 화면으로 이동했으면 힌트 표시 + setTimeout(() => { + if (step.target) spotlightElement(step.target); + renderMessage(`✅ 좋아요! 지금 **${step.title}** 단계를 진행 중입니다.\n\n${step.message.split('\n')[0]}`, false); + }, 500); + } + } + + // ── 패널 제어 ───────────────────────────────────────────── + function show() { + const panel = document.getElementById('grd-onboarding'); + if (panel) { panel.style.display = 'flex'; state.visible = true; } + } + + function hide() { + const panel = document.getElementById('grd-onboarding'); + if (panel) { panel.style.display = 'none'; state.visible = false; } + clearSpotlight(); + } + + function toggleMinimize() { + const panel = document.getElementById('grd-onboarding'); + if (!panel) return; + state.minimized = !state.minimized; + panel.classList.toggle('minimized', state.minimized); + document.getElementById('grd-ob-minimize').textContent = state.minimized ? '□' : '─'; + } + + function closeBotConfirm() { + if (confirm('온보딩 가이드를 닫을까요?\n언제든 우측하단 ? 버튼으로 다시 열 수 있습니다.')) { + dismissOnboarding(); + hide(); + buildHelpButton(); + } + } + + // ── 진입점 ──────────────────────────────────────────────── + // DOM 준비 후 실행 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + // 로그인 완료 후 토큰이 설정되면 초기화 + setTimeout(init, 1000); + } + + // 로그인 이벤트 감지 (localStorage 변화) + window.addEventListener('storage', e => { + if (e.key === 'access_token' && e.newValue && !_token) { + _token = e.newValue; + setTimeout(init, 500); + } + }); + + // 전역 노출 (수동 재시작) + window.GUARDiAOnboarding = { restart: () => { api('POST','/api/onboarding/reset').then(()=>location.reload()); } }; + +})(); diff --git a/setup/install_auto.sh b/setup/install_auto.sh index 3e5cf0b4..acef5063 100644 --- a/setup/install_auto.sh +++ b/setup/install_auto.sh @@ -310,6 +310,13 @@ _auto_start() { done ok "GUARDiA ITSM 정상 기동 확인!" + # 온보딩 초기화 (admin 계정 기준으로 온보딩 상태 리셋) + curl -sf -X POST http://localhost:8001/api/onboarding/reset \ + -H "Authorization: Bearer $(curl -sf -X POST http://localhost:8001/api/auth/login \ + -H 'Content-Type: application/json' -d '{"username":"admin","password":"1111"}' 2>/dev/null | \ + python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null)" \ + -o /dev/null 2>/dev/null && info "온보딩 가이드 활성화 완료" || true + # 브라우저 자동 열기 (GUI 환경) local server_ip server_ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")