guardia-itsm/routers/capacity.py
2026-05-30 23:02:43 +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,
}