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>
406 lines
16 KiB
Python
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()),
|
|
},
|
|
}
|