- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
335 lines
12 KiB
Python
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,
|
|
}
|