guardia-itsm/routers/timeline.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

406 lines
16 KiB
Python

"""
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()),
},
}