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