diff --git a/backups/guardia_backup_20260530_011012.zip b/backups/guardia_backup_20260530_011012.zip new file mode 100644 index 0000000..dd954fe Binary files /dev/null and b/backups/guardia_backup_20260530_011012.zip differ diff --git a/main.py b/main.py index c77fadd..6570b84 100644 --- a/main.py +++ b/main.py @@ -52,6 +52,7 @@ from routers import ( topology, portfolio, infra_ext, + admin as admin_router, ) @@ -271,6 +272,7 @@ app.include_router(siem.router) # SIEM 보안 이벤트 연동 app.include_router(topology.router) # 네트워크 토폴로지 시각화 app.include_router(portfolio.router) # 포트폴리오 + 리소스 관리 app.include_router(infra_ext.router) # Zero Trust + K8s + ERP +app.include_router(admin_router.router) # GS인증: About + 백업/복구 + 에러코드 @app.get("/topology") diff --git a/routers/admin.py b/routers/admin.py new file mode 100644 index 0000000..0e82ca6 --- /dev/null +++ b/routers/admin.py @@ -0,0 +1,324 @@ +""" +GUARDiA ITSM 관리자 기능 API (GS인증 필수 요구사항) + +엔드포인트: + GET /api/admin/about — 시스템 버전/빌드 정보 (GS인증 유지보수성) + POST /api/admin/backup — DB 백업 (GS인증 신뢰성 > 복구성) + GET /api/admin/backups — 백업 목록 + POST /api/admin/restore/{filename} — 백업 복원 + GET /api/admin/health — 시스템 상태 종합 + GET /api/admin/errors/codes — 에러 코드 목록 (GS인증 기능적절성) +""" +from __future__ import annotations + +import logging +import os +import shutil +from datetime import datetime +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/admin", tags=["admin"]) + +# 백업 저장 경로 +BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "./backups")) +BACKUP_DIR.mkdir(parents=True, exist_ok=True) + +# ── About / 버전 정보 (GS인증 유지보수성) ────────────────────────────────── + +@router.get("/about") +async def get_about(_u: User = Depends(get_current_user)): + """시스템 버전, 빌드 정보, 라이선스, 오픈소스 정보 (GS인증 유지보수성 > 분석성).""" + import sys, platform + + return { + "product": "GUARDiA ITSM", + "version": "2.0.0", + "build_date": "2026-05-30", + "description": "AI 기반 레거시 인프라 자율 운영 플랫폼", + "vendor": "(주)지오정보기술", + "copyright": "Copyright © 2026 (주)지오정보기술 All Rights Reserved.", + "support": "support@zioinfo.co.kr", + "website": "https://www.zioinfo.co.kr", + "gs_cert": "GS 1등급 취득 예정 (2026년 12월)", + "environment": { + "python": sys.version.split()[0], + "os": platform.system() + " " + platform.release(), + "arch": platform.machine(), + }, + "open_source": [ + {"name": "FastAPI", "version": "0.115+", "license": "MIT"}, + {"name": "SQLAlchemy", "version": "2.0+", "license": "MIT"}, + {"name": "Pydantic", "version": "2.0+", "license": "MIT"}, + {"name": "React.js", "version": "18.3+", "license": "MIT"}, + {"name": "Chart.js", "version": "4.4+", "license": "MIT"}, + {"name": "D3.js", "version": "7.0+", "license": "BSD-3"}, + {"name": "Ollama", "version": "최신", "license": "MIT"}, + {"name": "APScheduler", "version": "3.x", "license": "MIT"}, + {"name": "paramiko", "version": "최신", "license": "LGPL"}, + ], + "api_count": 595, + "total_routes": 595, + } + + +# ── 에러 코드 목록 (GS인증 기능 적절성) ───────────────────────────────────── + +@router.get("/errors/codes") +async def get_error_codes(_u: User = Depends(get_current_user)): + """GUARDiA 에러 코드 및 해결 방법 목록 (GS인증 기능 적절성 > 오류 메시지).""" + return { + "error_codes": [ + # 인증/권한 + {"code": "AUTH_001", "http": 401, "message": "로그인이 필요합니다.", "solution": "다시 로그인해 주세요."}, + {"code": "AUTH_002", "http": 401, "message": "아이디 또는 비밀번호가 틀렸습니다.", "solution": "입력 정보를 확인하고 다시 시도하세요."}, + {"code": "AUTH_003", "http": 403, "message": "계정이 잠겼습니다.", "solution": "30분 후 다시 시도하거나 관리자에게 문의하세요."}, + {"code": "AUTH_004", "http": 403, "message": "이 기능을 사용할 권한이 없습니다.", "solution": "관리자에게 권한 부여를 요청하세요."}, + # SR + {"code": "SR_001", "http": 404, "message": "SR을 찾을 수 없습니다.", "solution": "SR ID를 확인하거나 목록에서 다시 검색하세요."}, + {"code": "SR_002", "http": 400, "message": "이 상태에서는 해당 전이를 할 수 없습니다.", "solution": "SR 상태 흐름을 확인하세요 (접수→파싱→승인→진행→완료)."}, + {"code": "SR_003", "http": 400, "message": "SR ID 목록이 비어 있습니다.", "solution": "처리할 SR을 하나 이상 선택하세요."}, + {"code": "SR_004", "http": 400, "message": "한 번에 최대 100건까지 처리할 수 있습니다.", "solution": "SR을 100건 이하로 나누어 처리하세요."}, + # 라이선스 + {"code": "LIC_001", "http": 402, "message": "라이선스가 만료되었습니다.", "solution": "/license 페이지에서 라이선스를 갱신하세요."}, + {"code": "LIC_002", "http": 409, "message": "무료 체험은 설치당 1회만 가능합니다.", "solution": "정식 라이선스를 구매하거나 지원팀에 문의하세요."}, + {"code": "LIC_003", "http": 400, "message": "라이선스 한도를 초과했습니다.", "solution": "상위 에디션으로 업그레이드하세요."}, + # CMDB + {"code": "CMDB_001", "http": 400, "message": "root SSH 계정은 등록할 수 없습니다.", "solution": "opsagent 등 일반 계정을 사용하세요 (보안 정책)."}, + {"code": "CMDB_002", "http": 404, "message": "서버를 찾을 수 없습니다.", "solution": "CMDB에서 서버가 등록되어 있는지 확인하세요."}, + # AI/LLM + {"code": "AI_001", "http": 503, "message": "AI 엔진에 연결할 수 없습니다.", "solution": "Ollama 서비스 상태를 확인하세요 (http://localhost:11434)."}, + {"code": "AI_002", "http": 400, "message": "외부 AI API는 사용할 수 없습니다.", "solution": "온프레미스 Ollama만 허용됩니다 (보안 정책)."}, + # 일반 + {"code": "SYS_001", "http": 500, "message": "서버 오류가 발생했습니다.", "solution": "잠시 후 다시 시도하거나 관리자에게 문의하세요."}, + {"code": "SYS_002", "http": 503, "message": "서비스가 일시적으로 사용할 수 없습니다.", "solution": "잠시 후 다시 시도하세요."}, + {"code": "VAL_001", "http": 422, "message": "입력 값이 올바르지 않습니다.", "solution": "입력 필드의 형식과 범위를 확인하세요."}, + ] + } + + +# ── 백업 (GS인증 신뢰성 > 복구성) ────────────────────────────────────────── + +@router.post("/backup") +async def create_backup( + db: AsyncSession = Depends(get_db), + cu: User = Depends(require_admin_role), +): + """DB 및 설정 파일 백업 생성 (ADMIN 전용).""" + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + backup_name = f"guardia_backup_{timestamp}" + backup_path = BACKUP_DIR / backup_name + backup_path.mkdir(parents=True, exist_ok=True) + + backed_up = [] + + # 1. SQLite DB 백업 + from database import engine + db_url = str(engine.url) + if "sqlite" in db_url: + db_file = db_url.replace("sqlite+aiosqlite:///", "").replace("sqlite:///", "") + db_file = Path(db_file) + if db_file.exists(): + shutil.copy2(db_file, backup_path / "guardia_itsm.db") + backed_up.append("guardia_itsm.db") + + # 2. .env 파일 백업 + env_paths = [ + Path("./itsm/.env"), + Path("./.env"), + Path("../.env"), + ] + for env_p in env_paths: + if env_p.exists(): + shutil.copy2(env_p, backup_path / ".env") + backed_up.append(".env") + break + + # 3. 업로드 파일 백업 + upload_dir = Path("./uploads") + if upload_dir.exists(): + shutil.copytree(upload_dir, backup_path / "uploads", dirs_exist_ok=True) + backed_up.append("uploads/") + + # 4. 백업 메타데이터 + meta = { + "backup_name": backup_name, + "created_at": datetime.utcnow().isoformat(), + "created_by": cu.username, + "files": backed_up, + "version": "2.0.0", + } + import json + (backup_path / "backup_meta.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + # 5. ZIP 압축 + zip_path = BACKUP_DIR / f"{backup_name}.zip" + shutil.make_archive(str(BACKUP_DIR / backup_name), "zip", str(BACKUP_DIR), backup_name) + shutil.rmtree(backup_path) + + size_mb = round(zip_path.stat().st_size / 1024 / 1024, 2) + + logger.info("백업 생성: %s (%.2fMB) by %s", zip_path.name, size_mb, cu.username) + + return { + "message": f"백업 완료: {zip_path.name}", + "backup_name": zip_path.name, + "size_mb": size_mb, + "files": backed_up, + "created_at": datetime.utcnow().isoformat(), + } + + +@router.get("/backups") +async def list_backups(cu: User = Depends(require_admin_role)): + """백업 파일 목록 조회 (ADMIN 전용).""" + backups = [] + for f in sorted(BACKUP_DIR.glob("guardia_backup_*.zip"), reverse=True): + stat = f.stat() + backups.append({ + "filename": f.name, + "size_mb": round(stat.st_size / 1024 / 1024, 2), + "created_at": datetime.fromtimestamp(stat.st_mtime).isoformat(), + }) + return {"backups": backups, "total": len(backups)} + + +@router.get("/backups/{filename}/download") +async def download_backup( + filename: str, + cu: User = Depends(require_admin_role), +): + """백업 파일 다운로드 (ADMIN 전용).""" + # 경로 조작 방지 + safe_name = Path(filename).name + if not safe_name.startswith("guardia_backup_") or not safe_name.endswith(".zip"): + raise HTTPException(400, "올바르지 않은 백업 파일명입니다.") + file_path = BACKUP_DIR / safe_name + if not file_path.exists(): + raise HTTPException(404, "백업 파일을 찾을 수 없습니다.") + return FileResponse( + path=str(file_path), + filename=safe_name, + media_type="application/zip", + ) + + +@router.post("/restore/{filename}") +async def restore_backup( + filename: str, + db: AsyncSession = Depends(get_db), + cu: User = Depends(require_admin_role), +): + """백업에서 복원 (ADMIN 전용 — 주의: 현재 데이터 덮어씀).""" + safe_name = Path(filename).name + if not safe_name.startswith("guardia_backup_") or not safe_name.endswith(".zip"): + raise HTTPException(400, "올바르지 않은 백업 파일명입니다.") + file_path = BACKUP_DIR / safe_name + if not file_path.exists(): + raise HTTPException(404, "백업 파일을 찾을 수 없습니다.") + + # 복원 전 현재 상태 임시 백업 + pre_backup = BACKUP_DIR / f"pre_restore_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.zip" + + # SQLite 복원 + import zipfile + extract_dir = BACKUP_DIR / "restore_temp" + extract_dir.mkdir(exist_ok=True) + + try: + with zipfile.ZipFile(file_path, "r") as zf: + zf.extractall(extract_dir) + + # DB 파일 복원 + from database import engine + db_url = str(engine.url) + if "sqlite" in db_url: + db_file = db_url.replace("sqlite+aiosqlite:///", "").replace("sqlite:///", "") + db_file = Path(db_file) + # 복원할 DB 파일 탐색 + for f in extract_dir.rglob("guardia_itsm.db"): + shutil.copy2(f, db_file) + break + + return { + "message": f"복원 완료: {filename}", + "warning": "서비스를 재시작해야 변경사항이 적용됩니다.", + "pre_backup": pre_backup.name if pre_backup.exists() else None, + } + finally: + shutil.rmtree(extract_dir, ignore_errors=True) + + +# ── 시스템 건강 상태 ────────────────────────────────────────────────────────── + +@router.get("/health") +async def system_health( + db: AsyncSession = Depends(get_db), + _u: User = Depends(get_current_user), +): + """시스템 종합 건강 상태 (GS인증 신뢰성 > 가용성).""" + import httpx + + checks = {} + + # DB 연결 + try: + await db.execute(text("SELECT 1")) + checks["database"] = {"status": "ok", "message": "DB 연결 정상"} + except Exception as e: + checks["database"] = {"status": "error", "message": str(e)[:100]} + + # Ollama + try: + async with httpx.AsyncClient(timeout=3.0) as c: + r = await c.get(os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + "/api/version") + checks["ollama"] = {"status": "ok" if r.status_code == 200 else "warn", + "message": r.json().get("version", "연결됨")} + except Exception: + checks["ollama"] = {"status": "warn", "message": "Ollama 미연결 (AI 기능 제한)"} + + # 디스크 + import shutil as sh + disk = sh.disk_usage(".") + free_gb = round(disk.free / 1024**3, 1) + checks["disk"] = { + "status": "ok" if free_gb > 1 else "warn", + "message": f"여유 공간: {free_gb}GB", + "free_gb": free_gb, + } + + # 라이선스 + try: + from routers.license import get_license_status + lic = await get_license_status(db) + checks["license"] = { + "status": "ok" if lic.get("valid") else ("warn" if lic.get("expired") else "info"), + "message": lic.get("message", ""), + "edition": lic.get("edition"), + "days": lic.get("days_remaining"), + } + except Exception: + checks["license"] = {"status": "warn", "message": "라이선스 조회 실패"} + + overall = "ok" if all(c["status"] == "ok" for c in checks.values()) else \ + "error" if any(c["status"] == "error" for c in checks.values()) else "warn" + + return { + "overall": overall, + "version": "2.0.0", + "uptime": "서버 시작 후 경과 시간", + "checks": checks, + "checked_at": datetime.utcnow().isoformat(), + } diff --git a/static/help.js b/static/help.js new file mode 100644 index 0000000..0778b51 --- /dev/null +++ b/static/help.js @@ -0,0 +1,450 @@ +/** + * GUARDiA ITSM 화면별 도움말 시스템 (GS인증 사용성 요구사항) + * - 각 화면/기능의 ? 버튼 → 팝업 도움말 + * - F1 키 → 현재 화면 도움말 + * - 검색 가능한 도움말 DB + */ +(function GUARDiAHelp() { + 'use strict'; + + // ── 도움말 데이터베이스 ──────────────────────────────────── + const HELP_DB = { + 'dashboard': { + title: '대시보드', + icon: '📊', + content: ` +
GUARDiA ITSM의 모든 운영 현황을 한눈에 확인할 수 있는 화면입니다.
+상단 숫자 카드를 클릭하면 해당 상세 목록으로 이동합니다.
+IT 서비스 요청(SR)을 접수·처리·추적하는 화면입니다.
+/sr <제목> - 메신저에서 즉시 접수/sla - SLA 위반 목록 조회
+ `,
+ },
+ 'cmdb': {
+ title: 'CMDB 형상관리',
+ icon: '🖥️',
+ content: `
+관리하는 모든 IT 자산(서버·소프트웨어·네트워크)을 등록·관리합니다.
+서버 간 의존관계를 등록하면 배포 영향도 자동 분석에 활용됩니다.
+⚠️ root 계정 SSH 직접 접속 금지 — opsagent 계정 사용
+ `, + }, + 'incidents': { + title: '인시던트 관리', + icon: '🚨', + content: ` +IT 서비스 장애를 신속하게 탐지·대응·복구하는 프로세스입니다.
+인시던트 종료 시 Ollama AI가 근본원인 초안을 자동 생성합니다.
+/incident <제목> P1 - 즉시 P1 인시던트 등록/rca INC-XXXX - AI RCA 분석 요청
+ `,
+ },
+ 'si': {
+ title: 'PMS 프로젝트 관리',
+ icon: '🏗️',
+ content: `
+SI 프로젝트의 전체 생명주기를 관리합니다.
+매일 18:00 일일 보고서, 매주 금요일 주간 보고서가 운영팀에 자동 발송됩니다.
+ `, + }, + 'license': { + title: '라이선스 관리', + icon: '🔏', + content: ` +| 에디션 | 기관 | 사용자 | 기능 |
|---|---|---|---|
| COMMUNITY | 1 | 10 | 기본 |
| STANDARD | 50 | 200 | 전체 |
| ENTERPRISE | 무제한 | 무제한 | 전체+APM |
무료 체험 시작 버튼으로 30일 체험판을 즉시 활성화할 수 있습니다.
+만료 30일 전부터 알림이 발송됩니다. 갱신 키를 입력하여 연장하세요.
+ `, + }, + 'agents': { + title: 'AI 에이전트', + icon: '🤖', + content: ` +GUARDiA의 AI 에이전트는 온프레미스 Ollama LLM을 사용합니다. 외부 API 호출 없음.
+상단 Ollama 상태 표시가 🟢이면 AI 기능 사용 가능합니다.
+ `, + }, + 'default': { + title: 'GUARDiA ITSM 도움말', + icon: '❓', + content: ` +AI 기반 레거시 인프라 자율 운영 플랫폼
+/help - 전체 명령어 목록/sr <제목> - SR 접수/status - 시스템 현황📧 support@zioinfo.co.kr | 📞 02-000-0000
+ `, + }, + }; + + // ── 현재 뷰 감지 ─────────────────────────────────────────── + function getCurrentView() { + const path = location.pathname; + const viewEl = document.querySelector('[data-view].active'); + const viewId = viewEl?.dataset?.view; + + if (viewId) return viewId; + if (path.includes('/si')) return 'si'; + if (path.includes('/incidents')) return 'incidents'; + if (path.includes('/license')) return 'license'; + if (path.includes('/agents')) return 'agents'; + return 'default'; + } + + // ── 팝업 HTML 빌드 ───────────────────────────────────────── + function buildPopup() { + if (document.getElementById('grd-help-popup')) return; + + const overlay = document.createElement('div'); + overlay.id = 'grd-help-overlay'; + overlay.innerHTML = ` +${data.content.replace(/<[^>]+>/g,'').substring(0,150)}...
+"${q}"에 대한 결과가 없습니다.
`; + } + + // ── ? 버튼 자동 삽입 ─────────────────────────────────────── + function injectHelpButtons() { + const targets = [ + { selector: '.card-header', topic: null }, + { selector: '.section-header', topic: null }, + { selector: '.page-hero-title', topic: null }, + { selector: '#grd-ob-header', topic: 'default', skip: true }, + ]; + + targets.forEach(({ selector, topic, skip }) => { + if (skip) return; + document.querySelectorAll(selector).forEach(el => { + if (el.querySelector('.grd-help-btn')) return; + const btn = document.createElement('button'); + btn.className = 'grd-help-btn'; + btn.textContent = '?'; + btn.title = '도움말 (F1)'; + btn.setAttribute('aria-label', '도움말'); + btn.onclick = e => { e.stopPropagation(); showHelp(topic); }; + el.style.position = 'relative'; + el.appendChild(btn); + }); + }); + } + + // ── 전역 ? 도움말 버튼 ───────────────────────────────────── + function buildGlobalHelpBtn() { + if (document.getElementById('grd-global-help')) return; + const btn = document.createElement('button'); + btn.id = 'grd-global-help'; + btn.textContent = '?'; + btn.title = 'GUARDiA 도움말 (F1)'; + btn.setAttribute('aria-label', '도움말'); + btn.style.cssText = ` + position:fixed; right:70px; bottom:20px; z-index:8999; + width:44px; height:44px; border-radius:50%; + background:#4f46e5; color:#fff; border:none; + font-size:18px; font-weight:700; cursor:pointer; + box-shadow:0 4px 16px rgba(79,70,229,.4); + display:flex; align-items:center; justify-content:center; + transition:transform .2s; + `; + btn.onmouseover = () => btn.style.transform = 'scale(1.1)'; + btn.onmouseout = () => btn.style.transform = ''; + btn.onclick = () => showHelp(); + document.body.appendChild(btn); + } + + // ── 키보드 단축키 ────────────────────────────────────────── + document.addEventListener('keydown', e => { + if (e.key === 'F1') { e.preventDefault(); showHelp(); } + if (e.key === 'Escape') { + const ol = document.getElementById('grd-help-overlay'); + if (ol && ol.style.display !== 'none') closeHelp(); + } + }); + + // ── 초기화 ───────────────────────────────────────────────── + function init() { + buildGlobalHelpBtn(); + injectHelpButtons(); + // SPA 뷰 변화 시 버튼 재삽입 + new MutationObserver(() => injectHelpButtons()) + .observe(document.body, { childList: true, subtree: true }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + setTimeout(init, 300); + } + + // 전역 노출 + window.GUARDiAHelp = { show: showHelp, close: closeHelp }; + +})(); diff --git a/static/index.html b/static/index.html index 7ce4b8b..a81e5d5 100644 --- a/static/index.html +++ b/static/index.html @@ -788,5 +788,7 @@ + +