feat(onboarding): 설치 후 자동 실행 가이드 챗봇 구현

[온보딩 API (routers/onboarding.py)]
- 8단계 온보딩 플로우:
  0. 환영 → 1. 비밀번호변경 → 2. 대시보드 → 3. 프로젝트등록
  → 4. 서버등록 → 5. 소스코드등록 → 6. 메신저봇 → 7. 완료
- POST /api/onboarding/message: 현재화면 + 사용자질문 → Ollama 답변
- 화면별 스포트라이트 target 정의 (CSS selector)
- 사용자별 단계 상태 영속 관리

[온보딩 챗봇 UI (static/onboarding.js)]
- 우측 고정 패널 (360px, 모바일 하단 슬라이드)
- 타이핑 애니메이션 효과 + 마크다운 렌더링
- 스포트라이트: 현재 단계 UI 요소를 하이라이트
- 화면 변화 감지 (MutationObserver + click 이벤트)
- 최소화/닫기/재시작 제어
- 사용자 질문 입력 → Ollama 실시간 답변
- 온보딩 완료 후 우측하단 ? 도움말 버튼
- 액션버튼: next/navigate/external/complete/skip

[설치 자동화 연동]
- install_auto.sh: 설치 완료 후 onboarding reset API 호출
- 브라우저 열릴 때 챗봇 자동 표시

사용자 경험:
  설치 완료 → 브라우저 자동 오픈 → 챗봇 우측 등장
  → "환영합니다!" → 비밀번호변경 화면 이동 안내
  → CMDB 서버등록 스포트라이트 → Gitea 소스등록
  → 완료 후 ? 버튼으로 재시작 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-05-30 09:43:40 +09:00
parent bae659adba
commit 97afb8d51d
5 changed files with 1010 additions and 0 deletions

View File

@ -46,6 +46,7 @@ from routers import (
jmeter, jmeter,
public_checklist, public_checklist,
customer_portal, customer_portal,
onboarding,
groupware, groupware,
siem, siem,
topology, topology,
@ -264,6 +265,7 @@ app.include_router(public_checklist.router)
# 추가 기능 # 추가 기능
app.include_router(customer_portal.router) # 고객 셀프서비스 포털 app.include_router(customer_portal.router) # 고객 셀프서비스 포털
app.include_router(onboarding.router) # 온보딩 가이드 챗봇
app.include_router(groupware.router) # 그룹웨어 전자결재 연동 app.include_router(groupware.router) # 그룹웨어 전자결재 연동
app.include_router(siem.router) # SIEM 보안 이벤트 연동 app.include_router(siem.router) # SIEM 보안 이벤트 연동
app.include_router(topology.router) # 네트워크 토폴로지 시각화 app.include_router(topology.router) # 네트워크 토폴로지 시각화

369
itsm/routers/onboarding.py Normal file
View File

@ -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),
}

View File

@ -786,5 +786,7 @@
</div> </div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<!-- 온보딩 가이드 챗봇 — 설치 완료 후 자동 실행 -->
<script src="/static/onboarding.js"></script>
</body> </body>
</html> </html>

630
itsm/static/onboarding.js Normal file
View File

@ -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 = `
<div id="grd-ob-header">
<div class="grd-ob-avatar">🤖</div>
<div class="grd-ob-header-info">
<div class="grd-ob-title">GUARDiA 가이드</div>
<div class="grd-ob-subtitle" id="grd-ob-subtitle">초기 설정 안내</div>
</div>
<div class="grd-ob-header-actions">
<button class="grd-ob-btn-icon" id="grd-ob-minimize" title="최소화"></button>
<button class="grd-ob-btn-icon" id="grd-ob-close" title="닫기"></button>
</div>
</div>
<!-- 진행 -->
<div id="grd-ob-progress-bar">
<div id="grd-ob-progress-fill"></div>
</div>
<div id="grd-ob-progress-label">1 / 8 단계</div>
<!-- 메시지 영역 -->
<div id="grd-ob-messages"></div>
<!-- 입력 영역 -->
<div id="grd-ob-input-area">
<input id="grd-ob-input" type="text" placeholder="질문이 있으면 입력하세요..." />
<button id="grd-ob-send" title="전송"></button>
</div>
`;
// 스타일
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, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^```[\s\S]*?```$/gm, m => `<code style="display:block;padding:8px;margin:4px 0;background:rgba(0,0,0,.3);border-radius:6px;font-size:11px;white-space:pre">${m.replace(/```\w*\n?/g,'').trim()}</code>`)
.replace(/\n/g, '<br>');
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 = `
<div class="grd-ob-msg-avatar">🤖</div>
<div class="grd-ob-typing">
<span></span><span></span><span></span>
</div>`;
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()); } };
})();

View File

@ -310,6 +310,13 @@ _auto_start() {
done done
ok "GUARDiA ITSM 정상 기동 확인!" 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 환경) # 브라우저 자동 열기 (GUI 환경)
local server_ip local server_ip
server_ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost") server_ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")