guardia-itsm/routers/capacity.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

335 lines
12 KiB
Python

"""
C-4: 용량 관리 대시보드 API 라우터
엔드포인트:
GET /api/capacity/dashboard — 전체 용량 대시보드
POST /api/capacity/plans — 용량 계획 등록
GET /api/capacity/plans — 용량 계획 목록
GET /api/capacity/plans/{id} — 용량 계획 상세
PATCH /api/capacity/plans/{id} — 용량 계획 수정
DELETE /api/capacity/plans/{id} — 용량 계획 삭제
POST /api/capacity/plans/{id}/recalculate — 예측 재계산
GET /api/capacity/alerts — 용량 경보 목록
GET /api/capacity/trends/{source} — 소스별 트렌드
GET /api/capacity/stats — 용량 통계
"""
from __future__ import annotations
import logging
import math
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import select, desc, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
User, UserRole,
CapacityPlan, CapacityPlanOut, CapacityPlanCreate,
CapacityStatus,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/capacity", tags=["capacity"])
def _calc_forecasts(current: float, growth_rate: float, capacity_max: Optional[float]):
"""성장률 기반 N개월 후 예측값 및 확장 필요 시점 계산."""
if not current or not growth_rate:
return None, None, None, None
monthly_rate = growth_rate / 100.0
f3 = current * (1 + monthly_rate) ** 3
f6 = current * (1 + monthly_rate) ** 6
f12 = current * (1 + monthly_rate) ** 12
# 확장 필요 시점 (85% 임계)
expansion_at = None
if capacity_max and capacity_max > 0 and growth_rate > 0:
target = capacity_max * 0.85
if current < target:
try:
months = math.log(target / current) / math.log(1 + monthly_rate)
expansion_at = datetime.utcnow() + timedelta(days=months * 30)
except (ValueError, ZeroDivisionError):
pass
return round(f3, 2), round(f6, 2), round(f12, 2), expansion_at
def _calc_status(current: Optional[float], warn: Optional[float], crit: Optional[float]) -> str:
if current is None:
return CapacityStatus.NORMAL.value
if crit and current >= crit:
return CapacityStatus.OVERLOAD.value if current >= crit * 1.1 else CapacityStatus.CRITICAL.value
if warn and current >= warn:
return CapacityStatus.WARNING.value
return CapacityStatus.NORMAL.value
@router.get("/dashboard")
async def capacity_dashboard(
inst_id: Optional[int] = Query(None),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""전체 용량 관리 대시보드."""
q = select(CapacityPlan)
if inst_id:
q = q.where(CapacityPlan.inst_id == inst_id)
plans = (await db.execute(q)).scalars().all()
by_status: dict = {}
by_metric: dict = {}
alerts = []
for p in plans:
by_status[p.status] = by_status.get(p.status, 0) + 1
by_metric[p.metric_type] = by_metric.get(p.metric_type, 0) + 1
if p.status in (CapacityStatus.CRITICAL.value, CapacityStatus.OVERLOAD.value):
alerts.append({
"id": p.id,
"source": p.source,
"metric_type": p.metric_type,
"status": p.status,
"current": p.current_value,
"threshold": p.threshold_crit,
"expansion_at": p.expansion_needed_at.isoformat() if p.expansion_needed_at else None,
})
return {
"total_plans": len(plans),
"by_status": by_status,
"by_metric": by_metric,
"alert_count": len(alerts),
"alerts": alerts[:10], # 최대 10개
"as_of": datetime.utcnow().isoformat(),
}
@router.post("/plans", response_model=CapacityPlanOut, status_code=201)
async def create_plan(
body: CapacityPlanCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""용량 계획 등록."""
f3, f6, f12, exp_at = _calc_forecasts(
body.current_value, body.growth_rate, body.capacity_max
)
status = _calc_status(body.current_value, body.threshold_warn, body.threshold_crit)
plan = CapacityPlan(
source = body.source,
metric_type = body.metric_type.upper(),
current_value = body.current_value,
capacity_max = body.capacity_max,
threshold_warn = body.threshold_warn,
threshold_crit = body.threshold_crit,
status = status,
growth_rate = body.growth_rate,
forecast_3m = f3,
forecast_6m = f6,
forecast_12m = f12,
expansion_needed_at = exp_at,
note = body.note,
inst_id = body.inst_id,
updated_by = current_user.username,
)
db.add(plan)
await db.commit()
await db.refresh(plan)
return plan
@router.get("/plans", response_model=List[CapacityPlanOut])
async def list_plans(
metric_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
inst_id: Optional[int] = Query(None),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
conditions = []
if metric_type: conditions.append(CapacityPlan.metric_type == metric_type.upper())
if status: conditions.append(CapacityPlan.status == status.upper())
if inst_id: conditions.append(CapacityPlan.inst_id == inst_id)
q = select(CapacityPlan)
if conditions:
q = q.where(and_(*conditions))
q = q.order_by(desc(CapacityPlan.updated_at)).limit(limit)
return (await db.execute(q)).scalars().all()
@router.get("/plans/{plan_id}", response_model=CapacityPlanOut)
async def get_plan(plan_id: int, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user)):
plan = (await db.execute(select(CapacityPlan).where(CapacityPlan.id == plan_id))).scalars().first()
if not plan:
raise HTTPException(404, "용량 계획을 찾을 수 없습니다.")
return plan
@router.patch("/plans/{plan_id}", response_model=CapacityPlanOut)
async def update_plan(
plan_id: int,
body: dict = Body(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""용량 계획 수정."""
plan = (await db.execute(select(CapacityPlan).where(CapacityPlan.id == plan_id))).scalars().first()
if not plan:
raise HTTPException(404, "용량 계획을 찾을 수 없습니다.")
for k, v in body.items():
if hasattr(plan, k) and k not in ("id", "created_at"):
setattr(plan, k, v)
plan.status = _calc_status(plan.current_value, plan.threshold_warn, plan.threshold_crit)
plan.updated_by = current_user.username
await db.commit()
await db.refresh(plan)
return plan
@router.delete("/plans/{plan_id}", status_code=204)
async def delete_plan(plan_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
plan = (await db.execute(select(CapacityPlan).where(CapacityPlan.id == plan_id))).scalars().first()
if not plan:
raise HTTPException(404, "용량 계획을 찾을 수 없습니다.")
await db.delete(plan)
await db.commit()
@router.post("/plans/{plan_id}/recalculate", response_model=CapacityPlanOut)
async def recalculate_plan(
plan_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""메트릭 이력 기반 예측 재계산."""
plan = (await db.execute(select(CapacityPlan).where(CapacityPlan.id == plan_id))).scalars().first()
if not plan:
raise HTTPException(404, "용량 계획을 찾을 수 없습니다.")
# 예측 모듈 호출
try:
from core.predictive import predict_metric_trend, PREDICTION_THRESHOLDS
pred = await predict_metric_trend(db, plan.source, plan.metric_type)
if pred.get("slope_per_hour"):
monthly_rate = pred["slope_per_hour"] * 24 * 30 / (plan.current_value or 1) * 100
plan.growth_rate = round(monthly_rate, 2)
if pred.get("current_value"):
plan.current_value = pred["current_value"]
except Exception:
pass # 예측 실패 시 기존 값 유지
f3, f6, f12, exp_at = _calc_forecasts(plan.current_value, plan.growth_rate, plan.capacity_max)
plan.forecast_3m = f3
plan.forecast_6m = f6
plan.forecast_12m = f12
plan.expansion_needed_at = exp_at
plan.status = _calc_status(plan.current_value, plan.threshold_warn, plan.threshold_crit)
plan.updated_by = current_user.username
await db.commit()
await db.refresh(plan)
return plan
@router.get("/alerts")
async def capacity_alerts(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""CRITICAL/OVERLOAD 상태 용량 경보 목록."""
plans = (await db.execute(
select(CapacityPlan)
.where(CapacityPlan.status.in_([CapacityStatus.CRITICAL.value, CapacityStatus.OVERLOAD.value]))
.order_by(desc(CapacityPlan.updated_at))
)).scalars().all()
return [
{
"id": p.id,
"source": p.source,
"metric_type": p.metric_type,
"status": p.status,
"current_value": p.current_value,
"threshold_crit": p.threshold_crit,
"forecast_3m": p.forecast_3m,
"expansion_at": p.expansion_needed_at.isoformat() if p.expansion_needed_at else None,
}
for p in plans
]
@router.get("/trends/{source}")
async def capacity_trends(
source: str,
metric_type: str = Query("CPU_USAGE"),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""소스별 용량 트렌드 + 예측."""
try:
from core.predictive import predict_metric_trend
pred = await predict_metric_trend(db, source, metric_type.upper())
except Exception as e:
pred = {"error": str(e)}
plan = (await db.execute(
select(CapacityPlan)
.where(and_(CapacityPlan.source == source, CapacityPlan.metric_type == metric_type.upper()))
.order_by(desc(CapacityPlan.updated_at)).limit(1)
)).scalars().first()
return {
"source": source,
"metric_type": metric_type.upper(),
"prediction": pred,
"capacity_plan": {
"current": plan.current_value if plan else None,
"max": plan.capacity_max if plan else None,
"forecast_3m": plan.forecast_3m if plan else None,
"growth_rate": plan.growth_rate if plan else None,
"expansion_at": plan.expansion_needed_at.isoformat() if plan and plan.expansion_needed_at else None,
} if plan else None,
}
@router.get("/stats")
async def capacity_stats(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""용량 관리 통계."""
total = (await db.execute(select(func.count()).select_from(CapacityPlan))).scalar() or 0
by_status: dict = {}
for st, cnt in (await db.execute(
select(CapacityPlan.status, func.count()).group_by(CapacityPlan.status)
)).all():
by_status[st] = cnt
# 확장 임박 (30일 이내)
soon = datetime.utcnow() + timedelta(days=30)
expansion_soon = (await db.execute(
select(func.count()).select_from(CapacityPlan)
.where(and_(CapacityPlan.expansion_needed_at != None, CapacityPlan.expansion_needed_at <= soon))
)).scalar() or 0
return {
"total_plans": total,
"by_status": by_status,
"critical_count": by_status.get("CRITICAL", 0) + by_status.get("OVERLOAD", 0),
"expansion_soon_30d": expansion_soon,
}