feat(itsm): PMS/준수성/JMeter/공공기관 기능 + Nifty UI + 로고 Copyright

[PMS 완성]
- core/si_report.py: 일/주/월 보고서 (Excel/HTML/PDF/DOCX/PPTX)
- routers/si_report.py: daily|weekly|monthly + 메신저 발송
- routers/deliverables.py: 산출물 CRUD + 제출/검토
- si_issues.py: 이슈→SR 자동 연결
- scheduler.py: 일일 18:00 + 주간 금 17:00 자동 보고서
- models.py: Deliverable 모델

[준수성 자동 점검]
- core/compliance_check.py: SC-8개/WA-7개/PI-6개 규칙
- routers/compliance.py: 스캔 + HTML/Excel 보고서

[JMeter 성능 테스트]
- routers/jmeter.py: JTL 업로드 + 내장 부하 테스트 + 보고서

[공공기관 필수 기능]
- routers/public_checklist.py: 행안부 기준 19개 항목

[UI/브랜드]
- 로고(ziologo.png) + Copyright 2026 All Rights Reserved
- Nifty 계층형 사이드바 (PMS 서브메뉴)
- X-Powered-By + X-Copyright 응답 헤더
- manual/15_UI_Nifty_가이드.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-05-29 22:50:29 +09:00
parent d3c2754515
commit 82a4c72080
22 changed files with 3695 additions and 61 deletions

View File

@ -0,0 +1,291 @@
"""
GUARDiA 준수성 자동 점검 엔진
1. 시큐어코딩 (행안부 SW 보안약점 기준 + OWASP Top 10)
2. 접근성 (WCAG 2.1 / KWCAG 2.1 한국형)
3. 개인정보보호법 (PIPA 개인정보 식별자 탐지)
"""
from __future__ import annotations
import ast
import re
import os
import logging
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 1. 시큐어코딩 점검 ───────────────────────────────────────────────────────
# 행안부 SW 보안약점 주요 패턴
SECURE_CODING_RULES = [
# SQL 인젝션
{
"id": "SC-001",
"category": "SQL 인젝션",
"severity": "CRITICAL",
"pattern": r'execute\s*\(\s*f["\'].*\{|execute\s*\(\s*".*%.*%|execute\s*\(\s*\'.*\'\.format\(',
"message": "SQL 문자열 직접 포맷팅 — 파라미터 바인딩 사용 필요",
},
# XSS
{
"id": "SC-002",
"category": "XSS",
"severity": "HIGH",
"pattern": r'innerHTML\s*=\s*(?!"|\')|\.html\s*\(',
"message": "innerHTML 직접 삽입 — textContent 또는 escapeHtml 함수 사용 필요",
},
# 하드코딩 비밀번호/키
{
"id": "SC-003",
"category": "하드코딩 자격증명",
"severity": "CRITICAL",
"pattern": r'(?i)(password|passwd|secret|api_key|apikey|token)\s*=\s*["\'][^"\']{6,}["\']',
"message": "하드코딩된 자격증명 — 환경변수 또는 시크릿 관리자 사용 필요",
},
# OS 명령어 인젝션
{
"id": "SC-004",
"category": "OS 명령어 인젝션",
"severity": "CRITICAL",
"pattern": r'os\.system\s*\(|subprocess\.call\s*\(.*shell\s*=\s*True|eval\s*\(',
"message": "shell=True 또는 eval 사용 — 입력값 검증 필수",
},
# 경로 조작
{
"id": "SC-005",
"category": "경로 조작",
"severity": "HIGH",
"pattern": r'open\s*\(\s*.*\+|open\s*\(\s*f["\']',
"message": "사용자 입력 기반 파일 경로 — Path.resolve().relative_to() 검증 필수",
},
# 정보 노출
{
"id": "SC-006",
"category": "민감 정보 노출",
"severity": "MEDIUM",
"pattern": r'traceback\.print_exc\(\)|print\s*\(.*exception|logger\.(info|debug).*password',
"message": "예외 스택트레이스 직접 출력 — 상세 오류 메시지 은닉 필요",
},
# CSRF 위험 (GET으로 상태 변경)
{
"id": "SC-007",
"category": "CSRF",
"severity": "MEDIUM",
"pattern": r'@router\.get\s*\([^\)]+\)\s*\nasync def.*(delete|remove|drop|truncate)',
"message": "GET 메서드로 데이터 변경 — POST/DELETE 사용 및 CSRF 토큰 적용 필요",
},
# 취약한 암호화
{
"id": "SC-008",
"category": "취약 암호화",
"severity": "HIGH",
"pattern": r'md5|sha1\s*\(|DES\.|RC4\.',
"message": "취약한 해시/암호화 알고리즘 — SHA-256 이상 또는 AES-256-GCM 사용 필요",
},
]
# ── 2. 웹 접근성 점검 (HTML 기반) ────────────────────────────────────────────
ACCESSIBILITY_RULES = [
{
"id": "WA-001",
"category": "대체 텍스트",
"level": "A",
"pattern": r'<img(?![^>]*alt=)[^>]*>',
"message": "img 요소에 alt 속성 없음 — 시각 장애인 스크린리더 접근 불가",
},
{
"id": "WA-002",
"category": "색상 대비",
"level": "AA",
"pattern": r'color:\s*#(?:aaa|999|bbb|ccc|ddd|eee|f{3,6})',
"message": "낮은 색상 대비 — 4.5:1 이상 비율 필요 (WCAG 2.1 AA)",
},
{
"id": "WA-003",
"category": "키보드 접근성",
"level": "A",
"pattern": r'onclick="[^"]*"(?![^>]*tabindex)',
"message": "onclick만 있는 요소 — tabindex + onkeydown 추가 또는 <button> 사용",
},
{
"id": "WA-004",
"category": "폼 레이블",
"level": "A",
"pattern": r'<input(?![^>]*aria-label|[^>]*id=)[^>]*>',
"message": "input 요소에 label/aria-label 없음 — 폼 접근성 미준수",
},
{
"id": "WA-005",
"category": "언어 설정",
"level": "A",
"pattern": r'<html(?![^>]*lang=)',
"message": "html 요소에 lang 속성 없음 — <html lang=\"ko\"> 필요",
},
{
"id": "WA-006",
"category": "포커스 표시",
"level": "AA",
"pattern": r'outline:\s*none|outline:\s*0',
"message": "포커스 표시 제거 — 키보드 사용자 탐색 불가, :focus-visible 활용 권장",
},
{
"id": "WA-007",
"category": "ARIA 역할",
"level": "A",
"pattern": r'<div\s+onclick|<span\s+onclick',
"message": "div/span에 onclick — <button role=\"button\"> 또는 role+tabindex 추가 필요",
},
]
# ── 3. 개인정보보호법 위반 탐지 ──────────────────────────────────────────────
PIPA_RULES = [
{
"id": "PI-001",
"category": "주민등록번호",
"severity": "CRITICAL",
"pattern": r'\d{6}[-]\d{7}|\d{13}',
"message": "주민등록번호 패턴 감지 — 법적 수집 제한 항목 (개인정보보호법 제24조)",
},
{
"id": "PI-002",
"category": "신용카드번호",
"severity": "CRITICAL",
"pattern": r'\b(?:\d{4}[-\s]?){3}\d{4}\b',
"message": "신용카드번호 패턴 감지 — 마스킹 처리 필요",
},
{
"id": "PI-003",
"category": "이메일 수집",
"severity": "MEDIUM",
"pattern": r'email.*=.*Column.*String|Column\(.*email',
"message": "이메일 DB 저장 — 수집 동의 여부 및 암호화 저장 확인 필요",
},
{
"id": "PI-004",
"category": "비밀번호 평문",
"severity": "CRITICAL",
"pattern": r'password.*=.*Column(?!.*hash)|password_hash\s*=\s*None',
"message": "비밀번호 평문 저장 의심 — bcrypt/argon2 해시 저장 필수",
},
{
"id": "PI-005",
"category": "전화번호",
"severity": "LOW",
"pattern": r'01[016789][-]?\d{3,4}[-]?\d{4}',
"message": "전화번호 패턴 감지 — 불필요한 수집 여부 검토 필요",
},
{
"id": "PI-006",
"category": "로그에 개인정보",
"severity": "HIGH",
"pattern": r'logger\.(info|debug|warning).*(?:email|phone|name|password)',
"message": "로그에 개인정보 기록 의심 — 로그 마스킹 처리 필요",
},
]
# ── 점검 실행 ────────────────────────────────────────────────────────────────
def scan_file(filepath: str, content: str) -> list[dict]:
"""단일 파일에 대한 전체 점검 수행."""
ext = Path(filepath).suffix.lower()
findings = []
rule_sets = []
if ext in (".py",):
rule_sets = [SECURE_CODING_RULES, PIPA_RULES]
if ext in (".html", ".css", ".js"):
rule_sets.append(ACCESSIBILITY_RULES)
if ext in (".html", ".js"):
rule_sets.append(SECURE_CODING_RULES) # XSS 등
if ext in (".py", ".js", ".html"):
rule_sets.append(PIPA_RULES)
# 중복 제거
seen_ids: set = set()
for rules in rule_sets:
for rule in rules:
if rule["id"] in seen_ids:
continue
seen_ids.add(rule["id"])
try:
matches = list(re.finditer(rule["pattern"], content, re.IGNORECASE | re.MULTILINE))
for m in matches:
line_no = content[:m.start()].count("\n") + 1
findings.append({
"file": filepath,
"line": line_no,
"rule_id": rule["id"],
"category": rule["category"],
"severity": rule.get("severity", rule.get("level", "INFO")),
"message": rule["message"],
"snippet": content[max(0, m.start()-20):m.end()+20].strip()[:100],
})
except re.error:
pass
return findings
async def scan_project(base_dir: str = None) -> dict[str, Any]:
"""GUARDiA 프로젝트 전체 준수성 점검."""
if not base_dir:
base_dir = str(Path(__file__).parent.parent)
target_dirs = [
Path(base_dir) / "routers",
Path(base_dir) / "core",
Path(base_dir) / "static",
]
all_findings = []
scanned = 0
for tdir in target_dirs:
if not tdir.exists():
continue
for fpath in tdir.rglob("*"):
if fpath.suffix not in (".py", ".html", ".js", ".css"):
continue
if fpath.name.startswith("."):
continue
try:
content = fpath.read_text(encoding="utf-8", errors="ignore")
findings = scan_file(str(fpath.relative_to(base_dir)), content)
all_findings.extend(findings)
scanned += 1
except Exception:
pass
# 집계
by_severity: dict = {}
by_category: dict = {}
for f in all_findings:
sev = f["severity"]
cat = f["category"]
by_severity[sev] = by_severity.get(sev, 0) + 1
by_category[cat] = by_category.get(cat, 0) + 1
critical = by_severity.get("CRITICAL", 0)
high = by_severity.get("HIGH", 0)
risk_level = "CRITICAL" if critical > 0 else "HIGH" if high > 0 else "MEDIUM" if all_findings else "LOW"
return {
"scan_time": datetime.utcnow().isoformat(),
"scanned_files": scanned,
"total_findings": len(all_findings),
"risk_level": risk_level,
"by_severity": by_severity,
"by_category": by_category,
"findings": all_findings[:200], # 최대 200건
"summary": {
"secure_coding": sum(1 for f in all_findings if f["rule_id"].startswith("SC")),
"accessibility": sum(1 for f in all_findings if f["rule_id"].startswith("WA")),
"privacy": sum(1 for f in all_findings if f["rule_id"].startswith("PI")),
}
}

199
itsm/core/oauth.py Normal file
View File

@ -0,0 +1,199 @@
"""
OAuth2 / OpenID Connect 소셜 로그인 GUARDiA ITSM
지원 제공자:
google : Google OAuth2 (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET)
github : GitHub OAuth2 (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET)
kakao : 카카오 로그인 (KAKAO_CLIENT_ID)
naver : 네이버 로그인 (NAVER_CLIENT_ID / NAVER_CLIENT_SECRET)
keycloak: 온프레미스 Keycloak OIDC (KEYCLOAK_BASE_URL / KEYCLOAK_REALM / ...)
환경변수 미설정 해당 제공자는 로그인 화면에서 숨겨진다.
외부망 차단 환경에서는 Keycloak(온프레미스) 활성화 권장.
"""
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
import time
from typing import Optional
from urllib.parse import urlencode
import httpx
# ── 환경변수 ──────────────────────────────────────────────────────────────────
_BASE = os.getenv("OAUTH_REDIRECT_BASE", "http://localhost:8001")
PROVIDERS: dict[str, dict] = {
"google": {
"name": "Google",
"icon": "google",
"enabled": bool(os.getenv("GOOGLE_CLIENT_ID")),
"client_id": os.getenv("GOOGLE_CLIENT_ID", ""),
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET", ""),
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"userinfo_url": "https://www.googleapis.com/oauth2/v3/userinfo",
"scope": "openid email profile",
"redirect_uri": f"{_BASE}/api/auth/oauth/google/callback",
},
"github": {
"name": "GitHub",
"icon": "github",
"enabled": bool(os.getenv("GITHUB_CLIENT_ID")),
"client_id": os.getenv("GITHUB_CLIENT_ID", ""),
"client_secret": os.getenv("GITHUB_CLIENT_SECRET", ""),
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"userinfo_url": "https://api.github.com/user",
"scope": "read:user user:email",
"redirect_uri": f"{_BASE}/api/auth/oauth/github/callback",
},
"kakao": {
"name": "카카오",
"icon": "kakao",
"enabled": bool(os.getenv("KAKAO_CLIENT_ID")),
"client_id": os.getenv("KAKAO_CLIENT_ID", ""),
"client_secret": os.getenv("KAKAO_CLIENT_SECRET", ""),
"auth_url": "https://kauth.kakao.com/oauth/authorize",
"token_url": "https://kauth.kakao.com/oauth/token",
"userinfo_url": "https://kapi.kakao.com/v2/user/me",
"scope": "profile_nickname account_email",
"redirect_uri": f"{_BASE}/api/auth/oauth/kakao/callback",
},
"naver": {
"name": "네이버",
"icon": "naver",
"enabled": bool(os.getenv("NAVER_CLIENT_ID")),
"client_id": os.getenv("NAVER_CLIENT_ID", ""),
"client_secret": os.getenv("NAVER_CLIENT_SECRET", ""),
"auth_url": "https://nid.naver.com/oauth2.0/authorize",
"token_url": "https://nid.naver.com/oauth2.0/token",
"userinfo_url": "https://openapi.naver.com/v1/nid/me",
"scope": "name email",
"redirect_uri": f"{_BASE}/api/auth/oauth/naver/callback",
},
"keycloak": {
"name": "SSO (Keycloak)",
"icon": "sso",
"enabled": bool(os.getenv("KEYCLOAK_BASE_URL")),
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "guardia"),
"client_secret": os.getenv("KEYCLOAK_CLIENT_SECRET", ""),
"auth_url": f"{os.getenv('KEYCLOAK_BASE_URL','')}/realms/{os.getenv('KEYCLOAK_REALM','master')}/protocol/openid-connect/auth",
"token_url": f"{os.getenv('KEYCLOAK_BASE_URL','')}/realms/{os.getenv('KEYCLOAK_REALM','master')}/protocol/openid-connect/token",
"userinfo_url": f"{os.getenv('KEYCLOAK_BASE_URL','')}/realms/{os.getenv('KEYCLOAK_REALM','master')}/protocol/openid-connect/userinfo",
"scope": "openid email profile",
"redirect_uri": f"{_BASE}/api/auth/oauth/keycloak/callback",
},
}
# 임시 state 저장소 (메모리 — 분산 환경에서는 Redis 사용)
_states: dict[str, float] = {}
def get_enabled_providers() -> list[dict]:
"""활성화된 OAuth 제공자 목록 (민감정보 제외)."""
return [
{"id": pid, "name": p["name"], "icon": p["icon"]}
for pid, p in PROVIDERS.items()
if p["enabled"]
]
def build_auth_url(provider_id: str) -> str:
"""OAuth 인증 URL 생성 + CSRF state 발급."""
p = PROVIDERS.get(provider_id)
if not p or not p["enabled"]:
raise ValueError(f"OAuth 제공자 미설정: {provider_id}")
state = secrets.token_urlsafe(32)
_states[state] = time.time()
# 5분 초과 state 정리
cutoff = time.time() - 300
expired = [k for k, v in _states.items() if v < cutoff]
for k in expired:
_states.pop(k, None)
params = {
"client_id": p["client_id"],
"redirect_uri": p["redirect_uri"],
"scope": p["scope"],
"response_type": "code",
"state": state,
}
return p["auth_url"] + "?" + urlencode(params)
def verify_state(state: str) -> bool:
"""CSRF state 검증."""
ts = _states.pop(state, None)
if ts is None:
return False
return (time.time() - ts) < 300 # 5분 이내
async def exchange_code(provider_id: str, code: str) -> Optional[dict]:
"""인가 코드 → 액세스 토큰 교환 → 사용자 정보 조회."""
p = PROVIDERS.get(provider_id)
if not p:
return None
try:
async with httpx.AsyncClient(timeout=15.0) as client:
# 토큰 교환
token_data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": p["redirect_uri"],
"client_id": p["client_id"],
"client_secret": p["client_secret"],
}
headers = {"Accept": "application/json"}
resp = await client.post(p["token_url"], data=token_data, headers=headers)
resp.raise_for_status()
tokens = resp.json()
access_token = tokens.get("access_token")
if not access_token:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
# 사용자 정보 조회
userinfo_resp = await client.get(
p["userinfo_url"],
headers={"Authorization": f"Bearer {access_token}"},
)
userinfo_resp.raise_for_status()
return userinfo_resp.json()
except Exception:
return None
def extract_email(provider_id: str, userinfo: dict) -> Optional[str]:
"""제공자별 이메일 추출."""
if provider_id in ("google", "keycloak"):
return userinfo.get("email")
if provider_id == "github":
return userinfo.get("email") or f"{userinfo.get('login', '')}@github.com"
if provider_id == "kakao":
return userinfo.get("kakao_account", {}).get("email")
if provider_id == "naver":
return userinfo.get("response", {}).get("email")
return userinfo.get("email")
def extract_name(provider_id: str, userinfo: dict) -> str:
"""제공자별 표시 이름 추출."""
if provider_id in ("google", "keycloak"):
return userinfo.get("name", "")
if provider_id == "github":
return userinfo.get("name") or userinfo.get("login", "")
if provider_id == "kakao":
return userinfo.get("kakao_account", {}).get("profile", {}).get("nickname", "")
if provider_id == "naver":
return userinfo.get("response", {}).get("name", "")
return userinfo.get("name", "")

View File

@ -622,6 +622,82 @@ def start_scheduler() -> None:
except Exception as exc: except Exception as exc:
logger.warning("SLA 스케줄 등록 실패 (무시): %s", exc) logger.warning("SLA 스케줄 등록 실패 (무시): %s", exc)
# ── SI 프로젝트 자동 보고서 ─────────────────────────────────
try:
async def _si_daily_report():
"""매일 18:00 — 활성 SI 프로젝트 일일 보고서 메신저 발송."""
from database import SessionLocal
from models import SiProject
from sqlalchemy import select
from core.si_report import collect_project_data, generate_html
import os, httpx
async with SessionLocal() as db:
projects = (await db.execute(
select(SiProject).where(SiProject.is_active == True)
)).scalars().all()
for proj in projects:
try:
async with SessionLocal() as db:
data = await collect_project_data(proj.id, db)
from core.si_report import generate_html as gh
_ = gh(data, "daily") # 생성 검증
summary = (
f"[일일보고] {proj.project_name}\n"
f"진척: {data['overall_progress']}% | 이슈: {data['issue_open']}건 | 지연WBS: {data['wbs_delayed']}"
)
await _push_ops_notify(f"[SI 일일보고] {proj.project_name}", summary)
except Exception as ex:
logger.warning("SI 일일보고 실패: project=%d err=%s", proj.id, ex)
_scheduler.add_job(
_si_daily_report,
CronTrigger(hour=18, minute=0, timezone="Asia/Seoul"),
id="si_daily_report",
name="SI 일일 보고서 발송 (18:00)",
replace_existing=True,
misfire_grace_time=3600,
)
async def _si_weekly_report():
"""매주 금요일 17:00 — 주간 보고서 메신저 발송."""
from database import SessionLocal
from models import SiProject
from sqlalchemy import select
from core.si_report import collect_project_data
async with SessionLocal() as db:
projects = (await db.execute(
select(SiProject).where(SiProject.is_active == True)
)).scalars().all()
for proj in projects:
try:
async with SessionLocal() as db:
data = await collect_project_data(proj.id, db)
msg = (
f"[주간보고] {proj.project_name} ({proj.project_code})\n"
f"진척률: {data['overall_progress']}% | 단계: {proj.phase}\n"
f"WBS {data['wbs_done']}/{data['wbs_total']} 완료 | 지연 {data['wbs_delayed']}\n"
f"미결이슈: {data['issue_open']}건 | 고위험: {len(data['high_risks'])}"
)
await _push_ops_notify(f"[SI 주간보고] {proj.project_name}", msg)
except Exception as ex:
logger.warning("SI 주간보고 실패: project=%d err=%s", proj.id, ex)
_scheduler.add_job(
_si_weekly_report,
CronTrigger(day_of_week="fri", hour=17, minute=0, timezone="Asia/Seoul"),
id="si_weekly_report",
name="SI 주간 보고서 발송 (금 17:00)",
replace_existing=True,
misfire_grace_time=3600,
)
logger.info("SI 자동 보고서 스케줄 등록 완료 (일일 18:00 / 주간 금 17:00)")
except Exception as exc:
logger.warning("SI 보고서 스케줄 등록 실패 (무시): %s", exc)
# ── Scouter APM 알람 수집 (5분마다) ───────────────────────── # ── Scouter APM 알람 수집 (5분마다) ─────────────────────────
try: try:
async def _scouter_alert_check(): async def _scouter_alert_check():

683
itsm/core/si_report.py Normal file
View File

@ -0,0 +1,683 @@
"""
SI 프로젝트 보고서 자동 생성 엔진
지원 형식:
- Excel (.xlsx): WBS 현황 + 이슈 목록 + 산출물 현황 + KPI
- HTML: 보고서 (대시보드 표시)
- PDF: HTML PDF 변환 (weasyprint)
- DOCX: Word 보고서 (python-docx)
- PPTX: PowerPoint 보고서 (python-pptx)
보고서 유형:
- daily: 일일 진행 현황 (당일 WBS 완료 + 이슈 발생)
- weekly: 주간 보고서 (완료율 + 이슈 + 위험 요약)
- monthly: 월간 보고서 (KPI + 예산 + 이달의 산출물)
"""
from __future__ import annotations
import io
import logging
import os
from datetime import date, datetime, timedelta
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# ── 프로젝트 데이터 수집 ──────────────────────────────────────────────────────
async def collect_project_data(project_id: int, db) -> Dict[str, Any]:
"""보고서에 필요한 전체 프로젝트 데이터 수집."""
from models import (
SiProject, WbsItem, WbsStatus,
ProjectIssue, IssueStatus,
ProjectMilestone,
ProjectRisk, RiskLevel,
Deliverable, DeliverableStatus,
)
from sqlalchemy import select, func
proj = await db.get(SiProject, project_id)
if not proj:
raise ValueError(f"프로젝트 {project_id}를 찾을 수 없습니다.")
today = date.today()
# WBS 항목
wbs_items = (await db.execute(
select(WbsItem).where(WbsItem.project_id == project_id)
.order_by(WbsItem.wbs_code)
)).scalars().all()
leaf_items = [w for w in wbs_items if w.is_leaf]
completed = [w for w in leaf_items if w.completion_pct >= 100]
delayed = [w for w in leaf_items if w.planned_end and w.planned_end < today and w.completion_pct < 100]
# 이슈
issues = (await db.execute(
select(ProjectIssue).where(ProjectIssue.project_id == project_id)
.order_by(ProjectIssue.raised_date.desc())
)).scalars().all()
open_issues = [i for i in issues if i.status in (IssueStatus.OPEN, IssueStatus.IN_PROGRESS)]
closed_issues = [i for i in issues if i.status == IssueStatus.CLOSED]
# 마일스톤
milestones = (await db.execute(
select(ProjectMilestone).where(ProjectMilestone.project_id == project_id)
.order_by(ProjectMilestone.target_date)
)).scalars().all()
upcoming_milestones = [
m for m in milestones if m.target_date and m.target_date >= today
][:3]
# 위험
risks = (await db.execute(
select(ProjectRisk).where(ProjectRisk.project_id == project_id)
)).scalars().all()
high_risks = [r for r in risks if r.risk_level in (RiskLevel.HIGH, RiskLevel.CRITICAL)]
# 산출물
deliverables = (await db.execute(
select(Deliverable).where(Deliverable.project_id == project_id)
)).scalars().all()
overdue_deliverables = [
d for d in deliverables
if d.status == "PENDING" and d.due_date and d.due_date < today
]
# 진척률 계산
overall_progress = (
sum(w.completion_pct for w in leaf_items) / len(leaf_items)
if leaf_items else 0
)
# 예산 소진율
budget_pct = (
round(proj.budget_used / proj.budget_total * 100, 1)
if proj.budget_total else 0.0
)
return {
"project": proj,
"today": today,
"wbs_items": wbs_items,
"leaf_items": leaf_items,
"completed_wbs": completed,
"delayed_wbs": delayed,
"issues": issues,
"open_issues": open_issues,
"closed_issues": closed_issues,
"milestones": milestones,
"upcoming_milestones": upcoming_milestones,
"risks": risks,
"high_risks": high_risks,
"deliverables": deliverables,
"overdue_deliverables": overdue_deliverables,
"overall_progress": round(overall_progress, 1),
"budget_pct": budget_pct,
"wbs_total": len(leaf_items),
"wbs_done": len(completed),
"wbs_delayed": len(delayed),
"issue_open": len(open_issues),
"issue_closed": len(closed_issues),
}
# ── Excel 보고서 ──────────────────────────────────────────────────────────────
def generate_excel(data: Dict[str, Any], report_type: str = "weekly") -> bytes:
"""Excel (.xlsx) 보고서 생성."""
try:
import openpyxl
from openpyxl.styles import (
Font, PatternFill, Alignment, Border, Side, numbers
)
from openpyxl.utils import get_column_letter
except ImportError:
raise ValueError("openpyxl 미설치: pip install openpyxl")
proj = data["project"]
today = data["today"]
wb = openpyxl.Workbook()
wb.remove(wb.active)
# ── 공통 스타일 ────────────────────────────────────────────
HEADER_FILL = PatternFill("solid", fgColor="1E3A5F")
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
TITLE_FONT = Font(bold=True, size=13, color="1E3A5F")
OK_FILL = PatternFill("solid", fgColor="D5F5E3")
WARN_FILL = PatternFill("solid", fgColor="FEF9E7")
DANGER_FILL = PatternFill("solid", fgColor="FADBD8")
CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
LEFT = Alignment(horizontal="left", vertical="center", wrap_text=True)
thin = Side(style="thin", color="CCCCCC")
BORDER = Border(left=thin, right=thin, top=thin, bottom=thin)
def header_row(ws, headers, row=1):
for col, h in enumerate(headers, 1):
c = ws.cell(row=row, column=col, value=h)
c.font = HEADER_FONT; c.fill = HEADER_FILL
c.alignment = CENTER; c.border = BORDER
def data_row(ws, values, row, fills=None):
for col, val in enumerate(values, 1):
c = ws.cell(row=row, column=col, value=val)
c.alignment = LEFT; c.border = BORDER
if fills and col-1 < len(fills) and fills[col-1]:
c.fill = fills[col-1]
# ── 시트 1: 프로젝트 개요 ─────────────────────────────────
ws1 = wb.create_sheet("프로젝트 개요")
ws1.column_dimensions["A"].width = 22
ws1.column_dimensions["B"].width = 35
ws1["A1"] = f"📋 {proj.project_name}"
ws1["A1"].font = TITLE_FONT
ws1.merge_cells("A1:D1")
kpi_rows = [
("프로젝트 코드", proj.project_code),
("현재 단계", proj.phase),
("전체 진척률", f"{data['overall_progress']}%"),
("건강 상태", proj.health_status),
("계획 기간", f"{proj.planned_start} ~ {proj.planned_end}"),
("PM", proj.pm_user or ""),
("예산 소진율", f"{data['budget_pct']}%"),
("WBS 완료 수", f"{data['wbs_done']} / {data['wbs_total']}"),
("미결 이슈", f"{data['issue_open']}"),
("지연 WBS", f"{data['wbs_delayed']}"),
("보고일", str(today)),
]
for r, (k, v) in enumerate(kpi_rows, 3):
ws1.cell(row=r, column=1, value=k).font = Font(bold=True)
ws1.cell(row=r, column=2, value=v)
# ── 시트 2: WBS 현황 ──────────────────────────────────────
ws2 = wb.create_sheet("WBS 현황")
for col, w in zip("ABCDEFG", [10, 40, 20, 15, 15, 12, 25]):
ws2.column_dimensions[col].width = w
header_row(ws2, ["WBS 코드", "제목", "단계", "예정 시작", "예정 완료", "진척률(%)", "상태"])
for r, item in enumerate(data["wbs_items"], 2):
status = "완료" if item.completion_pct >= 100 else (
"지연" if item.planned_end and item.planned_end < today and item.completion_pct < 100
else "진행중"
)
fill = OK_FILL if status == "완료" else (DANGER_FILL if status == "지연" else None)
fills = [None, fill, None, None, None, fill, fill]
data_row(ws2, [
item.wbs_code, item.title, item.phase or "",
str(item.planned_start or ""), str(item.planned_end or ""),
item.completion_pct, status,
], r, fills)
# ── 시트 3: 이슈 목록 ─────────────────────────────────────
ws3 = wb.create_sheet("이슈 관리")
for col, w in zip("ABCDEFG", [18, 15, 35, 20, 12, 18, 30]):
ws3.column_dimensions[col].width = w
header_row(ws3, ["이슈 ID", "유형", "제목", "담당자", "심각도", "상태", "발생일"])
for r, iss in enumerate(data["issues"], 2):
severity_fill = DANGER_FILL if getattr(iss, "severity", "") in ("CRITICAL", "HIGH") else None
data_row(ws3, [
iss.issue_id, iss.issue_type, iss.title,
iss.assigned_to or "", getattr(iss, "severity", ""),
iss.status, str(iss.raised_date or ""),
], r, [None, None, None, None, severity_fill, None, None])
# ── 시트 4: 산출물 현황 ───────────────────────────────────
ws4 = wb.create_sheet("산출물 현황")
for col, w in zip("ABCDEF", [30, 15, 15, 15, 15, 20]):
ws4.column_dimensions[col].width = w
header_row(ws4, ["산출물명", "유형", "버전", "상태", "제출기한", "제출일"])
for r, dlv in enumerate(data["deliverables"], 2):
status_fill = (
OK_FILL if dlv.status == "APPROVED" else
DANGER_FILL if dlv.status == "REJECTED" else
WARN_FILL if dlv.status == "REVIEWING" else
DANGER_FILL if dlv.due_date and dlv.due_date < today and dlv.status == "PENDING"
else None
)
data_row(ws4, [
dlv.name, dlv.deliverable_type, dlv.version,
dlv.status,
str(dlv.due_date or ""),
str(dlv.submitted_at.date() if dlv.submitted_at else ""),
], r, [None, None, None, status_fill, None, None])
# ── 시트 5: 위험 관리 ─────────────────────────────────────
ws5 = wb.create_sheet("위험 관리")
for col, w in zip("ABCDE", [18, 35, 12, 12, 40]):
ws5.column_dimensions[col].width = w
header_row(ws5, ["위험 ID", "제목", "레벨", "상태", "대응 계획"])
for r, risk in enumerate(data["risks"], 2):
fill = DANGER_FILL if getattr(risk, "risk_level", "") in ("HIGH", "CRITICAL") else WARN_FILL
data_row(ws5, [
risk.risk_id, risk.title,
getattr(risk, "risk_level", ""), risk.status,
risk.mitigation or "",
], r, [None, None, fill, None, None])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
# ── HTML 보고서 ──────────────────────────────────────────────────────────────
def generate_html(data: Dict[str, Any], report_type: str = "weekly") -> str:
"""HTML 보고서 생성 (대시보드 표시 + PDF 변환용)."""
try:
from jinja2 import Template
except ImportError:
raise ValueError("jinja2 미설치: pip install jinja2")
proj = data["project"]
today = data["today"]
template_str = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
body { font-family: 'Noto Sans KR', Arial, sans-serif; margin: 30px; color: #333; font-size: 13px; }
h1 { color: #1e3a5f; border-bottom: 3px solid #1e3a5f; padding-bottom: 8px; }
h2 { color: #2c5282; margin-top: 24px; font-size: 15px; }
.kpi-grid { display: flex; gap: 16px; flex-wrap: wrap; margin: 16px 0; }
.kpi-card { background: #ebf8ff; border-radius: 8px; padding: 12px 18px; min-width: 130px; text-align: center; }
.kpi-card .value { font-size: 24px; font-weight: bold; color: #2b6cb0; }
.kpi-card .label { font-size: 11px; color: #718096; margin-top: 4px; }
table { border-collapse: collapse; width: 100%; margin-bottom: 16px; }
th { background: #2d3748; color: white; padding: 8px; text-align: left; font-size: 12px; }
td { padding: 7px 8px; border-bottom: 1px solid #e2e8f0; }
tr:nth-child(even) { background: #f7fafc; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: bold; }
.ok { background: #c6f6d5; color: #276749; }
.warn { background: #fefcbf; color: #744210; }
.danger{ background: #fed7d7; color: #c53030; }
.meta { color: #718096; font-size: 12px; }
@media print { .no-print { display: none; } }
</style>
</head>
<body>
<h1>{{ report_title }}</h1>
<p class="meta">프로젝트: {{ proj.project_name }} ({{ proj.project_code }}) | 보고일: {{ today }} | 단계: {{ proj.phase }}</p>
<h2>📊 KPI 현황</h2>
<div class="kpi-grid">
<div class="kpi-card"><div class="value">{{ data.overall_progress }}%</div><div class="label">전체 진척률</div></div>
<div class="kpi-card"><div class="value">{{ data.wbs_done }}/{{ data.wbs_total }}</div><div class="label">WBS 완료</div></div>
<div class="kpi-card"><div class="value {{ 'danger' if data.wbs_delayed > 0 else 'ok' }}">{{ data.wbs_delayed }}</div><div class="label">지연 항목</div></div>
<div class="kpi-card"><div class="value {{ 'danger' if data.issue_open > 0 else 'ok' }}">{{ data.issue_open }}</div><div class="label">미결 이슈</div></div>
<div class="kpi-card"><div class="value">{{ data.budget_pct }}%</div><div class="label">예산 소진율</div></div>
<div class="kpi-card"><div class="value {{ 'danger' if proj.health_status == 'RED' else 'warn' if proj.health_status == 'YELLOW' else 'ok' }}">{{ proj.health_status }}</div><div class="label">건강 상태</div></div>
</div>
<h2>📋 WBS 지연 현황 ({{ data.wbs_delayed }})</h2>
{% if data.delayed_wbs %}
<table>
<tr><th>WBS 코드</th><th>제목</th><th>예정 완료</th><th>진척률</th></tr>
{% for item in data.delayed_wbs %}
<tr>
<td>{{ item.wbs_code }}</td>
<td>{{ item.title }}</td>
<td><span class="badge danger">{{ item.planned_end }}</span></td>
<td>{{ item.completion_pct }}%</td>
</tr>
{% endfor %}
</table>
{% else %}<p style="color:#48bb78"> 지연 WBS 없음</p>{% endif %}
<h2>🔴 미결 이슈 ({{ data.issue_open }})</h2>
{% if data.open_issues %}
<table>
<tr><th>이슈 ID</th><th>제목</th><th>유형</th><th>담당자</th><th>발생일</th></tr>
{% for iss in data.open_issues[:10] %}
<tr>
<td>{{ iss.issue_id }}</td>
<td>{{ iss.title }}</td>
<td>{{ iss.issue_type }}</td>
<td>{{ iss.assigned_to or '' }}</td>
<td>{{ iss.raised_date }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p style="color:#48bb78"> 미결 이슈 없음</p>{% endif %}
<h2>📦 산출물 제출 현황</h2>
<table>
<tr><th>산출물명</th><th>유형</th><th>버전</th><th>상태</th><th>제출기한</th></tr>
{% for dlv in data.deliverables %}
<tr>
<td>{{ dlv.name }}</td>
<td>{{ dlv.deliverable_type }}</td>
<td>{{ dlv.version }}</td>
<td><span class="badge {{ 'ok' if dlv.status == 'APPROVED' else 'danger' if dlv.status == 'REJECTED' else 'warn' }}">{{ dlv.status }}</span></td>
<td>{{ dlv.due_date or '' }}</td>
</tr>
{% endfor %}
</table>
{% if data.upcoming_milestones %}
<h2>🏁 다가오는 마일스톤</h2>
<table>
<tr><th>마일스톤</th><th>목표일</th><th>상태</th></tr>
{% for m in data.upcoming_milestones %}
<tr><td>{{ m.name }}</td><td>{{ m.target_date }}</td><td>{{ m.status }}</td></tr>
{% endfor %}
</table>
{% endif %}
{% if data.high_risks %}
<h2> 고위험 요소 ({{ data.high_risks|length }})</h2>
<table>
<tr><th>위험 ID</th><th>제목</th><th>레벨</th><th>대응 계획</th></tr>
{% for r in data.high_risks %}
<tr>
<td>{{ r.risk_id }}</td>
<td>{{ r.title }}</td>
<td><span class="badge danger">{{ r.risk_level }}</span></td>
<td>{{ r.mitigation or '' }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
<p class="meta" style="margin-top:32px;border-top:1px solid #e2e8f0;padding-top:12px">
생성: GUARDiA ITSM | {{ today }}
</p>
</body>
</html>"""
title_map = {
"daily": f"일일 보고서 ({today})",
"weekly": f"주간 보고서 ({today} 기준)",
"monthly": f"월간 보고서 ({today.strftime('%Y년 %m월')})",
}
t = Template(template_str)
return t.render(
proj=proj,
data=data,
today=today,
report_title=title_map.get(report_type, "보고서"),
)
# ── PDF 보고서 ───────────────────────────────────────────────────────────────
def generate_pdf(html_content: str) -> bytes:
"""HTML → PDF 변환."""
try:
import weasyprint
pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
return pdf_bytes
except ImportError:
logger.warning("weasyprint 미설치 — HTML 반환")
return html_content.encode("utf-8")
except Exception as e:
logger.error("PDF 변환 오류: %s", e)
return html_content.encode("utf-8")
# ── DOCX 보고서 ──────────────────────────────────────────────────────────────
def generate_docx(data: Dict[str, Any], report_type: str = "weekly") -> bytes:
"""Word (.docx) 보고서 생성."""
try:
from docx import Document
from docx.shared import Pt, RGBColor, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
raise ValueError("python-docx 미설치: pip install python-docx")
proj = data["project"]
today = data["today"]
doc = Document()
# 제목
rtype_kor = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(report_type, "")
title = doc.add_heading(
f"{proj.project_name}{rtype_kor}보고서",
level=0
)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
doc.add_paragraph(f"보고일: {today} | 단계: {proj.phase} | PM: {proj.pm_user or ''}")
# KPI 테이블
doc.add_heading("KPI 현황", level=1)
kpi_table = doc.add_table(rows=1, cols=4)
kpi_table.style = "Table Grid"
for cell, text in zip(kpi_table.rows[0].cells, ["항목", "계획", "실적", "상태"]):
cell.text = text
kpi_rows = [
("전체 진척률", "", f"{data['overall_progress']}%",
"양호" if data['overall_progress'] >= 80 else "주의"),
("WBS 완료율", f"{data['wbs_total']}",
f"{data['wbs_done']}건 ({round(data['wbs_done']/data['wbs_total']*100 if data['wbs_total'] else 0, 1)}%)",
"양호" if data['wbs_delayed'] == 0 else "지연"),
("예산 소진율", "100%", f"{data['budget_pct']}%",
"양호" if data['budget_pct'] <= 80 else "주의"),
("미결 이슈", "0건", f"{data['issue_open']}",
"양호" if data['issue_open'] == 0 else "주의"),
]
for row_data in kpi_rows:
row = kpi_table.add_row()
for cell, val in zip(row.cells, row_data):
cell.text = val
# 지연 WBS
if data["delayed_wbs"]:
doc.add_heading(f"WBS 지연 현황 ({data['wbs_delayed']}건)", level=1)
t = doc.add_table(rows=1, cols=4)
t.style = "Table Grid"
for c, h in zip(t.rows[0].cells, ["WBS 코드", "제목", "예정 완료", "진척률"]):
c.text = h
for item in data["delayed_wbs"]:
r = t.add_row()
for c, v in zip(r.cells, [item.wbs_code, item.title, str(item.planned_end), f"{item.completion_pct}%"]):
c.text = v
# 이슈
if data["open_issues"]:
doc.add_heading(f"미결 이슈 ({data['issue_open']}건)", level=1)
t = doc.add_table(rows=1, cols=4)
t.style = "Table Grid"
for c, h in zip(t.rows[0].cells, ["이슈 ID", "제목", "담당자", "발생일"]):
c.text = h
for iss in data["open_issues"][:10]:
r = t.add_row()
for c, v in zip(r.cells, [iss.issue_id, iss.title, iss.assigned_to or "", str(iss.raised_date or "")]):
c.text = v
doc.add_paragraph(f"\n생성: GUARDiA ITSM | {today}")
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
# ── PPTX 보고서 ──────────────────────────────────────────────────────────────
def generate_pptx(data: Dict[str, Any], report_type: str = "weekly") -> bytes:
"""PowerPoint (.pptx) 보고서 생성."""
try:
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
except ImportError:
raise ValueError("python-pptx 미설치: pip install python-pptx")
proj = data["project"]
today = data["today"]
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
DARK_BLUE = RGBColor(0x1E, 0x3A, 0x5F)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
def add_slide(layout_idx=5):
layout = prs.slide_layouts[layout_idx]
return prs.slides.add_slide(layout)
def set_bg(slide, color=RGBColor(0xF7, 0xFA, 0xFC)):
from pptx.util import Emu
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = color
def add_text_box(slide, text, left, top, width, height, font_size=14, bold=False, color=None):
txBox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
run = p.add_run()
run.text = text
run.font.size = Pt(font_size)
run.font.bold = bold
if color:
run.font.color.rgb = color
return txBox
# ── 슬라이드 1: 표지 ──────────────────────────────────────
slide1 = add_slide(6) # blank
set_bg(slide1, DARK_BLUE)
add_text_box(slide1, proj.project_name, 1, 2, 11, 1.5, font_size=32, bold=True, color=WHITE)
rtype_name = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(report_type, "")
add_text_box(slide1, f"{rtype_name} 보고서", 1, 3.8, 11, 0.8, font_size=20, color=RGBColor(0xBD, 0xE3, 0xFF))
add_text_box(slide1, f"보고일: {today} | 단계: {proj.phase}", 1, 5, 11, 0.6, font_size=14, color=RGBColor(0xA0, 0xC4, 0xFF))
# ── 슬라이드 2: KPI 요약 ──────────────────────────────────
slide2 = add_slide(6)
set_bg(slide2)
add_text_box(slide2, "KPI 현황", 0.5, 0.3, 12, 0.6, font_size=22, bold=True, color=DARK_BLUE)
kpis = [
("전체 진척률", f"{data['overall_progress']}%"),
("WBS 완료", f"{data['wbs_done']}/{data['wbs_total']}"),
("지연 항목", f"{data['wbs_delayed']}"),
("미결 이슈", f"{data['issue_open']}"),
("예산 소진", f"{data['budget_pct']}%"),
("건강 상태", proj.health_status),
]
for i, (label, value) in enumerate(kpis):
x = 0.5 + (i % 3) * 4.2
y = 1.5 + (i // 3) * 2.2
box = slide2.shapes.add_shape(
1, Inches(x), Inches(y), Inches(3.8), Inches(1.8)
)
box.fill.solid(); box.fill.fore_color.rgb = RGBColor(0xEB, 0xF8, 0xFF)
box.line.color.rgb = RGBColor(0x90, 0xCF, 0xF8)
tf = box.text_frame
tf.word_wrap = True
p1 = tf.paragraphs[0]
p1.alignment = PP_ALIGN.CENTER
r1 = p1.add_run(); r1.text = value
r1.font.size = Pt(28); r1.font.bold = True
r1.font.color.rgb = DARK_BLUE
p2 = tf.add_paragraph()
p2.alignment = PP_ALIGN.CENTER
r2 = p2.add_run(); r2.text = label
r2.font.size = Pt(12); r2.font.color.rgb = RGBColor(0x71, 0x80, 0x96)
# ── 슬라이드 3: WBS 현황 ──────────────────────────────────
if data["delayed_wbs"]:
slide3 = add_slide(6)
set_bg(slide3)
add_text_box(slide3, f"WBS 지연 현황 ({data['wbs_delayed']}건)", 0.5, 0.3, 12, 0.6, 22, True, DARK_BLUE)
from pptx.util import Inches as I
tbl = slide3.shapes.add_table(
min(len(data["delayed_wbs"]) + 1, 8), 4,
I(0.5), I(1.2), I(12), I(4.5)
).table
headers = ["WBS 코드", "제목", "예정 완료", "진척률"]
for col, h in enumerate(headers):
tbl.cell(0, col).text = h
for r, item in enumerate(data["delayed_wbs"][:6], 1):
vals = [item.wbs_code, item.title[:30], str(item.planned_end), f"{item.completion_pct}%"]
for col, v in enumerate(vals):
tbl.cell(r, col).text = v
# ── 슬라이드 4: 이슈 요약 ─────────────────────────────────
if data["open_issues"]:
slide4 = add_slide(6)
set_bg(slide4)
add_text_box(slide4, f"미결 이슈 ({data['issue_open']}건)", 0.5, 0.3, 12, 0.6, 22, True, DARK_BLUE)
from pptx.util import Inches as I
tbl = slide4.shapes.add_table(
min(len(data["open_issues"]) + 1, 8), 4,
I(0.5), I(1.2), I(12), I(4.5)
).table
headers = ["이슈 ID", "제목", "유형", "담당자"]
for col, h in enumerate(headers):
tbl.cell(0, col).text = h
for r, iss in enumerate(data["open_issues"][:6], 1):
vals = [iss.issue_id, iss.title[:30], iss.issue_type, iss.assigned_to or ""]
for col, v in enumerate(vals):
tbl.cell(r, col).text = v
buf = io.BytesIO()
prs.save(buf)
return buf.getvalue()
# ── 보고서 유형 통합 생성 ─────────────────────────────────────────────────────
async def generate_report(
project_id: int,
report_type: str, # daily | weekly | monthly
output_fmt: str, # excel | html | pdf | docx | pptx
db,
) -> tuple[bytes, str, str]:
"""
보고서 생성 통합 함수.
Returns:
(content_bytes, media_type, filename)
"""
data = await collect_project_data(project_id, db)
proj = data["project"]
today = data["today"]
rtype = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(report_type, report_type)
base_name = f"{proj.project_code}_{rtype}보고서_{today}"
if output_fmt == "excel":
content = generate_excel(data, report_type)
mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
filename = f"{base_name}.xlsx"
elif output_fmt == "html":
content = generate_html(data, report_type).encode("utf-8")
mime = "text/html; charset=utf-8"
filename = f"{base_name}.html"
elif output_fmt == "pdf":
html_str = generate_html(data, report_type)
content = generate_pdf(html_str)
mime = "application/pdf"
filename = f"{base_name}.pdf"
elif output_fmt == "docx":
content = generate_docx(data, report_type)
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
filename = f"{base_name}.docx"
elif output_fmt == "pptx":
content = generate_pptx(data, report_type)
mime = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
filename = f"{base_name}.pptx"
else:
raise ValueError(f"지원하지 않는 형식: {output_fmt}")
return content, mime, filename

BIN
itsm/guardia_itsm.db.bak Normal file

Binary file not shown.

View File

@ -40,6 +40,11 @@ from routers import (
learning, learning,
push as push_router, push as push_router,
scouter as scouter_router, scouter as scouter_router,
deliverables,
si_report,
compliance,
jmeter,
public_checklist,
) )
@ -98,7 +103,15 @@ async def lifespan(app: FastAPI):
pass pass
app = FastAPI(title="GUARDiA ITSM", version="1.0.0", lifespan=lifespan) app = FastAPI(title="GUARDiA ITSM", version="2.0.0", lifespan=lifespan)
@app.middleware("http")
async def add_copyright_header(request, call_next):
response = await call_next(request)
response.headers["X-Powered-By"] = "GUARDiA ITSM 2.0"
response.headers["X-Copyright"] = "Copyright 2026 GUARDiA All Rights Reserved"
return response
# ── F-2: Redis 캐시 종료 훅 ────────────────────────────────────────────────── # ── F-2: Redis 캐시 종료 훅 ──────────────────────────────────────────────────
# (lifespan의 yield 이후에 실행 — close_redis는 shutdown시 호출) # (lifespan의 yield 이후에 실행 — close_redis는 shutdown시 호출)
@ -230,6 +243,19 @@ app.include_router(push_router.router)
# Scouter APM # Scouter APM
app.include_router(scouter_router.router) app.include_router(scouter_router.router)
# PMS — 산출물 + 보고서
app.include_router(deliverables.router)
app.include_router(si_report.router)
# 준수성 점검 (시큐어코딩/웹접근성/개인정보보호)
app.include_router(compliance.router)
# 성능 테스트 (JMeter JTL 분석 + 내장 부하 테스트)
app.include_router(jmeter.router)
# 공공기관 필수 기능 체크리스트
app.include_router(public_checklist.router)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@ -2017,6 +2017,8 @@ class SiProject(Base):
cascade="all, delete-orphan") cascade="all, delete-orphan")
phase_checklists = relationship("SiPhaseChecklist", back_populates="project", phase_checklists = relationship("SiPhaseChecklist", back_populates="project",
cascade="all, delete-orphan") cascade="all, delete-orphan")
deliverables = relationship("Deliverable", back_populates="project",
cascade="all, delete-orphan")
class SiPhaseChecklist(Base): class SiPhaseChecklist(Base):
@ -2204,6 +2206,102 @@ class ProjectDeliverable(Base):
milestone = relationship("ProjectMilestone", back_populates="deliverables") milestone = relationship("ProjectMilestone", back_populates="deliverables")
# ── ORM: 산출물 관리 ──────────────────────────────────────────────────────────
class DeliverableStatus(str, Enum):
PENDING = "PENDING" # 미제출
SUBMITTED = "SUBMITTED" # 제출됨
REVIEWING = "REVIEWING" # 검토중
APPROVED = "APPROVED" # 승인
REJECTED = "REJECTED" # 반려
class DeliverableType(str, Enum):
DOCUMENT = "DOCUMENT" # 문서 (분석서, 설계서 등)
CODE = "CODE" # 소스코드
TEST_RESULT = "TEST_RESULT" # 테스트 결과
DESIGN = "DESIGN" # 설계도/UI
REPORT = "REPORT" # 보고서
MANUAL = "MANUAL" # 매뉴얼
OTHER = "OTHER"
class Deliverable(Base):
"""산출물(Deliverable) — WBS 항목·마일스톤별 제출 추적."""
__tablename__ = "tb_deliverable"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("tb_si_project.id"), nullable=False, index=True)
wbs_item_id = Column(Integer, ForeignKey("tb_wbs_item.id"), nullable=True)
milestone_id = Column(Integer, ForeignKey("tb_project_milestone.id"), nullable=True)
# 식별
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
deliverable_type= Column(String(30), default=DeliverableType.DOCUMENT)
# 상태
status = Column(String(20), default=DeliverableStatus.PENDING)
version = Column(String(20), default="1.0")
# 일정
due_date = Column(Date, nullable=True)
submitted_at = Column(DateTime, nullable=True)
submitted_by = Column(String(100), nullable=True)
# 검토
reviewer = Column(String(100), nullable=True)
reviewed_at = Column(DateTime, nullable=True)
review_comment = Column(Text, nullable=True)
# 파일
file_path = Column(String(500), nullable=True)
file_name = Column(String(200), nullable=True)
# 이력
created_by = Column(String(100), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
project = relationship("SiProject", foreign_keys=[project_id])
class DeliverableOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
project_id: int
wbs_item_id: Optional[int] = None
milestone_id: Optional[int] = None
name: str
deliverable_type:str
status: str
version: str
due_date: Optional[date] = None
submitted_at: Optional[datetime] = None
submitted_by: Optional[str] = None
reviewer: Optional[str] = None
reviewed_at: Optional[datetime] = None
review_comment: Optional[str] = None
file_name: Optional[str] = None
created_at: datetime
class DeliverableCreate(BaseModel):
project_id: int
wbs_item_id: Optional[int] = None
milestone_id: Optional[int] = None
name: str
description: Optional[str] = None
deliverable_type:str = DeliverableType.DOCUMENT
version: str = "1.0"
due_date: Optional[date] = None
reviewer: Optional[str] = None
class DeliverableUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
version: Optional[str] = None
due_date: Optional[date] = None
reviewer: Optional[str] = None
review_comment: Optional[str] = None
# ── ORM: 변경 요청 ──────────────────────────────────────────────────────────── # ── ORM: 변경 요청 ────────────────────────────────────────────────────────────
class ChangeRequest(Base): class ChangeRequest(Base):

View File

@ -20,3 +20,9 @@ pywebpush>=2.0.0
alembic>=1.13.0 alembic>=1.13.0
asyncpg>=0.29.0 asyncpg>=0.29.0
psycopg2-binary>=2.9.0 psycopg2-binary>=2.9.0
# SI 보고서 생성 (WBS/산출물/일간/주간/월간)
openpyxl>=3.1.2
python-docx>=1.1.0
python-pptx>=0.6.23
jinja2>=3.1.3
weasyprint>=62.0

View File

@ -451,3 +451,95 @@ async def admin_user_lock_status(
"locked_until": target.locked_until.isoformat() if target.locked_until else None, "locked_until": target.locked_until.isoformat() if target.locked_until else None,
"remaining_minutes": remaining_min, "remaining_minutes": remaining_min,
} }
# ── OAuth2 소셜 로그인 ────────────────────────────────────────────────────────
@router.get("/oauth/providers")
async def oauth_providers():
"""활성화된 OAuth 제공자 목록 반환 (로그인 페이지에서 버튼 표시 여부 결정)."""
from core.oauth import get_enabled_providers
return {"providers": get_enabled_providers()}
@router.get("/oauth/{provider}/start")
async def oauth_start(provider: str):
"""OAuth 인증 흐름 시작 — 제공자 인증 페이지로 리디렉트."""
from core.oauth import build_auth_url, PROVIDERS
from fastapi.responses import RedirectResponse
if provider not in PROVIDERS or not PROVIDERS[provider]["enabled"]:
raise HTTPException(400, f"OAuth 제공자 '{provider}'가 설정되지 않았습니다.")
url = build_auth_url(provider)
return RedirectResponse(url)
@router.get("/oauth/{provider}/callback")
async def oauth_callback(
provider: str,
code: str = "",
state: str = "",
error: str = "",
db: AsyncSession = Depends(get_db),
):
"""
OAuth 콜백 처리 인가 코드로 토큰 교환 GUARDiA 계정과 연결.
- 이메일 기반으로 기존 계정 조회 또는 신규 계정 자동 생성 (auth_type=oauth)
- 성공 /login 페이지로 리디렉트하며 JWT 토큰을 URL 파라미터로 전달
"""
from core.oauth import verify_state, exchange_code, extract_email, extract_name, PROVIDERS
from fastapi.responses import RedirectResponse
LOGIN_FAIL_URL = "/login?error=oauth_failed"
if error:
return RedirectResponse(f"/login?error={error}")
if not verify_state(state):
return RedirectResponse(f"{LOGIN_FAIL_URL}&reason=state_mismatch")
if provider not in PROVIDERS or not PROVIDERS[provider]["enabled"]:
return RedirectResponse(f"{LOGIN_FAIL_URL}&reason=unknown_provider")
userinfo = await exchange_code(provider, code)
if not userinfo:
return RedirectResponse(f"{LOGIN_FAIL_URL}&reason=token_exchange")
email = extract_email(provider, userinfo)
if not email:
return RedirectResponse(f"{LOGIN_FAIL_URL}&reason=no_email")
# 기존 계정 조회 (이메일 기준)
result = await db.execute(select(User).where(User.email == email, User.is_active == True))
user = result.scalars().first()
if not user:
# 자동 계정 생성 (CUSTOMER 역할, 비밀번호 없음)
display = extract_name(provider, userinfo)
username = email.split("@")[0].replace(".", "_")[:30]
# username 중복 처리
existing = (await db.execute(select(User).where(User.username == username))).scalars().first()
if existing:
username = f"{username}_{provider[:3]}"
user = User(
username = username,
display_name = display or username,
email = email,
hashed_pw = "", # OAuth 계정은 비밀번호 없음
role = "CUSTOMER",
is_active = True,
must_change_pw= False,
auth_type = f"oauth:{provider}",
)
db.add(user)
await db.flush()
user.last_login_at = datetime.now()
await db.commit()
token = create_access_token({"sub": user.username, "role": user.role})
# 토큰을 URL 파라미터로 전달 → 로그인 페이지 JS에서 처리
return RedirectResponse(f"/login?oauth_token={token}&username={user.username}&role={user.role}")

229
itsm/routers/compliance.py Normal file
View File

@ -0,0 +1,229 @@
"""
준수성 자동 점검 API (시큐어코딩 / 접근성 / 개인정보보호법)
엔드포인트:
POST /api/compliance/scan 전체 프로젝트 스캔 (ADMIN 전용)
GET /api/compliance/results 최근 스캔 결과 조회
GET /api/compliance/rules 점검 규칙 목록
POST /api/compliance/scan/file 파일 텍스트 단건 점검
GET /api/compliance/report/html HTML 점검 보고서
GET /api/compliance/report/excel Excel 점검 보고서
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse, Response
from pydantic import BaseModel
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/compliance", tags=["compliance"])
# 최근 스캔 결과 캐시 (메모리)
_last_result: dict = {}
class FileScanRequest(BaseModel):
filename: str
content: str
# ── 전체 스캔 ─────────────────────────────────────────────────────────────────
@router.post("/scan")
async def run_scan(
current_user: User = Depends(require_admin_role),
):
"""GUARDiA 소스 전체 준수성 점검 (ADMIN 전용)."""
global _last_result
from core.compliance_check import scan_project
try:
result = await scan_project()
_last_result = result
return {
"message": f"점검 완료 — {result['scanned_files']}개 파일, {result['total_findings']}건 발견",
"risk_level": result["risk_level"],
"total_findings": result["total_findings"],
"by_severity": result["by_severity"],
"summary": result["summary"],
}
except Exception as e:
raise HTTPException(500, f"점검 오류: {str(e)[:200]}")
# ── 결과 조회 ─────────────────────────────────────────────────────────────────
@router.get("/results")
async def get_results(
category: Optional[str] = None,
severity: Optional[str] = None,
limit: int = 50,
current_user: User = Depends(get_current_user),
):
"""최근 스캔 결과 조회."""
if not _last_result:
return {"message": "스캔 결과 없음 — POST /api/compliance/scan 실행 필요", "findings": []}
findings = _last_result.get("findings", [])
if category:
findings = [f for f in findings if category.lower() in f["category"].lower()]
if severity:
findings = [f for f in findings if f["severity"].upper() == severity.upper()]
return {
"scan_time": _last_result.get("scan_time"),
"risk_level": _last_result.get("risk_level"),
"total_findings": _last_result.get("total_findings", 0),
"by_severity": _last_result.get("by_severity", {}),
"by_category": _last_result.get("by_category", {}),
"summary": _last_result.get("summary", {}),
"findings": findings[:limit],
}
# ── 규칙 목록 ─────────────────────────────────────────────────────────────────
@router.get("/rules")
async def list_rules(
_u: User = Depends(get_current_user),
):
"""점검 규칙 목록 (패턴 제외)."""
from core.compliance_check import SECURE_CODING_RULES, ACCESSIBILITY_RULES, PIPA_RULES
return {
"secure_coding": [{"id": r["id"], "category": r["category"], "severity": r["severity"], "message": r["message"]} for r in SECURE_CODING_RULES],
"accessibility": [{"id": r["id"], "category": r["category"], "level": r["level"], "message": r["message"]} for r in ACCESSIBILITY_RULES],
"privacy": [{"id": r["id"], "category": r["category"], "severity": r["severity"], "message": r["message"]} for r in PIPA_RULES],
}
# ── 단건 파일 점검 ────────────────────────────────────────────────────────────
@router.post("/scan/file")
async def scan_single_file(
body: FileScanRequest,
_u: User = Depends(get_current_user),
):
"""파일 텍스트 단건 점검 (개발 중 빠른 검토용)."""
from core.compliance_check import scan_file
findings = scan_file(body.filename, body.content)
return {
"filename": body.filename,
"findings": findings,
"total": len(findings),
"by_severity": {
sev: sum(1 for f in findings if f["severity"] == sev)
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
if any(f["severity"] == sev for f in findings)
}
}
# ── HTML 보고서 ──────────────────────────────────────────────────────────────
@router.get("/report/html", response_class=HTMLResponse)
async def compliance_html_report(
_u: User = Depends(get_current_user),
):
"""준수성 점검 HTML 보고서."""
if not _last_result:
return HTMLResponse("<h2>스캔 결과 없음</h2><p>POST /api/compliance/scan 을 먼저 실행하세요.</p>")
findings = _last_result.get("findings", [])
sev_color = {"CRITICAL": "#fed7d7", "HIGH": "#feebc8", "MEDIUM": "#fefcbf", "LOW": "#e6fffa"}
def _row(f):
bg = sev_color.get(f["severity"], "#fff")
return (
f"<tr style='background:{bg}'>"
f"<td>{f['rule_id']}</td><td>{f['category']}</td>"
f"<td><b>{f['severity']}</b></td>"
f"<td>{f['file']}:{f['line']}</td>"
f"<td style='font-size:12px'>{f['message']}</td>"
f"<td><code style='font-size:11px'>{f.get('snippet','')[:60]}</code></td></tr>"
)
rows = "".join(_row(f) for f in findings[:100])
risk = _last_result.get("risk_level", "?")
risk_badge_color = {"CRITICAL": "#c53030", "HIGH": "#c05621", "MEDIUM": "#744210", "LOW": "#276749"}.get(risk, "#666")
html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8">
<title>GUARDiA 준수성 점검 보고서</title>
<style>
body{{font-family:Arial,sans-serif;margin:30px;color:#333;font-size:13px}}
h1{{color:#1a365d}} h2{{color:#2c5282;margin-top:20px}}
table{{border-collapse:collapse;width:100%}} th{{background:#2d3748;color:#fff;padding:8px;text-align:left}}
td{{padding:6px 8px;border-bottom:1px solid #e2e8f0}}
.badge{{padding:3px 10px;border-radius:12px;font-size:11px;font-weight:bold;color:white;background:{risk_badge_color}}}
</style></head><body>
<h1>GUARDiA 준수성 점검 보고서</h1>
<p>점검일시: {_last_result.get('scan_time','')} | 스캔 파일: {_last_result.get('scanned_files',0)}</p>
<p>종합 위험도: <span class="badge">{risk}</span>
| 발견: {_last_result.get('total_findings',0)}
| 시큐어코딩: {_last_result.get('summary',{}).get('secure_coding',0)}
| 웹접근성: {_last_result.get('summary',{}).get('accessibility',0)}
| 개인정보: {_last_result.get('summary',{}).get('privacy',0)}
</p>
<h2>점검 결과 (상위 100)</h2>
<table>
<tr><th>규칙ID</th><th>분류</th><th>심각도</th><th>위치</th><th>내용</th><th>코드</th></tr>
{rows}
</table>
<p style="margin-top:24px;color:#718096;font-size:11px">
Copyright &copy; 2026 GUARDiA All Rights Reserved. | 보고서는 자동 생성되었습니다.
</p></body></html>"""
return HTMLResponse(html)
# ── Excel 보고서 ─────────────────────────────────────────────────────────────
@router.get("/report/excel")
async def compliance_excel_report(
_u: User = Depends(get_current_user),
):
"""준수성 점검 Excel 보고서."""
if not _last_result:
raise HTTPException(404, "스캔 결과 없음 — POST /api/compliance/scan 먼저 실행")
try:
import io, openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
except ImportError:
raise HTTPException(500, "openpyxl 미설치")
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "준수성 점검 결과"
headers = ["규칙 ID", "분류", "심각도", "파일", "라인", "내용", "코드 스니펫"]
for col, h in enumerate(headers, 1):
c = ws.cell(row=1, column=col, value=h)
c.font = Font(bold=True, color="FFFFFF")
c.fill = PatternFill("solid", fgColor="1a365d")
sev_colors = {"CRITICAL": "FED7D7", "HIGH": "FEEBC8", "MEDIUM": "FEFCBF", "LOW": "E6FFFA"}
for row, f in enumerate(_last_result.get("findings", []), 2):
color = sev_colors.get(f["severity"], "FFFFFF")
vals = [f["rule_id"], f["category"], f["severity"], f["file"], f["line"], f["message"], f.get("snippet", "")]
for col, val in enumerate(vals, 1):
c = ws.cell(row=row, column=col, value=val)
c.fill = PatternFill("solid", fgColor=color)
for col_idx, width in enumerate([10, 18, 12, 40, 7, 50, 40], 1):
ws.column_dimensions[ws.cell(1, col_idx).column_letter].width = width
buf = io.BytesIO()
wb.save(buf)
today = datetime.utcnow().strftime("%Y%m%d")
return Response(
content=buf.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="GUARDiA_compliance_{today}.xlsx"'},
)

View File

@ -0,0 +1,263 @@
"""
SI 프로젝트 산출물(Deliverable) 관리 API
엔드포인트:
GET /api/si/projects/{pid}/deliverables 산출물 목록
POST /api/si/projects/{pid}/deliverables 산출물 등록
GET /api/si/projects/{pid}/deliverables/{id} 산출물 상세
PATCH /api/si/projects/{pid}/deliverables/{id} 산출물 수정
DELETE /api/si/projects/{pid}/deliverables/{id} 산출물 삭제
POST /api/si/projects/{pid}/deliverables/{id}/submit 제출 처리
POST /api/si/projects/{pid}/deliverables/{id}/review 검토 결과 등록
GET /api/si/projects/{pid}/deliverables/summary 제출 현황 요약
"""
from __future__ import annotations
import logging
from datetime import datetime, date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
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 (
Deliverable, DeliverableCreate, DeliverableOut, DeliverableUpdate,
DeliverableStatus, SiProject, User, UserRole,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/si/projects", tags=["deliverables"])
class ReviewRequest(BaseModel):
result: str # APPROVED | REJECTED
comment: Optional[str] = None
async def _get_project(db: AsyncSession, pid: int) -> SiProject:
proj = await db.get(SiProject, pid)
if not proj:
raise HTTPException(404, f"프로젝트 ID {pid}를 찾을 수 없습니다.")
return proj
async def _get_deliverable(db: AsyncSession, pid: int, did: int) -> Deliverable:
d = await db.get(Deliverable, did)
if not d or d.project_id != pid:
raise HTTPException(404, f"산출물 ID {did}를 찾을 수 없습니다.")
return d
# ── 목록 ─────────────────────────────────────────────────────────────────────
@router.get("/{pid}/deliverables", response_model=List[DeliverableOut])
async def list_deliverables(
pid: int,
status: Optional[str] = None,
type_: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""프로젝트 산출물 목록 조회."""
await _get_project(db, pid)
q = select(Deliverable).where(Deliverable.project_id == pid)
if status:
q = q.where(Deliverable.status == status)
if type_:
q = q.where(Deliverable.deliverable_type == type_)
q = q.order_by(Deliverable.due_date.asc().nullslast())
rows = (await db.execute(q)).scalars().all()
return rows
# ── 생성 ─────────────────────────────────────────────────────────────────────
@router.post("/{pid}/deliverables", response_model=DeliverableOut, status_code=201)
async def create_deliverable(
pid: int,
body: DeliverableCreate,
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
"""산출물 등록."""
await _get_project(db, pid)
body.project_id = pid
d = Deliverable(
project_id = pid,
wbs_item_id = body.wbs_item_id,
milestone_id = body.milestone_id,
name = body.name,
description = body.description,
deliverable_type= body.deliverable_type,
version = body.version,
due_date = body.due_date,
reviewer = body.reviewer,
created_by = cu.username,
)
db.add(d)
await db.commit()
await db.refresh(d)
return d
# ── 상세 ─────────────────────────────────────────────────────────────────────
@router.get("/{pid}/deliverables/{did}", response_model=DeliverableOut)
async def get_deliverable(
pid: int, did: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
return await _get_deliverable(db, pid, did)
# ── 수정 ─────────────────────────────────────────────────────────────────────
@router.patch("/{pid}/deliverables/{did}", response_model=DeliverableOut)
async def update_deliverable(
pid: int, did: int,
body: DeliverableUpdate,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
d = await _get_deliverable(db, pid, did)
for field, val in body.model_dump(exclude_none=True).items():
setattr(d, field, val)
d.updated_at = datetime.now()
await db.commit()
await db.refresh(d)
return d
# ── 삭제 ─────────────────────────────────────────────────────────────────────
@router.delete("/{pid}/deliverables/{did}", status_code=204)
async def delete_deliverable(
pid: int, did: int,
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
if cu.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "ADMIN 또는 PM만 산출물을 삭제할 수 있습니다.")
d = await _get_deliverable(db, pid, did)
await db.delete(d)
await db.commit()
# ── 제출 처리 ────────────────────────────────────────────────────────────────
@router.post("/{pid}/deliverables/{did}/submit")
async def submit_deliverable(
pid: int,
did: int,
file: Optional[UploadFile] = File(None),
comment: str = Form(""),
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
"""산출물 제출 (파일 첨부 선택)."""
d = await _get_deliverable(db, pid, did)
if d.status in (DeliverableStatus.APPROVED,):
raise HTTPException(400, "이미 승인된 산출물입니다.")
# 파일 저장
if file:
import aiofiles
from pathlib import Path
upload_dir = Path(__file__).parent.parent / "uploads" / "deliverables" / str(pid)
upload_dir.mkdir(parents=True, exist_ok=True)
safe_name = f"{did}_{file.filename}"
out_path = upload_dir / safe_name
async with aiofiles.open(out_path, "wb") as f:
content = await file.read()
await f.write(content)
d.file_path = str(out_path)
d.file_name = file.filename
d.status = DeliverableStatus.SUBMITTED
d.submitted_at = datetime.now()
d.submitted_by = cu.username
d.updated_at = datetime.now()
await db.commit()
return {
"message": f"산출물 '{d.name}' 제출 완료",
"status": d.status,
"submitted_at": d.submitted_at.isoformat(),
}
# ── 검토 결과 등록 ────────────────────────────────────────────────────────────
@router.post("/{pid}/deliverables/{did}/review")
async def review_deliverable(
pid: int,
did: int,
body: ReviewRequest,
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
"""검토 결과 등록 (APPROVED / REJECTED)."""
if cu.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN만 검토 결과를 등록할 수 있습니다.")
d = await _get_deliverable(db, pid, did)
if d.status not in (DeliverableStatus.SUBMITTED, DeliverableStatus.REVIEWING):
raise HTTPException(400, "제출된 산출물에만 검토 결과를 등록할 수 있습니다.")
d.status = body.result # APPROVED or REJECTED
d.reviewer = cu.username
d.reviewed_at = datetime.now()
d.review_comment = body.comment
d.updated_at = datetime.now()
await db.commit()
msg = "승인" if body.result == DeliverableStatus.APPROVED else "반려"
return {"message": f"산출물 '{d.name}' {msg} 처리 완료", "status": d.status}
# ── 제출 현황 요약 ────────────────────────────────────────────────────────────
@router.get("/{pid}/deliverables/summary")
async def deliverable_summary(
pid: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""산출물 제출 현황 요약."""
await _get_project(db, pid)
rows = (await db.execute(
select(Deliverable).where(Deliverable.project_id == pid)
)).scalars().all()
total = len(rows)
by_status= {}
overdue = []
today = date.today()
for d in rows:
by_status[d.status] = by_status.get(d.status, 0) + 1
if d.status == DeliverableStatus.PENDING and d.due_date and d.due_date < today:
overdue.append({
"id": d.id,
"name": d.name,
"due_date": d.due_date.isoformat(),
"days_overdue": (today - d.due_date).days,
})
approved = by_status.get(DeliverableStatus.APPROVED, 0)
submit_rate = round(
(total - by_status.get(DeliverableStatus.PENDING, 0)) / total * 100, 1
) if total else 0.0
return {
"total": total,
"by_status": by_status,
"approved": approved,
"submit_rate": submit_rate,
"approval_rate": round(approved / total * 100, 1) if total else 0.0,
"overdue": overdue,
}

431
itsm/routers/jmeter.py Normal file
View File

@ -0,0 +1,431 @@
"""
JMeter 성능 테스트 연동 API
기능:
1. JMeter JTL 결과 파일 업로드 분석 보고서 생성
2. GUARDiA API 자동 성능 테스트 (내장 Python httpx 기반)
3. 결과 HTML/Excel 보고서 다운로드
엔드포인트:
POST /api/perf/upload/jtl JTL 파일 업로드 분석
POST /api/perf/run GUARDiA API 자동 성능 테스트
GET /api/perf/results 최근 테스트 결과 목록
GET /api/perf/results/{id} 특정 결과 상세
GET /api/perf/results/{id}/html HTML 보고서
GET /api/perf/results/{id}/excel Excel 보고서
"""
from __future__ import annotations
import asyncio
import io
import csv
import logging
import time
import uuid
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, Response
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, UserRole
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/perf", tags=["performance"])
# 결과 인메모리 저장소
_results: dict[str, dict] = {}
class PerfTestRequest(BaseModel):
target_url: str = "http://localhost:8001"
endpoints: list[str] = ["/", "/api/tasks", "/api/dashboard/me"]
users: int = 10 # 동시 사용자
duration: int = 30 # 테스트 시간 (초)
ramp_up: int = 5 # 사용자 증가 시간 (초)
think_time: float = 0.1 # 요청 간 대기 (초)
# ── JTL 파일 분석 ────────────────────────────────────────────────────────────
def _parse_jtl(content: str) -> dict[str, Any]:
"""JMeter JTL (CSV) 파일 파싱 및 통계 계산."""
reader = csv.DictReader(io.StringIO(content))
rows = list(reader)
if not rows:
return {"error": "JTL 파일에 데이터가 없습니다."}
# 필드 이름 정규화
def get_field(row, *names):
for n in names:
if n in row:
return row[n]
return ""
samples = []
for row in rows:
try:
elapsed = int(get_field(row, "elapsed", "Elapsed"))
success = get_field(row, "success", "Success").lower() == "true"
label = get_field(row, "label", "Label", "sampler_label")
rc = get_field(row, "responseCode", "ResponseCode")
ts = int(get_field(row, "timeStamp", "Timestamp") or 0)
samples.append({
"elapsed": elapsed,
"success": success,
"label": label,
"rc": rc,
"ts": ts,
})
except (ValueError, KeyError):
continue
if not samples:
return {"error": "유효한 샘플 데이터 없음"}
elapsed_list = [s["elapsed"] for s in samples]
success_count = sum(1 for s in samples if s["success"])
error_count = len(samples) - success_count
elapsed_sorted = sorted(elapsed_list)
p_idx = lambda p: max(0, int(len(elapsed_sorted) * p / 100) - 1)
# 초당 처리량
ts_list = [s["ts"] for s in samples if s["ts"] > 0]
if ts_list:
duration_ms = max(ts_list) - min(ts_list)
tps = len(samples) / (duration_ms / 1000) if duration_ms > 0 else 0
else:
tps = 0
# 레이블별 통계
by_label: dict = {}
for s in samples:
lbl = s["label"]
if lbl not in by_label:
by_label[lbl] = {"count": 0, "success": 0, "elapsed": []}
by_label[lbl]["count"] += 1
by_label[lbl]["success"] += int(s["success"])
by_label[lbl]["elapsed"].append(s["elapsed"])
label_stats = {}
for lbl, d in by_label.items():
es = sorted(d["elapsed"])
label_stats[lbl] = {
"count": d["count"],
"error_rate": round((d["count"] - d["success"]) / d["count"] * 100, 1),
"avg_ms": round(sum(es) / len(es), 1) if es else 0,
"p90_ms": es[max(0, int(len(es) * 0.9) - 1)] if es else 0,
"p95_ms": es[max(0, int(len(es) * 0.95) - 1)] if es else 0,
"max_ms": max(es) if es else 0,
}
return {
"total_samples": len(samples),
"success_count": success_count,
"error_count": error_count,
"error_rate_pct": round(error_count / len(samples) * 100, 2),
"tps": round(tps, 2),
"avg_response_ms": round(sum(elapsed_list) / len(elapsed_list), 1),
"min_response_ms": min(elapsed_list),
"max_response_ms": max(elapsed_list),
"p50_ms": elapsed_sorted[p_idx(50)],
"p90_ms": elapsed_sorted[p_idx(90)],
"p95_ms": elapsed_sorted[p_idx(95)],
"p99_ms": elapsed_sorted[p_idx(99)],
"by_label": label_stats,
}
@router.post("/upload/jtl")
async def upload_jtl(
file: UploadFile = File(...),
_u: User = Depends(get_current_user),
):
"""JMeter JTL 파일 업로드 및 통계 분석."""
if not file.filename.endswith(".jtl") and not file.filename.endswith(".csv"):
raise HTTPException(400, "JTL 또는 CSV 파일만 지원합니다.")
content = (await file.read()).decode("utf-8", errors="ignore")
stats = _parse_jtl(content)
if "error" in stats:
raise HTTPException(400, stats["error"])
result_id = str(uuid.uuid4())[:8]
_results[result_id] = {
"id": result_id,
"type": "jtl_upload",
"filename": file.filename,
"created_at":datetime.utcnow().isoformat(),
"stats": stats,
}
return {
"result_id": result_id,
"message": f"JTL 분석 완료 — 총 {stats['total_samples']}개 샘플",
"summary": {
"tps": stats["tps"],
"avg_ms": stats["avg_response_ms"],
"p95_ms": stats["p95_ms"],
"error_rate_pct": stats["error_rate_pct"],
},
}
# ── 내장 성능 테스트 ─────────────────────────────────────────────────────────
@router.post("/run")
async def run_performance_test(
body: PerfTestRequest,
cu: User = Depends(get_current_user),
):
"""GUARDiA API 자동 성능 테스트 (httpx 기반, JMeter 불필요)."""
if cu.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "ADMIN/PM/ENGINEER만 성능 테스트를 실행할 수 있습니다.")
import httpx
result_id = str(uuid.uuid4())[:8]
samples: list[dict] = []
async def hit_endpoint(client: httpx.AsyncClient, url: str) -> dict:
t0 = time.monotonic()
try:
r = await client.get(url, timeout=10.0)
elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
return {"url": url, "status": r.status_code, "elapsed_ms": elapsed_ms, "success": r.status_code < 400}
except Exception as e:
elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
return {"url": url, "status": 0, "elapsed_ms": elapsed_ms, "success": False, "error": str(e)[:50]}
# 점진적 부하 증가 (ramp-up)
start_time = time.monotonic()
users_active = 0
ramp_interval = body.ramp_up / max(body.users, 1)
async with httpx.AsyncClient(base_url=body.target_url) as client:
while time.monotonic() - start_time < body.duration:
# 현재 활성 사용자 수 계산
elapsed_total = time.monotonic() - start_time
target_users = min(body.users, int(elapsed_total / ramp_interval) + 1)
tasks = []
for _ in range(target_users):
for ep in body.endpoints:
tasks.append(hit_endpoint(client, ep))
results = await asyncio.gather(*tasks)
samples.extend(results)
await asyncio.sleep(body.think_time)
if time.monotonic() - start_time >= body.duration:
break
# 통계 계산
elapsed_list = [s["elapsed_ms"] for s in samples]
success_count = sum(1 for s in samples if s["success"])
el_sorted = sorted(elapsed_list)
p_idx = lambda p: max(0, int(len(el_sorted) * p / 100) - 1)
duration_sec = time.monotonic() - start_time
stats = {
"total_samples": len(samples),
"success_count": success_count,
"error_count": len(samples) - success_count,
"error_rate_pct": round((len(samples) - success_count) / max(len(samples), 1) * 100, 2),
"tps": round(len(samples) / duration_sec, 2),
"avg_response_ms": round(sum(elapsed_list) / max(len(elapsed_list), 1), 1),
"min_response_ms": min(elapsed_list) if elapsed_list else 0,
"max_response_ms": max(elapsed_list) if elapsed_list else 0,
"p50_ms": el_sorted[p_idx(50)] if el_sorted else 0,
"p90_ms": el_sorted[p_idx(90)] if el_sorted else 0,
"p95_ms": el_sorted[p_idx(95)] if el_sorted else 0,
"p99_ms": el_sorted[p_idx(99)] if el_sorted else 0,
"by_endpoint": {
ep: {
"count": sum(1 for s in samples if s["url"] == ep),
"avg_ms": round(
sum(s["elapsed_ms"] for s in samples if s["url"] == ep) /
max(sum(1 for s in samples if s["url"] == ep), 1), 1
),
"errors": sum(1 for s in samples if s["url"] == ep and not s["success"]),
}
for ep in body.endpoints
},
}
_results[result_id] = {
"id": result_id,
"type": "auto_test",
"target_url": body.target_url,
"users": body.users,
"duration": body.duration,
"created_at": datetime.utcnow().isoformat(),
"stats": stats,
"config": body.model_dump(),
}
return {
"result_id": result_id,
"message": f"성능 테스트 완료 ({body.duration}초, {body.users}명)",
"summary": {
"tps": stats["tps"],
"avg_ms": stats["avg_response_ms"],
"p95_ms": stats["p95_ms"],
"error_rate_pct": stats["error_rate_pct"],
},
}
# ── 결과 목록/상세 ───────────────────────────────────────────────────────────
@router.get("/results")
async def list_results(_u: User = Depends(get_current_user)):
return [
{
"id": r["id"],
"type": r["type"],
"created_at": r["created_at"],
"tps": r["stats"].get("tps", 0),
"avg_ms": r["stats"].get("avg_response_ms", 0),
"error_rate": r["stats"].get("error_rate_pct", 0),
}
for r in sorted(_results.values(), key=lambda x: x["created_at"], reverse=True)
]
@router.get("/results/{rid}")
async def get_result(rid: str, _u: User = Depends(get_current_user)):
r = _results.get(rid)
if not r:
raise HTTPException(404, "결과를 찾을 수 없습니다.")
return r
# ── HTML 보고서 ──────────────────────────────────────────────────────────────
@router.get("/results/{rid}/html", response_class=HTMLResponse)
async def perf_html_report(rid: str, _u: User = Depends(get_current_user)):
r = _results.get(rid)
if not r:
raise HTTPException(404, "결과를 찾을 수 없습니다.")
s = r["stats"]
err_color = "green" if s["error_rate_pct"] < 1 else ("orange" if s["error_rate_pct"] < 5 else "red")
by_ep = s.get("by_endpoint") or s.get("by_label", {})
ep_rows = "".join(
f"<tr><td>{k}</td><td>{v.get('count','-')}</td><td>{v.get('avg_ms','-')} ms</td>"
f"<td>{v.get('errors',v.get('error_rate','-'))}</td></tr>"
for k, v in by_ep.items()
)
html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8">
<title>GUARDiA 성능 테스트 보고서</title>
<style>
body{{font-family:Arial,sans-serif;margin:30px;color:#333;font-size:13px}}
h1{{color:#1a365d}} .kpi{{display:flex;gap:16px;flex-wrap:wrap;margin:16px 0}}
.kcard{{background:#ebf8ff;border-radius:8px;padding:12px 18px;text-align:center;min-width:120px}}
.kcard .v{{font-size:22px;font-weight:bold;color:#2b6cb0}}
.kcard .l{{font-size:11px;color:#718096}}
table{{border-collapse:collapse;width:100%;margin:12px 0}}
th{{background:#2d3748;color:#fff;padding:8px}} td{{padding:7px 8px;border-bottom:1px solid #e2e8f0}}
</style></head><body>
<h1> 성능 테스트 보고서</h1>
<p>생성일시: {r['created_at']} | 유형: {r['type']}</p>
<div class="kpi">
<div class="kcard"><div class="v">{s['tps']}</div><div class="l">TPS</div></div>
<div class="kcard"><div class="v">{s['avg_response_ms']} ms</div><div class="l">평균 응답</div></div>
<div class="kcard"><div class="v">{s['p95_ms']} ms</div><div class="l">P95</div></div>
<div class="kcard"><div class="v">{s['p99_ms']} ms</div><div class="l">P99</div></div>
<div class="kcard"><div class="v">{s['total_samples']}</div><div class="l"> 요청</div></div>
<div class="kcard"><div class="v" style="color:{err_color}">{s['error_rate_pct']}%</div><div class="l">에러율</div></div>
</div>
<h2>응답시간 분포</h2>
<table><tr><th>지표</th><th></th></tr>
<tr><td>최소</td><td>{s['min_response_ms']} ms</td></tr>
<tr><td>평균</td><td>{s['avg_response_ms']} ms</td></tr>
<tr><td>P50</td><td>{s['p50_ms']} ms</td></tr>
<tr><td>P90</td><td>{s['p90_ms']} ms</td></tr>
<tr><td>P95</td><td>{s['p95_ms']} ms</td></tr>
<tr><td>P99</td><td>{s['p99_ms']} ms</td></tr>
<tr><td>최대</td><td>{s['max_response_ms']} ms</td></tr>
</table>
<h2>엔드포인트별 결과</h2>
<table><tr><th>엔드포인트</th><th>요청 </th><th>평균 응답</th><th>오류</th></tr>
{ep_rows}
</table>
<p style="margin-top:24px;color:#718096;font-size:11px">
Copyright &copy; 2026 GUARDiA All Rights Reserved.
</p></body></html>"""
return HTMLResponse(html)
# ── Excel 보고서 ─────────────────────────────────────────────────────────────
@router.get("/results/{rid}/excel")
async def perf_excel_report(rid: str, _u: User = Depends(get_current_user)):
r = _results.get(rid)
if not r:
raise HTTPException(404, "결과를 찾을 수 없습니다.")
try:
import io, openpyxl
from openpyxl.styles import Font, PatternFill
except ImportError:
raise HTTPException(500, "openpyxl 미설치")
s = r["stats"]
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "성능 테스트 결과"
header_fill = PatternFill("solid", fgColor="1a365d")
header_font = Font(bold=True, color="FFFFFF")
# 요약 시트
summary_rows = [
("테스트 유형", r["type"]),
("생성일시", r["created_at"]),
("총 요청 수", s["total_samples"]),
("TPS", s["tps"]),
("평균 응답 (ms)", s["avg_response_ms"]),
("P50 (ms)", s["p50_ms"]),
("P90 (ms)", s["p90_ms"]),
("P95 (ms)", s["p95_ms"]),
("P99 (ms)", s["p99_ms"]),
("최대 응답 (ms)", s["max_response_ms"]),
("에러율 (%)", s["error_rate_pct"]),
("에러 수", s["error_count"]),
]
for row, (k, v) in enumerate(summary_rows, 1):
ws.cell(row=row, column=1, value=k).font = Font(bold=True)
ws.cell(row=row, column=2, value=v)
# 엔드포인트 시트
ws2 = wb.create_sheet("엔드포인트별")
by_ep = s.get("by_endpoint") or s.get("by_label", {})
headers = ["엔드포인트/라벨", "요청 수", "평균 응답 (ms)", "오류"]
for col, h in enumerate(headers, 1):
c = ws2.cell(row=1, column=col, value=h)
c.font = header_font; c.fill = header_fill
for row, (k, v) in enumerate(by_ep.items(), 2):
ws2.cell(row=row, column=1, value=k)
ws2.cell(row=row, column=2, value=v.get("count", "-"))
ws2.cell(row=row, column=3, value=v.get("avg_ms", "-"))
ws2.cell(row=row, column=4, value=v.get("errors", v.get("error_rate", "-")))
buf = io.BytesIO()
wb.save(buf)
today = datetime.utcnow().strftime("%Y%m%d")
return Response(
content=buf.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="GUARDiA_perf_{today}.xlsx"'},
)

View File

@ -0,0 +1,354 @@
"""
공공기관 정보화사업 필수 기능 체크리스트 API
근거 법령/기준:
- 행안부 정보시스템 구축·운영 지침
- 행안부 소프트웨어 개발보안 가이드
- 개인정보보호법 시행령
- 정보보안 관리체계(ISMS-P) 인증기준
- 장애인차별금지법 ( 접근성 의무)
- 전자서명법
엔드포인트:
GET /api/public/checklist 공공기관 필수 기능 체크리스트
POST /api/public/checklist/{id}/check 항목 완료 처리
GET /api/public/status GUARDiA 공공기관 준비 현황
GET /api/public/report/html 준비 현황 HTML 보고서
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
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/public", tags=["public_checklist"])
# 공공기관 필수 기능 체크리스트
PUBLIC_CHECKLIST = [
# ── 보안 ────────────────────────────────────────────────────────────────
{
"id": "SEC-001",
"category": "보안",
"law": "행안부 SW 개발보안 가이드",
"title": "시큐어코딩 적용",
"desc": "OWASP Top 10 및 행안부 47개 보안약점 제거",
"guardia_status": "구현됨",
"api": "POST /api/compliance/scan",
},
{
"id": "SEC-002",
"category": "보안",
"law": "ISMS-P 인증기준",
"title": "취약점 자동 스캔",
"desc": "서버 포트/CVE 취약점 정기 스캔 및 패치 추적",
"guardia_status": "구현됨",
"api": "POST /api/vuln/scan",
},
{
"id": "SEC-003",
"category": "보안",
"law": "개인정보보호법 제29조",
"title": "접근권한 관리 (RBAC)",
"desc": "역할 기반 최소권한 원칙, 퇴직자 계정 즉시 비활성화",
"guardia_status": "구현됨",
"api": "GET /api/auth/users",
},
{
"id": "SEC-004",
"category": "보안",
"law": "개인정보보호법 제25조",
"title": "감사 로그 (불변 로그)",
"desc": "모든 접근 및 변경 이력 해시체인 기록 (최소 1년 보관)",
"guardia_status": "구현됨",
"api": "GET /api/audit",
},
{
"id": "SEC-005",
"category": "보안",
"law": "전자서명법",
"title": "전자서명 / MFA",
"desc": "관리자 계정 다단계 인증 필수",
"guardia_status": "구현됨",
"api": "POST /api/auth/mfa/setup",
},
{
"id": "SEC-006",
"category": "보안",
"law": "ISMS-P",
"title": "특권 접근 관리 (PAM)",
"desc": "root/admin 계정 사용 제어, 세션 녹화, 명령 로깅",
"guardia_status": "구현됨",
"api": "GET /api/pam/sessions",
},
# ── 개인정보보호 ─────────────────────────────────────────────────────────
{
"id": "PI-001",
"category": "개인정보보호",
"law": "개인정보보호법 제30조",
"title": "개인정보처리방침 게시",
"desc": "처리 목적·보유기간·제3자 제공 현황 공개",
"guardia_status": "미구현",
"action": "/static/privacy-policy.html 페이지 생성 필요",
},
{
"id": "PI-002",
"category": "개인정보보호",
"law": "개인정보보호법 제28조",
"title": "개인정보 암호화",
"desc": "비밀번호, 주민번호, 바이오정보 AES-256 이상 암호화",
"guardia_status": "구현됨",
"api": "모델: os_pw_enc 컬럼 AES-256-GCM",
},
{
"id": "PI-003",
"category": "개인정보보호",
"law": "개인정보보호법 제31조",
"title": "개인정보 영향평가 (PIA)",
"desc": "10만명 이상 처리 시스템 PIA 의무 수행",
"guardia_status": "해당없음",
"action": "사용 규모에 따라 PIA 수행 여부 판단 필요",
},
# ── 접근성 ──────────────────────────────────────────────────────────────
{
"id": "ACC-001",
"category": "웹 접근성",
"law": "장애인차별금지법 제21조",
"title": "KWCAG 2.1 준수 (수준 AA)",
"desc": "대체 텍스트, 자막, 색상 대비, 키보드 접근성 등 4개 원칙",
"guardia_status": "부분구현",
"api": "POST /api/compliance/scan/file → WA- 규칙",
},
{
"id": "ACC-002",
"category": "웹 접근성",
"law": "장애인차별금지법",
"title": "접근성 인증 마크",
"desc": "한국웹접근성인증평가원(WA) 인증 취득",
"guardia_status": "미구현",
"action": "접근성 점검 완료 후 WA 인증 신청 필요",
},
# ── 성능/가용성 ──────────────────────────────────────────────────────────
{
"id": "PERF-001",
"category": "성능",
"law": "행안부 정보시스템 구축·운영 지침",
"title": "성능 테스트 수행",
"desc": "오픈 전 목표 부하의 120% 이상 성능 검증",
"guardia_status": "구현됨",
"api": "POST /api/perf/run 또는 POST /api/perf/upload/jtl",
},
{
"id": "PERF-002",
"category": "가용성",
"law": "행안부 운영 지침",
"title": "99.9% 가용성 (연간 8.7시간 이하 다운)",
"desc": "이중화 구성, 자동 장애조치, 헬스체크",
"guardia_status": "부분구현",
"action": "Nginx HA, DB 이중화 설정 필요",
},
# ── 형상관리/배포 ────────────────────────────────────────────────────────
{
"id": "CM-001",
"category": "형상관리",
"law": "행안부 정보화사업 관리지침",
"title": "소스코드 형상관리 (Git)",
"desc": "브랜치 전략, 코드 리뷰, 변경 이력 관리",
"guardia_status": "구현됨",
"api": "Gitea: http://localhost:3000",
},
{
"id": "CM-002",
"category": "CI/CD",
"law": "행안부 SW 품질관리 지침",
"title": "자동 빌드·테스트·배포 파이프라인",
"desc": "Jenkins CI/CD, SonarQube 품질 게이트",
"guardia_status": "구현됨",
"api": "POST /api/vibe/{id}/build",
},
# ── 문서화 ──────────────────────────────────────────────────────────────
{
"id": "DOC-001",
"category": "문서화",
"law": "행안부 정보화사업 관리지침",
"title": "산출물 목록 관리",
"desc": "분석서, 설계서, 시험계획서, 시험결과서, 운영매뉴얼 필수",
"guardia_status": "구현됨",
"api": "GET /api/si/projects/{id}/deliverables",
},
{
"id": "DOC-002",
"category": "문서화",
"law": "행안부 정보화사업 관리지침",
"title": "정기 보고서 제출 (주간/월간)",
"desc": "주간 업무 보고, 월간 실적 보고 PM에게 제출",
"guardia_status": "구현됨",
"api": "GET /api/si/projects/{id}/report/weekly",
},
# ── 운영이관 ────────────────────────────────────────────────────────────
{
"id": "OP-001",
"category": "운영이관",
"law": "행안부 운영 지침",
"title": "CMDB 등록 (자산 관리)",
"desc": "서버·SW·네트워크 자산 CMDB 등록 및 라이프사이클 관리",
"guardia_status": "구현됨",
"api": "GET /api/cmdb/cis",
},
{
"id": "OP-002",
"category": "운영이관",
"law": "행안부 운영 지침",
"title": "On-Call 체계 구축",
"desc": "24/7 장애 대응 담당자 지정, 에스컬레이션 체계",
"guardia_status": "구현됨",
"api": "GET /api/oncall/on-duty",
},
]
# 항목별 완료 상태 저장 (실제 운영 시 DB 테이블로 이전)
_check_status: dict[str, dict] = {}
class CheckUpdateRequest(BaseModel):
completed: bool
note: Optional[str] = None
evidence_url: Optional[str] = None
@router.get("/checklist")
async def get_checklist(
category: Optional[str] = None,
status: Optional[str] = None,
_u: User = Depends(get_current_user),
):
"""공공기관 정보화사업 필수 체크리스트."""
items = []
for item in PUBLIC_CHECKLIST:
if category and item["category"] != category:
continue
check = _check_status.get(item["id"], {})
merged = {**item, "completed": check.get("completed", False),
"note": check.get("note"), "evidence_url": check.get("evidence_url"),
"checked_by": check.get("checked_by"), "checked_at": check.get("checked_at")}
if status == "done" and not merged["completed"]:
continue
if status == "todo" and merged["completed"]:
continue
items.append(merged)
completed = sum(1 for i in items if i["completed"])
return {
"total": len(items),
"completed": completed,
"completion_rate": round(completed / len(items) * 100, 1) if items else 0,
"items": items,
}
@router.post("/checklist/{check_id}/check")
async def update_check(
check_id: str,
body: CheckUpdateRequest,
_u: User = Depends(get_current_user),
):
"""체크리스트 항목 완료 처리."""
item = next((i for i in PUBLIC_CHECKLIST if i["id"] == check_id), None)
if not item:
raise HTTPException(404, f"체크리스트 항목 {check_id}를 찾을 수 없습니다.")
_check_status[check_id] = {
"completed": body.completed,
"note": body.note,
"evidence_url": body.evidence_url,
"checked_by": _u.username,
"checked_at": datetime.utcnow().isoformat(),
}
return {"message": f"{item['title']}{'완료' if body.completed else '미완료'} 처리"}
@router.get("/status")
async def public_status(_u: User = Depends(get_current_user)):
"""GUARDiA 공공기관 준비 현황 요약."""
items = PUBLIC_CHECKLIST
total = len(items)
impl_done = sum(1 for i in items if i.get("guardia_status") in ("구현됨", "해당없음"))
check_done= sum(1 for i in items if _check_status.get(i["id"], {}).get("completed"))
by_cat: dict = {}
for item in items:
cat = item["category"]
if cat not in by_cat:
by_cat[cat] = {"total": 0, "impl": 0, "checked": 0}
by_cat[cat]["total"] += 1
if item.get("guardia_status") in ("구현됨", "해당없음"):
by_cat[cat]["impl"] += 1
if _check_status.get(item["id"], {}).get("completed"):
by_cat[cat]["checked"] += 1
return {
"total_items": total,
"guardia_impl": impl_done,
"verified": check_done,
"impl_rate": round(impl_done / total * 100, 1),
"verify_rate": round(check_done / total * 100, 1),
"by_category": by_cat,
"action_items": [i["id"] + ": " + i.get("action","") for i in items if i.get("action") and not _check_status.get(i["id"],{}).get("completed")],
}
@router.get("/report/html", response_class=HTMLResponse)
async def public_report_html(_u: User = Depends(get_current_user)):
"""공공기관 준비 현황 HTML 보고서."""
rows = ""
for item in PUBLIC_CHECKLIST:
chk = _check_status.get(item["id"], {})
done = chk.get("completed", False)
gstatus = item.get("guardia_status", "")
color = "#c6f6d5" if done else ("#fef9e7" if gstatus == "구현됨" else "#fed7d7")
gstatus_color = "green" if gstatus == "구현됨" else "orange"
check_mark = "✅ 검증완료" if done else "⬜ 미검증"
note_text = item.get("action", "") or item.get("api", "")
rows += (
f"<tr style='background:{color}'>"
f"<td>{item['id']}</td><td>{item['category']}</td>"
f"<td><b>{item['title']}</b><br><small>{item['desc']}</small></td>"
f"<td>{item['law']}</td>"
f"<td style='color:{gstatus_color}'>{gstatus}</td>"
f"<td>{check_mark}</td>"
f"<td><small>{note_text}</small></td>"
"</tr>"
)
status = await public_status(_u)
html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8">
<title>공공기관 필수 기능 체크리스트</title>
<style>
body{{font-family:Arial,sans-serif;margin:30px;font-size:12px}}
h1{{color:#1a365d}} table{{border-collapse:collapse;width:100%}}
th{{background:#2d3748;color:#fff;padding:8px;text-align:left}}
td{{padding:6px 8px;border-bottom:1px solid #e2e8f0;vertical-align:top}}
.bar{{background:#e2e8f0;border-radius:4px;height:12px;overflow:hidden}}
.bar-fill{{background:#4299e1;height:100%;border-radius:4px}}
</style></head><body>
<h1>공공기관 정보화사업 필수 기능 체크리스트</h1>
<p> {status['total_items']} 항목 | GUARDiA 구현율: <b>{status['impl_rate']}%</b> | 검증 완료: <b>{status['verify_rate']}%</b></p>
<div class="bar"><div class="bar-fill" style="width:{status['impl_rate']}%"></div></div>
<br>
<table>
<tr><th>ID</th><th>분류</th><th>항목</th><th>법적 근거</th><th>GUARDiA</th><th>검증</th><th>비고</th></tr>
{rows}
</table>
<p style="margin-top:20px;color:#718096;font-size:11px">
Copyright &copy; 2026 GUARDiA All Rights Reserved.
</p></body></html>"""
return HTMLResponse(html)

View File

@ -200,6 +200,65 @@ async def get_issue_stats(
} }
# ── 이슈 → SR 자동 연결 ───────────────────────────────────────────────────────
@router.post("/{project_id}/issues/{issue_id}/create-sr", status_code=201)
async def create_sr_from_issue(
project_id: int,
issue_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
프로젝트 이슈를 ITSM SR(서비스 요청) 자동 변환.
- 이슈 내용 SR 제목/설명 자동 매핑
- 이슈 우선순위 SR 우선순위 변환
- 생성된 SR ID를 이슈의 note에 기록
"""
from models import SRRequest, SRStatus, SiProject
from uuid import uuid4
from datetime import datetime as dt
issue = await _get_issue_or_404(project_id, issue_id, db)
proj = await db.get(SiProject, project_id)
# 우선순위 매핑
prio_map = {"CRITICAL": "CRITICAL", "HIGH": "HIGH", "MEDIUM": "MEDIUM", "LOW": "LOW"}
priority = prio_map.get(getattr(issue, "priority", "MEDIUM"), "MEDIUM")
sr_id = f"SR-{dt.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
sr = SRRequest(
sr_id = sr_id,
title = f"[{proj.project_code if proj else ''}] {issue.title}",
description = (
f"프로젝트 이슈에서 자동 생성\n"
f"이슈 ID: {issue.issue_id}\n"
f"이슈 유형: {issue.issue_type}\n"
f"담당자: {issue.assigned_to or ''}\n\n"
f"{issue.description or ''}"
),
sr_type = "OTHER",
priority = priority,
requested_by = current_user.username,
assigned_to = issue.assigned_to,
status = SRStatus.RECEIVED,
)
db.add(sr)
# 이슈에 SR 연결 기록
old_note = issue.note or ""
issue.note = f"{old_note}\n[SR 연결] {sr_id} (생성자: {current_user.username})".strip()
issue.updated_at = dt.now()
await db.commit()
return {
"message": f"이슈 '{issue.title}' → SR {sr_id} 자동 생성 완료",
"sr_id": sr_id,
"issue_id": issue.issue_id,
}
# ── 내부 헬퍼 ───────────────────────────────────────────────────────────────── # ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
async def _assert_project(project_id: int, db: AsyncSession) -> None: async def _assert_project(project_id: int, db: AsyncSession) -> None:

189
itsm/routers/si_report.py Normal file
View File

@ -0,0 +1,189 @@
"""
SI 프로젝트 보고서 생성 API
엔드포인트:
GET /api/si/projects/{pid}/report/daily 일일 보고서
GET /api/si/projects/{pid}/report/weekly 주간 보고서
GET /api/si/projects/{pid}/report/monthly 월간 보고서
GET /api/si/projects/{pid}/report/status 현황 요약 (JSON)
POST /api/si/projects/{pid}/report/send 메신저 자동 발송
쿼리 파라미터:
format: excel | html | pdf | docx | pptx (기본: html)
send_messenger: true 발송 파일도 반환
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response, HTMLResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import SiProject, User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/si/projects", tags=["si_report"])
_MIME = {
"excel": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"html": "text/html; charset=utf-8",
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
}
_EXT = {"excel": "xlsx", "html": "html", "pdf": "pdf", "docx": "docx", "pptx": "pptx"}
class SendRequest(BaseModel):
room: str = "ops"
report_type: str = "weekly"
fmt: str = "excel"
async def _build_report(pid: int, report_type: str, fmt: str, db: AsyncSession):
"""보고서 생성 공통 헬퍼."""
from core.si_report import generate_report
try:
content, mime, filename = await generate_report(pid, report_type, fmt, db)
return content, mime, filename
except ValueError as e:
raise HTTPException(400, str(e))
except Exception as e:
logger.error("보고서 생성 오류: %s", e, exc_info=True)
raise HTTPException(500, f"보고서 생성 실패: {str(e)[:200]}")
# ── 일일 보고서 ───────────────────────────────────────────────────────────────
@router.get("/{pid}/report/daily")
async def daily_report(
pid: int,
format: str = Query("html", description="출력 형식: excel|html|pdf|docx|pptx"),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
content, mime, filename = await _build_report(pid, "daily", format, db)
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
if format == "html":
return HTMLResponse(content.decode("utf-8") if isinstance(content, bytes) else content)
return Response(content=content, media_type=mime, headers=headers)
# ── 주간 보고서 ───────────────────────────────────────────────────────────────
@router.get("/{pid}/report/weekly")
async def weekly_report(
pid: int,
format: str = Query("excel"),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
content, mime, filename = await _build_report(pid, "weekly", format, db)
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
if format == "html":
return HTMLResponse(content.decode("utf-8") if isinstance(content, bytes) else content)
return Response(content=content, media_type=mime, headers=headers)
# ── 월간 보고서 ───────────────────────────────────────────────────────────────
@router.get("/{pid}/report/monthly")
async def monthly_report(
pid: int,
format: str = Query("pptx"),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
content, mime, filename = await _build_report(pid, "monthly", format, db)
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
if format == "html":
return HTMLResponse(content.decode("utf-8") if isinstance(content, bytes) else content)
return Response(content=content, media_type=mime, headers=headers)
# ── 현황 요약 (JSON) ──────────────────────────────────────────────────────────
@router.get("/{pid}/report/status")
async def project_status(
pid: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""프로젝트 현황 JSON 요약 (대시보드용)."""
from core.si_report import collect_project_data
data = await collect_project_data(pid, db)
proj = data["project"]
return {
"project_code": proj.project_code,
"project_name": proj.project_name,
"phase": proj.phase,
"health_status": proj.health_status,
"overall_progress": data["overall_progress"],
"budget_pct": data["budget_pct"],
"wbs_total": data["wbs_total"],
"wbs_done": data["wbs_done"],
"wbs_delayed": data["wbs_delayed"],
"issue_open": data["issue_open"],
"issue_closed": data["issue_closed"],
"high_risks": len(data["high_risks"]),
"overdue_deliverables": len(data["overdue_deliverables"]),
"upcoming_milestones": [
{"name": m.name, "target_date": str(m.target_date)}
for m in data["upcoming_milestones"]
],
}
# ── 메신저 자동 발송 ──────────────────────────────────────────────────────────
@router.post("/{pid}/report/send")
async def send_report(
pid: int,
body: SendRequest,
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
"""보고서를 생성하여 메신저 채널로 자동 발송."""
from core.si_report import collect_project_data, generate_excel, generate_html
import os, httpx
data = await collect_project_data(pid, db)
proj = data["project"]
rtype_name = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(body.report_type, body.report_type)
# 텍스트 요약 생성
summary = (
f"[{rtype_name} 보고서] {proj.project_name}\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"단계: {proj.phase} | 건강상태: {proj.health_status}\n"
f"진척률: {data['overall_progress']}% | 예산소진: {data['budget_pct']}%\n"
f"WBS 완료: {data['wbs_done']}/{data['wbs_total']} | 지연: {data['wbs_delayed']}\n"
f"미결 이슈: {data['issue_open']}건 | 고위험: {len(data['high_risks'])}\n"
f"미제출 산출물: {len(data['overdue_deliverables'])}\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"상세: http://localhost:8001/si (SI 프로젝트 관리)"
)
# 메신저 발송
messenger_url = os.getenv("MESSENGER_BASE_URL", "http://localhost:8002")
try:
async with httpx.AsyncClient(timeout=5.0) as client:
await client.post(
f"{messenger_url}/api/webhook/itsm",
json={"room": body.room, "text": summary, "event": "project_report"},
)
except Exception:
pass # Fail-Safe
return {
"message": f"{rtype_name} 보고서 발송 완료",
"room": body.room,
"summary": summary[:200],
}

View File

@ -20,6 +20,32 @@ if ('serviceWorker' in navigator) {
}); });
} }
/*
Nifty 사이드바 계층 메뉴 토글
*/
function toggleNavGroup(header) {
const body = header.nextElementSibling;
const isOpen = body.classList.contains('open');
body.classList.toggle('open', !isOpen);
header.setAttribute('aria-expanded', String(!isOpen));
}
// 현재 URL에 해당하는 메뉴 자동 열기
(function autoOpenNavGroup() {
document.querySelectorAll('.nav-group-body .nav-sub-item').forEach(item => {
const href = item.getAttribute('href') || '';
if (href && location.pathname.startsWith(href.split('?')[0])) {
const body = item.closest('.nav-group-body');
const header = body?.previousElementSibling;
if (body && header) {
body.classList.add('open');
header.setAttribute('aria-expanded', 'true');
item.classList.add('active');
}
}
});
})();
/* /*
테마 관리 (스크립트 최상단 FOUC 방지) 테마 관리 (스크립트 최상단 FOUC 방지)
*/ */

BIN
itsm/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
itsm/static/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA ITSM</title> <title>GUARDiA ITSM — AI 기반 레거시 인프라 자율 운영 플랫폼</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<!-- Chart.js — 대시보드 차트 (CDN 실패 시 로컬 폴백) --> <!-- Chart.js — 대시보드 차트 (CDN 실패 시 로컬 폴백) -->
<script> <script>
@ -38,7 +39,12 @@
<!-- ── Sidebar ───────────────────────────────────── --> <!-- ── Sidebar ───────────────────────────────────── -->
<aside id="sidebar"> <aside id="sidebar">
<div id="sidebar-logo"> <div id="sidebar-logo">
<img src="/static/icons/logo.png" alt="GUARDiA 로고"
style="height:36px;width:auto;object-fit:contain;margin-right:10px"
onerror="this.style.display='none';document.getElementById('logo-fallback').style.display='flex'">
<div id="logo-fallback" style="display:none;align-items:center;gap:8px">
<div class="logo-icon">G</div> <div class="logo-icon">G</div>
</div>
<div> <div>
<div class="logo-title">GUARDiA ITSM</div> <div class="logo-title">GUARDiA ITSM</div>
<div class="logo-sub">인프라 자동화 플랫폼</div> <div class="logo-sub">인프라 자동화 플랫폼</div>
@ -46,24 +52,47 @@
</div> </div>
<nav id="sidebar-nav"> <nav id="sidebar-nav">
<!-- 대시보드 -->
<div class="nav-item active" data-view="dashboard"> <div class="nav-item active" data-view="dashboard">
<span class="nav-icon">📊</span> 대시보드 <span class="nav-icon">📊</span> 대시보드
</div> </div>
<div class="nav-item" data-view="board">
<span class="nav-icon">🗂️</span> 칸반 보드 <!-- SR 관리 -->
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
<span class="nav-icon">🗂️</span><span>SR 관리</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div> </div>
<div class="nav-item" data-view="list"> <div class="nav-group-body" role="group">
<span class="nav-icon">📋</span> SR 목록 <div class="nav-sub-item" data-view="board">칸반 보드</div>
<div class="nav-sub-item" data-view="list">SR 목록</div>
<div class="nav-sub-item" data-view="audit">감사 로그</div>
</div> </div>
<div class="nav-item" data-view="audit">
<span class="nav-icon">🔐</span> 감사 로그 <!-- PMS 프로젝트 관리 -->
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
<span class="nav-icon">🏗️</span><span>PMS 프로젝트</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div> </div>
<div class="nav-item" data-view="cmdb"> <div class="nav-group-body" role="group">
<span class="nav-icon">🖥️</span> CMDB <a class="nav-sub-item" href="/si">프로젝트 목록</a>
<a class="nav-sub-item" href="/si?tab=wbs">WBS 관리</a>
<a class="nav-sub-item" href="/si?tab=deliverables">산출물 관리</a>
<a class="nav-sub-item" href="/si?tab=issues">이슈 관리</a>
<a class="nav-sub-item" href="/si?tab=risks">위험 관리</a>
<a class="nav-sub-item" href="/si?tab=report">보고서 (일/주/월)</a>
</div> </div>
<div class="nav-item" data-view="kb">
<span class="nav-icon">📚</span> 기술 문서 KB <!-- 인프라 -->
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
<span class="nav-icon">🖥️</span><span>인프라</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div> </div>
<div class="nav-group-body" role="group">
<div class="nav-sub-item" data-view="cmdb">CMDB</div>
<div class="nav-sub-item" data-view="kb">기술 문서 KB</div>
<div class="nav-sub-item" data-view="institutions">기관/사이트</div>
</div>
<div class="nav-separator"></div> <div class="nav-separator"></div>
<a class="nav-item nav-link-ext" href="/incidents"> <a class="nav-item nav-link-ext" href="/incidents">
<span class="nav-icon">🚨</span> 장애 관리 <span class="nav-icon">🚨</span> 장애 관리
@ -109,6 +138,12 @@
<div class="status-dot online"></div> <div class="status-dot online"></div>
<span>시스템 정상</span> <span>시스템 정상</span>
</div> </div>
<div style="padding:8px 16px;font-size:10px;color:var(--text-muted);line-height:1.5;border-top:1px solid rgba(255,255,255,.06)">
<img src="/static/icons/logo.png" alt="" style="height:18px;vertical-align:middle;margin-right:4px;opacity:.6"
onerror="this.style.display='none'">
Copyright &copy; 2026 GUARDiA<br>
All Rights Reserved.
</div>
<!-- 사용자 정보 + 로그아웃 --> <!-- 사용자 정보 + 로그아웃 -->
<div id="sidebar-user"> <div id="sidebar-user">

View File

@ -5,33 +5,93 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA ITSM — 로그인</title> <title>GUARDiA ITSM — 로그인</title>
<link rel="stylesheet" href="/static/login.css"> <link rel="stylesheet" href="/static/login.css">
<style>
/* ── 소셜 로그인 추가 스타일 ── */
.social-divider {
display: flex; align-items: center; gap: 10px;
margin: 18px 0 14px;
}
.social-divider hr { flex: 1; border: none; border-top: 1px solid rgba(255,255,255,.12); }
.social-divider span { font-size: 11px; color: var(--text-muted, #94a3b8); white-space: nowrap; }
.social-btns { display: flex; flex-direction: column; gap: 9px; }
.social-btns.hidden { display: none; }
.btn-social {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-radius: 8px;
border: 1px solid rgba(255,255,255,.15);
background: rgba(255,255,255,.06);
color: #e2e8f0; font-size: 13px; font-weight: 500;
cursor: pointer; text-decoration: none;
transition: background .18s, border-color .18s;
}
.btn-social:hover { background: rgba(255,255,255,.13); border-color: rgba(255,255,255,.28); }
.social-icon {
width: 20px; height: 20px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
border-radius: 4px; font-size: 13px;
}
.icon-google { background: #fff; }
.icon-github { background: #24292e; color:#fff; }
.icon-kakao { background: #fee500; }
.icon-naver { background: #03c75a; color:#fff; }
.icon-sso { background: #4b6cf8; color:#fff; font-size:10px; }
/* ── 탭 (ID/PW ↔ 소셜) ── */
.login-tabs { display: flex; gap: 0; margin-bottom: 18px; border-bottom: 1px solid rgba(255,255,255,.12); }
.login-tab {
flex: 1; padding: 9px 0; text-align: center;
font-size: 12px; font-weight: 600; cursor: pointer;
color: var(--text-muted, #94a3b8);
border-bottom: 2px solid transparent;
transition: color .2s, border-color .2s;
}
.login-tab.active { color: #818cf8; border-bottom-color: #818cf8; }
.login-panel { display: none; }
.login-panel.active { display: block; }
/* OAuth 오류 배너 */
#oauth-error {
background: rgba(239,68,68,.18); border: 1px solid rgba(239,68,68,.4);
border-radius: 8px; padding: 10px 14px; font-size: 12px; color: #fca5a5;
margin-bottom: 14px; display: none;
}
</style>
</head> </head>
<body> <body>
<div id="login-bg"> <div id="login-bg">
<div id="login-wrap"> <div id="login-wrap">
<!-- 카드 -->
<div id="login-card"> <div id="login-card">
<!-- 로고 --> <!-- 로고 -->
<div id="login-logo"> <div id="login-logo">
<div class="logo-shield"> <div class="logo-shield"><span>G</span></div>
<span>G</span>
</div>
<div class="logo-text"> <div class="logo-text">
<div class="logo-title">GUARDiA ITSM</div> <div class="logo-title">GUARDiA ITSM</div>
<div class="logo-sub">인프라 자동화 플랫폼</div> <div class="logo-sub">인프라 자동화 플랫폼</div>
</div> </div>
</div> </div>
<!----> <!-- OAuth 오류 배너 -->
<div id="oauth-error"></div>
<!---->
<div class="login-tabs" id="login-tabs">
<div class="login-tab active" data-tab="password" onclick="switchTab('password')">계정 로그인</div>
<div class="login-tab" data-tab="social" onclick="switchTab('social')" id="social-tab-btn">소셜 로그인</div>
</div>
<!-- ── 패널 1: 계정 ID/PW ── -->
<div class="login-panel active" id="panel-password">
<form id="login-form" autocomplete="off"> <form id="login-form" autocomplete="off">
<div class="field-group"> <div class="field-group">
<label for="username">아이디</label> <label for="username">아이디</label>
<input type="text" id="username" name="username" <input type="text" id="username" name="username"
placeholder="아이디를 입력하세요" autocomplete="username" required> placeholder="아이디를 입력하세요" autocomplete="username" required>
</div> </div>
<div class="field-group"> <div class="field-group">
<label for="password">비밀번호</label> <label for="password">비밀번호</label>
<div class="pw-wrap"> <div class="pw-wrap">
@ -49,12 +109,7 @@
</button> </button>
</form> </form>
<!-- 힌트 --> <!-- 테스트 계정 -->
<div class="login-hint">
초기 비밀번호 <code>1111</code> — 최초 로그인 시 변경 필요
</div>
<!-- 테스트 계정 표 -->
<div class="account-table"> <div class="account-table">
<div class="account-title">테스트 계정</div> <div class="account-title">테스트 계정</div>
<div class="account-row"> <div class="account-row">
@ -75,13 +130,127 @@
</div> </div>
<div class="account-note">↑ 클릭하면 자동 입력됩니다</div> <div class="account-note">↑ 클릭하면 자동 입력됩니다</div>
</div> </div>
</div>
<!-- ── 패널 2: 소셜 로그인 ── -->
<div class="login-panel" id="panel-social">
<div id="social-loading" style="text-align:center;padding:20px;color:#94a3b8;font-size:13px">
로그인 방법을 불러오는 중...
</div>
<div class="social-btns hidden" id="social-btns"></div>
<div id="social-none" style="display:none;text-align:center;padding:20px;font-size:12px;color:#94a3b8">
소셜 로그인이 설정되지 않았습니다.<br>
<small>환경변수 GOOGLE_CLIENT_ID 등을 설정하세요.</small>
</div>
<div class="social-divider" style="margin-top:18px">
<hr><span>또는</span><hr>
</div>
<button class="btn-social" onclick="switchTab('password')" style="justify-content:center">
🔑 계정 ID/PW로 로그인
</button>
</div>
<!-- 힌트 -->
<div class="login-hint">
초기 비밀번호 <code>1111</code> — 최초 로그인 시 변경 필요
</div>
</div><!-- /login-card --> </div><!-- /login-card -->
<div id="login-footer">© 2026 GUARDiA — 폐쇄망 인프라 자동화 플랫폼 | on-premise sLLM</div> <div id="login-footer">
<img src="/static/icons/logo.png" alt="" style="height:20px;vertical-align:middle;margin-right:6px;opacity:.7" onerror="this.style.display='none'">
Copyright &copy; 2026 GUARDiA All Rights Reserved. — 온프레미스 인프라 자동화 플랫폼
</div>
</div> </div>
</div> </div>
<script src="/static/login.js"></script> <script src="/static/login.js"></script>
<script>
// ── 탭 전환 ──────────────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.login-tab').forEach(t =>
t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.login-panel').forEach(p =>
p.classList.toggle('active', p.id === `panel-${tab}`));
}
// ── OAuth 오류 처리 ──────────────────────────────────────
(function handleOAuthCallback() {
const params = new URLSearchParams(location.search);
const oauthToken = params.get('oauth_token');
const oauthError = params.get('error');
const reason = params.get('reason');
if (oauthToken) {
// OAuth 성공 → 토큰 저장 후 메인으로
const username = params.get('username') || '';
const role = params.get('role') || 'CUSTOMER';
localStorage.setItem('access_token', oauthToken);
localStorage.setItem('username', username);
localStorage.setItem('role', role);
location.replace('/');
return;
}
if (oauthError || reason) {
const msgs = {
oauth_failed: 'OAuth 인증에 실패했습니다.',
state_mismatch: 'CSRF 검증 실패 — 다시 시도하세요.',
token_exchange: '토큰 교환 중 오류가 발생했습니다.',
no_email: '이메일 정보를 가져올 수 없습니다.',
access_denied: '로그인이 취소되었습니다.',
unknown_provider: '지원하지 않는 로그인 방법입니다.',
};
const msg = msgs[reason] || msgs[oauthError] || `인증 오류: ${reason || oauthError}`;
const el = document.getElementById('oauth-error');
el.textContent = '⚠️ ' + msg;
el.style.display = 'block';
history.replaceState({}, '', '/login');
}
})();
// ── 소셜 제공자 버튼 동적 로드 ──────────────────────────
const PROVIDER_META = {
google: { label: 'Google', bg: '#fff', color: '#333', svg: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>' },
github: { label: 'GitHub', bg: '#24292e', color: '#fff', svg: '<svg width="18" height="18" viewBox="0 0 24 24" fill="white"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>' },
kakao: { label: '카카오', bg: '#fee500', color: '#3c1e1e', svg: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="#3c1e1e" d="M12 3C6.48 3 2 6.48 2 10.8c0 2.75 1.64 5.17 4.12 6.59l-.84 3.07c-.07.26.22.46.44.31l3.71-2.46c.82.15 1.67.23 2.57.23 5.52 0 10-3.48 10-7.74S17.52 3 12 3z"/></svg>' },
naver: { label: '네이버', bg: '#03c75a', color: '#fff', svg: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="white" d="M16.273 12.845L7.376 0H0v24h7.727V11.155L16.624 24H24V0h-7.727z"/></svg>' },
keycloak: { label: 'SSO (Keycloak)', bg: '#4b6cf8', color: '#fff', svg: '🔐' },
};
async function loadSocialProviders() {
try {
const res = await fetch('/api/auth/oauth/providers');
const { providers } = await res.json();
document.getElementById('social-loading').style.display = 'none';
if (!providers || providers.length === 0) {
document.getElementById('social-none').style.display = 'block';
document.getElementById('social-tab-btn').style.display = 'none';
return;
}
const btns = document.getElementById('social-btns');
btns.classList.remove('hidden');
providers.forEach(p => {
const meta = PROVIDER_META[p.icon] || { label: p.name, bg: '#4b6cf8', color: '#fff', svg: '🔗' };
const a = document.createElement('a');
a.href = `/api/auth/oauth/${p.id}/start`;
a.className = 'btn-social';
a.innerHTML = `
<span class="social-icon" style="background:${meta.bg};color:${meta.color}">${meta.svg}</span>
<span>${meta.label}로 로그인</span>`;
btns.appendChild(a);
});
} catch {
document.getElementById('social-loading').textContent = '소셜 로그인 정보를 불러올 수 없습니다.';
}
}
loadSocialProviders();
</script>
</body> </body>
</html> </html>

View File

@ -278,10 +278,128 @@ html, body {
.stat-card.purple .stat-icon { background: rgba(167,139,250,.15); color: var(--purple); } .stat-card.purple .stat-icon { background: rgba(167,139,250,.15); color: var(--purple); }
.stat-card.orange .stat-icon { background: rgba(251,146,60,.15); color: var(--orange); } .stat-card.orange .stat-icon { background: rgba(251,146,60,.15); color: var(--orange); }
/* ─── Dashboard grid ────────────────────────────── */ /* ─── Dashboard grid (legacy) ───────────────────── */
.dashboard-grid { display: grid; grid-template-columns: 1fr 340px; gap: 18px; } .dashboard-grid { display: grid; grid-template-columns: 1fr 340px; gap: 18px; }
@media (max-width: 960px) { .dashboard-grid { grid-template-columns: 1fr; } } @media (max-width: 960px) { .dashboard-grid { grid-template-columns: 1fr; } }
/* ─── Nifty 사이드바 계층 메뉴 ──────────────────── */
.nav-group-header {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px; cursor: pointer;
color: var(--text-muted); font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: .6px;
border-radius: 6px; margin: 1px 6px;
transition: color .15s, background .15s;
}
.nav-group-header:hover { color: var(--text-bright); background: var(--surface-2); }
.nav-group-header .nav-arrow {
margin-left: auto; font-size: 10px; transition: transform .2s;
color: var(--text-muted);
}
.nav-group-header[aria-expanded="true"] .nav-arrow { transform: rotate(180deg); }
.nav-group-body {
display: none; padding: 2px 6px 4px 30px;
}
.nav-group-body.open { display: block; }
.nav-sub-item {
display: block; padding: 7px 12px; font-size: 13px;
color: var(--text-muted); text-decoration: none;
border-radius: 6px; cursor: pointer; transition: all .15s;
}
.nav-sub-item:hover { background: var(--surface-2); color: var(--text-bright); }
.nav-sub-item.active { background: rgba(129,140,248,.15); color: var(--accent); font-weight: 600; }
/* Topbar */
#topbar {
position: sticky; top: 0; z-index: 100;
display: flex; align-items: center; gap: 12px;
padding: 0 20px; height: 54px;
background: var(--topbar-bg, var(--surface));
border-bottom: 1px solid var(--border);
}
#topbar-search input {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: 8px; padding: 6px 12px; font-size: 13px;
color: var(--text-bright); width: 260px; outline: none;
}
#topbar-search input:focus { border-color: var(--accent); }
#topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.topbar-icon {
padding: 6px 10px; border-radius: 8px; cursor: pointer;
color: var(--text-muted); font-size: 16px; position: relative;
background: none; border: none;
transition: background .15s, color .15s;
}
.topbar-icon:hover { background: var(--surface-2); color: var(--text-bright); }
.topbar-icon .badge {
position: absolute; top: 2px; right: 2px;
background: #f87171; color: #fff; font-size: 9px;
border-radius: 50%; width: 14px; height: 14px;
display: flex; align-items: center; justify-content: center;
font-weight: 700;
}
/* Page tabs (서브페이지 탭) */
.page-tabs {
display: flex; gap: 0; border-bottom: 2px solid var(--border);
margin-bottom: 18px; overflow-x: auto; scrollbar-width: none;
}
.page-tabs::-webkit-scrollbar { display: none; }
.page-tab {
padding: 9px 18px; background: none; border: none;
color: var(--text-muted); font-size: 13px; font-weight: 600;
cursor: pointer; border-bottom: 3px solid transparent;
margin-bottom: -2px; white-space: nowrap;
transition: color .15s, border-color .15s;
}
.page-tab:hover { color: var(--accent); }
.page-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ─── Dashboard Tabs ─────────────────────────────── */
.dash-tab-nav {
display: flex; gap: 2px; margin-bottom: 16px;
border-bottom: 2px solid var(--border);
overflow-x: auto; scrollbar-width: none;
}
.dash-tab-nav::-webkit-scrollbar { display: none; }
.dash-tab {
padding: 9px 18px; font-size: 13px; font-weight: 600;
color: var(--text-muted); background: none; border: none;
border-bottom: 3px solid transparent; margin-bottom: -2px;
cursor: pointer; white-space: nowrap;
transition: color .18s, border-color .18s;
}
.dash-tab:hover { color: var(--accent); }
.dash-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.dash-tab-panel { display: none; }
.dash-tab-panel.active { display: block; }
/* ─── Chart grid ─────────────────────────────────── */
.chart-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 960px) { .chart-grid-2 { grid-template-columns: 1fr; } }
.chart-card { min-height: 280px; }
.chart-body { padding: 14px 16px; display: flex; align-items: center; justify-content: center; }
.chart-body canvas { max-height: 240px; width: 100% !important; }
/* ─── Server health tile ─────────────────────────── */
.server-tile {
display: inline-flex; flex-direction: column; align-items: center;
gap: 4px; padding: 10px 12px; border-radius: 8px;
font-size: 11px; font-weight: 600; cursor: default;
min-width: 80px; text-align: center;
}
.server-tile.ok { background: rgba(52,211,153,.18); color: #34d399; }
.server-tile.warn { background: rgba(251,191,36,.18); color: #fbbf24; }
.server-tile.critical{ background: rgba(239,68,68,.18); color: #f87171; }
.server-tile.unknown { background: rgba(148,163,184,.12); color: #94a3b8; }
.server-tile-name { font-size: 10px; color: var(--text-muted); max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Recent SR rows */ /* Recent SR rows */
.recent-row { .recent-row {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;

View File

@ -0,0 +1,290 @@
# GUARDiA ITSM — Nifty 스타일 UI 가이드
> 참조: https://preview.themeon.net/nifty/
> 적용 버전: GUARDiA ITSM 2.0
---
## 1. 레이아웃 구조
```
┌─────────────────────────────────────────────────────┐
#topbar [로고] [검색] [알림] [사용자] [다크/라이트] │
├──────────┬──────────────────────────────────────────┤
│ │ #content-header (빵크럼 + 페이지 제목) │
#sidebar │──────────────────────────────────────────│
│ (250px) │ │
│ 계층형 │ #main-content │
│ 메뉴 │ (페이지별 뷰 컨텐츠) │
│ │ │
└──────────┴──────────────────────────────────────────┘
#footer Copyright © 2026 GUARDiA All Rights Reserved │
└─────────────────────────────────────────────────────┘
```
---
## 2. 사이드바 메뉴 계층 구조
```
GUARDiA ITSM
├── 📊 대시보드
│ ├── 운영 현황 (SR/SLA/인시던트)
│ ├── 인프라 현황 (서버/CMDB)
│ ├── 보안 현황
│ └── AI 인사이트
├── 🗂️ SR 관리 (서비스 요청)
│ ├── SR 목록
│ ├── 칸반 보드
│ ├── 대량 처리
│ └── SLA 위반 현황
├── 🏗️ PMS (프로젝트 관리) ← 신규 서브메뉴
│ ├── 프로젝트 목록
│ ├── WBS 관리
│ ├── 산출물 관리
│ ├── 이슈 관리
│ ├── 위험 관리
│ ├── 변경 요청
│ ├── 마일스톤
│ └── 보고서
│ ├── 일일 보고
│ ├── 주간 보고
│ └── 월간 보고
├── 🖥️ 인프라
│ ├── CMDB
│ ├── 서버 관리
│ └── 기관/사이트
├── 🔐 보안
│ ├── 취약점 스캔
│ ├── PAM (특권 접근)
│ ├── 감사 로그
│ └── 시큐어코딩 점검 ← 신규
├── 🤖 AI 운영
│ ├── 에이전트 현황
│ ├── 이상 탐지
│ ├── 예측 유지보수
│ └── 학습 루프
├── 📡 모니터링
│ ├── Scouter APM
│ ├── SSL 만료
│ └── 성능 테스트 (JMeter) ← 신규
├── ⚙️ 운영
│ ├── 배포 파이프라인
│ ├── 배치 작업
│ ├── On-Call 로테이션
│ └── 작업 타임테이블
├── 📚 지식관리
│ ├── KB 문서
│ └── 챗봇
└── 🔧 시스템 설정
├── 사용자 관리
├── LDAP/SSO
├── 라이선스
└── 공공기관 체크리스트 ← 신규
```
---
## 3. Nifty 스타일 핵심 CSS 변수
```css
/* 다크 테마 (기본) */
:root[data-theme="dark"] {
--sidebar-bg: #1a1f2e;
--sidebar-hover: rgba(255,255,255,.06);
--sidebar-active: #4f8ef7;
--accent: #818cf8;
--accent-hover: #6366f1;
--surface: #1e2333;
--surface-2: #252b3b;
--border: rgba(255,255,255,.08);
--text-bright: #f1f5f9;
--text-muted: #64748b;
--topbar-bg: #141824;
}
/* 라이트 테마 */
:root[data-theme="light"] {
--sidebar-bg: #1e2333; /* 사이드바는 다크 유지 */
--surface: #f8fafc;
--surface-2: #ffffff;
--border: #e2e8f0;
--text-bright: #1e293b;
--text-muted: #64748b;
--topbar-bg: #ffffff;
}
```
---
## 4. 서브메뉴 (아코디언) 구현
```html
<!-- 사이드바 계층 메뉴 패턴 -->
<div class="nav-group">
<div class="nav-group-header" onclick="toggleNav(this)">
<span class="nav-icon">🏗️</span>
<span>PMS 프로젝트 관리</span>
<span class="nav-arrow"></span>
</div>
<div class="nav-group-body">
<a class="nav-sub-item" href="/si">프로젝트 목록</a>
<a class="nav-sub-item" href="/si?tab=wbs">WBS 관리</a>
<a class="nav-sub-item" href="/si?tab=deliverables">산출물 관리</a>
<a class="nav-sub-item" href="/si?tab=issues">이슈 관리</a>
<a class="nav-sub-item" href="/si?tab=report">보고서</a>
</div>
</div>
```
```css
.nav-group-header {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px; cursor: pointer;
color: var(--text-muted); font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: .5px;
}
.nav-group-header:hover { color: var(--text-bright); }
.nav-group-body { display: none; padding: 0 0 4px 36px; }
.nav-group-body.open { display: block; }
.nav-sub-item {
display: block; padding: 7px 12px; font-size: 13px;
color: var(--text-muted); text-decoration: none; border-radius: 6px;
}
.nav-sub-item:hover, .nav-sub-item.active {
background: var(--sidebar-hover); color: var(--text-bright);
}
.nav-arrow { margin-left: auto; transition: transform .2s; }
.nav-group-header.open .nav-arrow { transform: rotate(180deg); }
```
---
## 5. 상단 탑바 구성
```html
<header id="topbar">
<!-- 햄버거 버튼 (모바일) -->
<button id="sidebar-toggle" onclick="toggleSidebar()"></button>
<!-- 글로벌 검색 -->
<div id="topbar-search">
<input type="search" placeholder="SR·서버·KB 통합 검색..." id="global-search">
</div>
<!-- 우측 액션 -->
<div id="topbar-actions">
<!-- 실시간 알림 -->
<div class="topbar-icon" id="notif-bell">
🔔 <span class="badge" id="notif-count">0</span>
</div>
<!-- 라이선스 상태 -->
<div class="topbar-icon" id="license-badge"></div>
<!-- 테마 토글 -->
<button class="topbar-icon" onclick="toggleTheme()">🌙</button>
<!-- 사용자 드롭다운 -->
<div class="topbar-user" id="topbar-user"></div>
</div>
</header>
```
---
## 6. 페이지 탭 (서브페이지 내부 탭)
```html
<!-- PMS 페이지 내부 탭 예시 -->
<div class="page-tabs">
<button class="page-tab active" data-tab="overview">개요</button>
<button class="page-tab" data-tab="wbs">WBS</button>
<button class="page-tab" data-tab="issues">이슈</button>
<button class="page-tab" data-tab="deliverables">산출물</button>
<button class="page-tab" data-tab="risks">위험</button>
<button class="page-tab" data-tab="report">보고서</button>
</div>
<div class="page-tab-content">
<div id="tab-overview" class="tab-panel active">...</div>
<div id="tab-wbs" class="tab-panel">...</div>
...
</div>
```
```css
.page-tabs {
display: flex; gap: 2px; border-bottom: 2px solid var(--border);
margin-bottom: 20px; overflow-x: auto;
}
.page-tab {
padding: 10px 20px; background: none; border: none;
color: var(--text-muted); font-size: 13px; font-weight: 600;
cursor: pointer; border-bottom: 3px solid transparent;
margin-bottom: -2px; white-space: nowrap;
}
.page-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
```
---
## 7. 카드 컴포넌트
```html
<!-- Nifty 스타일 카드 -->
<div class="nf-card">
<div class="nf-card-header">
<h3 class="nf-card-title">📊 프로젝트 현황</h3>
<div class="nf-card-actions">
<button class="btn-sm">새로고침</button>
</div>
</div>
<div class="nf-card-body">
<!-- 내용 -->
</div>
</div>
```
---
## 8. 반응형 브레이크포인트
| 해상도 | 사이드바 | 레이아웃 |
|--------|---------|---------|
| ≥1280px | 250px 고정 | 2열 그리드 가능 |
| 768~1280px | 60px (아이콘만) | 1열 |
| < 768px | 오버레이 (토글) | 모바일 전용 |
---
## 9. 적용 우선순위
1. **즉시 적용**: 탑바 + 사이드바 아코디언 메뉴 (`index.html` + `style.css`)
2. **1주일 내**: PMS 서브탭 페이지 (`si.html` 확장)
3. **2주 내**: 보안/모니터링 전용 페이지 UI 통일
---
## 10. Copyright 표기 위치
- **사이드바 하단**: `Copyright © 2026 GUARDiA All Rights Reserved.`
- **로그인 페이지**: 로고 이미지 + Copyright
- **출력 보고서**: 모든 Excel/PDF/PPTX 하단
- **API 응답 헤더**: `X-Powered-By: GUARDiA ITSM 2.0`
```python
# FastAPI 미들웨어로 응답 헤더 추가
@app.middleware("http")
async def add_copyright_header(request, call_next):
response = await call_next(request)
response.headers["X-Powered-By"] = "GUARDiA ITSM 2.0 | Copyright 2026"
return response
```