""" GUARDiA ITSM — 운영 이벤트 타임라인 API (Enhancement A-4) 기능: - SR 생성/상태 변경, 배포(VibeSession), 배치 실행(BatchRun), On-Call 당직, 인시던트, SLA 위반 이벤트를 단일 타임라인으로 통합 - 기간/유형/우선순위 필터 - 무한 스크롤 페이지네이션 (cursor 기반) 엔드포인트: GET /api/timeline — 통합 이벤트 타임라인 GET /api/timeline/summary — 기간별 이벤트 수 요약 """ from __future__ import annotations import logging from datetime import date, datetime, timedelta from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, and_, or_ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( User, UserRole, SRRequest, SRStatus, VibeSession, VibeSessionStatus, AuditLog, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/timeline", tags=["timeline"]) # ── 이벤트 타입 정의 ────────────────────────────────────────────────────────── EVENT_TYPES = { "sr_created", "sr_status_changed", "sr_sla_violated", "sr_escalated", "deploy_started", "deploy_completed", "deploy_failed", "batch_started", "batch_completed", "batch_failed", "oncall_assigned", "incident_created", "incident_resolved", } def _require_non_customer(current_user: User) -> None: if current_user.role == UserRole.CUSTOMER: raise HTTPException(403, "권한이 없습니다.") # ── 이벤트 수집 헬퍼 ───────────────────────────────────────────────────────── async def _collect_sr_events(db, start: datetime, end: datetime, filter_types: set) -> List[dict]: """SR 생성 + SLA 위반 + 에스컬레이션 이벤트.""" events = [] if not ({"sr_created", "sr_sla_violated", "sr_escalated"} & filter_types): return events rows = (await db.execute( select(SRRequest).where( and_( SRRequest.created_at >= start, SRRequest.created_at <= end, ) ).order_by(SRRequest.created_at.desc()).limit(500) )).scalars().all() for sr in rows: if "sr_created" in filter_types: events.append({ "id": f"sr_created_{sr.sr_id}", "type": "sr_created", "timestamp": sr.created_at.isoformat(), "title": f"SR 접수: {sr.title}", "detail": f"우선순위: {sr.priority} | 담당: {sr.assigned_to or '미배정'}", "priority": sr.priority, "ref_id": sr.sr_id, "actor": sr.requested_by, "icon": "ticket", "color": "#2563eb", }) if "sr_sla_violated" in filter_types and sr.sla_breached: events.append({ "id": f"sr_sla_violated_{sr.sr_id}", "type": "sr_sla_violated", "timestamp": sr.escalated_at.isoformat() if sr.escalated_at else sr.created_at.isoformat(), "title": f"SLA 위반: {sr.title}", "detail": f"담당: {sr.assigned_to or '미배정'} | 에스컬레이션: {sr.escalated_to or '없음'}", "priority": sr.priority, "ref_id": sr.sr_id, "actor": "SYSTEM", "icon": "alert", "color": "#dc2626", }) if "sr_escalated" in filter_types and sr.escalated_at: events.append({ "id": f"sr_escalated_{sr.sr_id}", "type": "sr_escalated", "timestamp": sr.escalated_at.isoformat(), "title": f"에스컬레이션: {sr.title}", "detail": f"에스컬레이션 대상: {sr.escalated_to}", "priority": sr.priority, "ref_id": sr.sr_id, "actor": "SYSTEM", "icon": "escalate", "color": "#ea580c", }) return events async def _collect_audit_events(db, start: datetime, end: datetime, filter_types: set) -> List[dict]: """감사 로그 기반 SR 상태 변경 이벤트.""" if "sr_status_changed" not in filter_types: return [] rows = (await db.execute( select(AuditLog).where( and_( AuditLog.action == "STATUS_CHANGED", AuditLog.created_at >= start, AuditLog.created_at <= end, ) ).order_by(AuditLog.created_at.desc()).limit(500) )).scalars().all() return [ { "id": f"sr_status_{row.id}", "type": "sr_status_changed", "timestamp": row.created_at.isoformat(), "title": f"SR 상태 변경: {row.sr_id}", "detail": row.detail or "", "priority": None, "ref_id": row.sr_id, "actor": row.actor, "icon": "refresh", "color": "#0891b2", } for row in rows ] async def _collect_deploy_events(db, start: datetime, end: datetime, filter_types: set) -> List[dict]: """배포 세션 이벤트 (시작/완료/실패).""" deploy_types = {"deploy_started", "deploy_completed", "deploy_failed"} if not (deploy_types & filter_types): return [] rows = (await db.execute( select(VibeSession).where( and_( VibeSession.started_at >= start, VibeSession.started_at <= end, VibeSession.status.notin_([VibeSessionStatus.PENDING, VibeSessionStatus.CODING]), ) ).order_by(VibeSession.started_at.desc()).limit(200) )).scalars().all() events = [] for s in rows: if "deploy_started" in filter_types and s.started_at: events.append({ "id": f"deploy_start_{s.id}", "type": "deploy_started", "timestamp": s.started_at.isoformat(), "title": f"배포 시작: 세션 #{s.id}", "detail": f"SR: {s.sr_id or '없음'} | 시작: {s.started_by or '시스템'}", "priority": None, "ref_id": str(s.id), "actor": s.started_by or "system", "icon": "rocket", "color": "#7c3aed", }) if s.status == VibeSessionStatus.COMPLETED and "deploy_completed" in filter_types and s.deployed_at: events.append({ "id": f"deploy_done_{s.id}", "type": "deploy_completed", "timestamp": s.deployed_at.isoformat(), "title": f"배포 완료: 세션 #{s.id}", "detail": (s.deploy_log or "")[:100], "priority": None, "ref_id": str(s.id), "actor": s.started_by or "system", "icon": "check-circle", "color": "#16a34a", }) if s.status == VibeSessionStatus.FAILED and "deploy_failed" in filter_types: ts = s.deployed_at or s.started_at events.append({ "id": f"deploy_fail_{s.id}", "type": "deploy_failed", "timestamp": ts.isoformat() if ts else datetime.now().isoformat(), "title": f"배포 실패: 세션 #{s.id}", "detail": (s.error_msg or "")[:100], "priority": None, "ref_id": str(s.id), "actor": s.started_by or "system", "icon": "x-circle", "color": "#dc2626", }) return events async def _collect_batch_events(db, start: datetime, end: datetime, filter_types: set) -> List[dict]: """배치 실행 이벤트.""" batch_types = {"batch_started", "batch_completed", "batch_failed"} if not (batch_types & filter_types): return [] try: from models import BatchRun, BatchRunResult, BatchJob rows = (await db.execute( select(BatchRun, BatchJob).join( BatchJob, BatchRun.job_id == BatchJob.id, isouter=True ).where( and_( BatchRun.started_at >= start, BatchRun.started_at <= end, ) ).order_by(BatchRun.started_at.desc()).limit(200) )).all() events = [] for run, job in rows: job_name = job.job_name if job else f"배치 #{run.job_id}" if "batch_started" in filter_types: events.append({ "id": f"batch_start_{run.id}", "type": "batch_started", "timestamp": run.started_at.isoformat(), "title": f"배치 시작: {job_name}", "detail": f"Run ID: {run.id}", "priority": None, "ref_id": str(run.id), "actor": "scheduler", "icon": "cpu", "color": "#6366f1", }) if run.result == BatchRunResult.SUCCESS and "batch_completed" in filter_types and run.ended_at: events.append({ "id": f"batch_done_{run.id}", "type": "batch_completed", "timestamp": run.ended_at.isoformat(), "title": f"배치 완료: {job_name}", "detail": f"종료코드: {run.exit_code}", "priority": None, "ref_id": str(run.id), "actor": "scheduler", "icon": "check", "color": "#16a34a", }) if run.result in (BatchRunResult.FAILED, BatchRunResult.TIMEOUT) and "batch_failed" in filter_types and run.ended_at: events.append({ "id": f"batch_fail_{run.id}", "type": "batch_failed", "timestamp": run.ended_at.isoformat(), "title": f"배치 실패: {job_name} ({run.result})", "detail": (run.error_msg or "")[:100], "priority": None, "ref_id": str(run.id), "actor": "scheduler", "icon": "alert-triangle", "color": "#dc2626", }) return events except Exception: return [] # ═══════════════════════════════════════════════════════════════════════════════ # ── 엔드포인트 ──────────────────────────────────────────────────────────────── # ═══════════════════════════════════════════════════════════════════════════════ @router.get("") async def get_timeline( days: int = Query(7, ge=1, le=90, description="조회 기간(일)"), event_types: Optional[str] = Query(None, description="콤마 구분 이벤트 타입 필터"), priority: Optional[str] = Query(None, description="우선순위 필터 (CRITICAL/HIGH/MEDIUM/LOW)"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ 통합 운영 이벤트 타임라인. SR/배포/배치/인시던트/온콜 이벤트를 시간 역순으로 통합 반환. 다양한 소스에서 이벤트를 수집하여 단일 피드로 제공합니다. """ _require_non_customer(current_user) end = datetime.now() start = end - timedelta(days=days) # 필터 타입 파싱 if event_types: filter_types = {t.strip() for t in event_types.split(",") if t.strip() in EVENT_TYPES} else: filter_types = set(EVENT_TYPES) # 전체 # 각 소스에서 이벤트 병렬 수집 import asyncio results = await asyncio.gather( _collect_sr_events(db, start, end, filter_types), _collect_audit_events(db, start, end, filter_types), _collect_deploy_events(db, start, end, filter_types), _collect_batch_events(db, start, end, filter_types), return_exceptions=True, ) all_events = [] for r in results: if isinstance(r, list): all_events.extend(r) elif isinstance(r, Exception): logger.debug("타임라인 이벤트 수집 오류: %s", r) # 우선순위 필터 if priority: all_events = [e for e in all_events if e.get("priority") == priority.upper()] # 시간 역순 정렬 all_events.sort(key=lambda e: e["timestamp"], reverse=True) total = len(all_events) paged = all_events[skip:skip + limit] return { "from": start.isoformat(), "to": end.isoformat(), "total": total, "skip": skip, "limit": limit, "has_more": (skip + limit) < total, "event_types": sorted(filter_types), "events": paged, } @router.get("/summary") async def timeline_summary( days: int = Query(7, ge=1, le=90), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """기간별 이벤트 수 요약 (일별 카운트).""" _require_non_customer(current_user) end = datetime.now() start = end - timedelta(days=days) # 빠른 카운트 집계 (SR만 기준) sr_rows = (await db.execute( select(SRRequest).where( SRRequest.created_at >= start, SRRequest.created_at <= end, ) )).scalars().all() deploy_rows = (await db.execute( select(VibeSession).where( VibeSession.started_at >= start, VibeSession.started_at <= end, ) )).scalars().all() by_day: dict[str, dict] = {} for d_offset in range(days): d = (end - timedelta(days=d_offset)).date() by_day[d.isoformat()] = {"sr": 0, "deploy": 0, "sla_violation": 0} for sr in sr_rows: key = sr.created_at.date().isoformat() if key in by_day: by_day[key]["sr"] += 1 if sr.sla_breached: by_day[key]["sla_violation"] += 1 for s in deploy_rows: key = s.started_at.date().isoformat() if key in by_day: by_day[key]["deploy"] += 1 sorted_days = sorted(by_day.items()) return { "from": start.date().isoformat(), "to": end.date().isoformat(), "data": [ {"date": d, **counts} for d, counts in sorted_days ], "totals": { "sr": sum(v["sr"] for v in by_day.values()), "deploy": sum(v["deploy"] for v in by_day.values()), "sla_violation": sum(v["sla_violation"] for v in by_day.values()), }, }