guardia-itsm/routers/bi_dashboard.py
2026-06-02 06:07:36 +09:00

290 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
BI 대시보드 API — 실시간 KPI 위젯 + 트렌드 데이터
기존 analytics.py / sla.py / report.py를 통합·고도화.
Chart.js / D3.js 프론트엔드용 구조화된 데이터 반환.
엔드포인트:
GET /api/bi/overview — 전체 현황 요약 (메인 대시보드)
GET /api/bi/sr-trend — SR 트렌드 (일별/주별/월별)
GET /api/bi/sla-heatmap — SLA 준수율 히트맵
GET /api/bi/engineer-load — 엔지니어별 워크로드 분포
GET /api/bi/category-pie — SR 카테고리별 분포
GET /api/bi/resolution-funnel — SR 처리 단계별 퍼널
GET /api/bi/mttr-trend — MTTR 트렌드
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func, and_, case, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import SRRequest, SRStatus, User, WorkLog
router = APIRouter(prefix="/api/bi", tags=["BI Dashboard"])
def _date_series(days: int) -> list[str]:
return [(date.today() - timedelta(days=i)).isoformat() for i in range(days - 1, -1, -1)]
@router.get("/overview")
async def bi_overview(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""메인 대시보드 — 핵심 지표 카드 데이터."""
today = date.today()
month_start = today.replace(day=1)
week_start = today - timedelta(days=today.weekday())
async def count_sr(status=None, since=None):
q = select(func.count(SRRequest.id))
filters = []
if status: filters.append(SRRequest.status == status)
if since: filters.append(SRRequest.created_at >= since)
if filters: q = q.where(and_(*filters))
return (await db.execute(q)).scalar() or 0
open_sr = await count_sr(status=SRStatus.OPEN)
inprog_sr = await count_sr(status=SRStatus.IN_PROGRESS)
done_month = await count_sr(status=SRStatus.DONE, since=month_start)
done_week = await count_sr(status=SRStatus.DONE, since=week_start)
# MTTR 이번 달
mttr_result = await db.execute(
select(
func.avg(
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600
)
).where(
SRRequest.status == SRStatus.DONE,
SRRequest.updated_at >= month_start,
)
)
mttr = round(mttr_result.scalar() or 0, 1)
# 전월 대비 증감
prev_month_start = (month_start - timedelta(days=1)).replace(day=1)
done_prev = await count_sr(status=SRStatus.DONE, since=prev_month_start)
done_prev_cnt = done_prev - done_month if done_prev > done_month else done_prev
return {
"cards": [
{"key": "open_sr", "label": "미처리 SR", "value": open_sr, "unit": "", "color": "red"},
{"key": "inprog_sr", "label": "처리 중 SR", "value": inprog_sr, "unit": "", "color": "orange"},
{"key": "done_month", "label": "이번 달 완료", "value": done_month, "unit": "", "color": "green",
"change": done_month - done_prev_cnt, "change_label": "전월 대비"},
{"key": "done_week", "label": "이번 주 완료", "value": done_week, "unit": "", "color": "blue"},
{"key": "mttr", "label": "평균 처리 시간", "value": mttr, "unit": "시간", "color": "purple"},
],
"updated_at": datetime.utcnow().isoformat(),
}
@router.get("/sr-trend")
async def sr_trend(
period: str = Query("daily", pattern="^(daily|weekly|monthly)$"),
days: int = Query(30, ge=7, le=365),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""SR 생성/완료 트렌드 (Chart.js line chart용)."""
today = date.today()
since = today - timedelta(days=days)
# 날짜별 생성 건수
created = await db.execute(
select(
func.date(SRRequest.created_at).label("d"),
func.count(SRRequest.id).label("cnt"),
).where(SRRequest.created_at >= since)
.group_by(func.date(SRRequest.created_at))
.order_by("d")
)
created_map = {str(r.d): r.cnt for r in created.all()}
# 날짜별 완료 건수
resolved = await db.execute(
select(
func.date(SRRequest.updated_at).label("d"),
func.count(SRRequest.id).label("cnt"),
).where(
SRRequest.status == SRStatus.DONE,
SRRequest.updated_at >= since,
).group_by(func.date(SRRequest.updated_at)).order_by("d")
)
resolved_map = {str(r.d): r.cnt for r in resolved.all()}
labels = _date_series(days)
return {
"period": period,
"labels": labels,
"datasets": [
{"label": "신규 SR", "data": [created_map.get(d, 0) for d in labels], "color": "#003366"},
{"label": "완료 SR", "data": [resolved_map.get(d, 0) for d in labels], "color": "#10B981"},
],
}
@router.get("/sla-heatmap")
async def sla_heatmap(
weeks: int = Query(12, ge=4, le=52),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""SLA 준수율 히트맵 (주별 × 카테고리)."""
since = date.today() - timedelta(weeks=weeks)
rows = await db.execute(
select(
func.date_trunc('week', SRRequest.created_at).label("week"),
SRRequest.category.label("cat"),
func.count(SRRequest.id).label("total"),
func.sum(
case(
(func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) <= 14400, 1),
else_=0
)
).label("on_time"),
).where(
SRRequest.status == SRStatus.DONE,
SRRequest.created_at >= since,
).group_by("week", SRRequest.category).order_by("week")
)
data = []
for r in rows.all():
rate = round(r.on_time / r.total * 100, 1) if r.total else 0
data.append({
"week": r.week.date().isoformat() if r.week else None,
"category": r.cat or "기타",
"total": r.total,
"on_time": r.on_time,
"compliance_pct": rate,
})
return {"data": data}
@router.get("/engineer-load")
async def engineer_load(
days: int = Query(30, ge=7, le=90),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""엔지니어별 SR 워크로드 분포 (bar chart용)."""
since = date.today() - timedelta(days=days)
rows = await db.execute(
select(
User.name.label("engineer"),
func.count(SRRequest.id).label("total"),
func.sum(case((SRRequest.status == SRStatus.DONE, 1), else_=0)).label("done"),
func.sum(case((SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]), 1), else_=0)).label("open"),
).join(User, SRRequest.assignee_id == User.id, isouter=True)
.where(SRRequest.created_at >= since)
.group_by(User.name).order_by(desc("total")).limit(20)
)
data = [
{"engineer": r.engineer or "미배정", "total": r.total, "done": r.done, "open": r.open}
for r in rows.all()
]
return {
"period_days": days,
"labels": [d["engineer"] for d in data],
"datasets": [
{"label": "완료", "data": [d["done"] for d in data], "color": "#10B981"},
{"label": "진행중", "data": [d["open"] for d in data], "color": "#F59E0B"},
],
}
@router.get("/category-pie")
async def category_pie(
days: int = Query(30, ge=7, le=365),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""SR 카테고리별 분포 (pie chart용)."""
since = date.today() - timedelta(days=days)
rows = await db.execute(
select(
SRRequest.category.label("cat"),
func.count(SRRequest.id).label("cnt"),
).where(SRRequest.created_at >= since)
.group_by(SRRequest.category).order_by(desc("cnt"))
)
data = [{"category": r.cat or "기타", "count": r.cnt} for r in rows.all()]
total = sum(d["count"] for d in data)
for d in data:
d["pct"] = round(d["count"] / total * 100, 1) if total else 0
return {"period_days": days, "total": total, "data": data}
@router.get("/mttr-trend")
async def mttr_trend(
months: int = Query(6, ge=3, le=24),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""MTTR 월별 트렌드."""
since = date.today() - timedelta(days=months * 30)
rows = await db.execute(
select(
func.date_trunc('month', SRRequest.updated_at).label("month"),
func.avg(
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600
).label("mttr_hours"),
func.count(SRRequest.id).label("cnt"),
).where(
SRRequest.status == SRStatus.DONE,
SRRequest.updated_at >= since,
).group_by("month").order_by("month")
)
data = [
{
"month": r.month.strftime("%Y-%m") if r.month else None,
"mttr_hours": round(r.mttr_hours or 0, 1),
"count": r.cnt,
}
for r in rows.all()
]
return {
"labels": [d["month"] for d in data],
"datasets": [{"label": "MTTR (시간)", "data": [d["mttr_hours"] for d in data], "color": "#6366F1"}],
"raw": data,
}
@router.get("/resolution-funnel")
async def resolution_funnel(
days: int = Query(30, ge=7, le=90),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""SR 처리 단계별 퍼널 (funnel chart용)."""
since = date.today() - timedelta(days=days)
statuses = ["OPEN", "IN_PROGRESS", "PENDING", "RESOLVED", "DONE"]
counts = {}
for st in statuses:
result = await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.created_at >= since,
SRRequest.status == st,
)
)
counts[st] = result.scalar() or 0
total = sum(counts.values())
return {
"period_days": days,
"total_created": total,
"funnel": [
{"stage": st, "count": counts[st],
"pct": round(counts[st] / total * 100, 1) if total else 0}
for st in statuses
],
}