Files
loom/backend/app/api/v1/dashboard.py
T
2026-04-24 17:46:03 +08:00

271 lines
9.1 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.
"""仪表盘 & 统计报表聚合接口。"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from app.core.deps import DbDep, require_perm
from app.core.exceptions import ok
from app.models import Organization, Schedule, Skill, Task, User
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
def _today_start_utc() -> datetime:
now = datetime.now(timezone.utc)
return now.replace(hour=0, minute=0, second=0, microsecond=0)
@router.get("/summary", dependencies=[Depends(require_perm("dashboard:view"))])
async def summary(db: DbDep) -> dict:
today = _today_start_utc()
total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0
total_skills = (
await db.execute(select(func.count(Skill.id)).where(Skill.status == "ACTIVE"))
).scalar() or 0
total_tasks = (await db.execute(select(func.count(Task.id)))).scalar() or 0
enabled_schedules = (
await db.execute(select(func.count(Schedule.id)).where(Schedule.enabled.is_(True)))
).scalar() or 0
today_total = (await db.execute(
select(func.count(Task.id)).where(Task.created_at >= today)
)).scalar() or 0
today_succeeded = (await db.execute(
select(func.count(Task.id)).where(Task.created_at >= today, Task.status == "SUCCEEDED")
)).scalar() or 0
today_failed = (await db.execute(
select(func.count(Task.id)).where(
Task.created_at >= today, Task.status.in_(["FAILED", "TIMEOUT"])
)
)).scalar() or 0
running = (await db.execute(
select(func.count(Task.id)).where(
Task.status.in_(["PENDING", "QUEUED", "RUNNING"])
)
)).scalar() or 0
# 活跃用户:近 7 天内有创建任务的 distinct 人数
active_start = datetime.now(timezone.utc) - timedelta(days=7)
active_users = (await db.execute(
select(func.count(func.distinct(Task.operator_id)))
.where(Task.created_at >= active_start, Task.operator_id.is_not(None))
)).scalar() or 0
status_rows = (await db.execute(
select(Task.status, func.count(Task.id)).group_by(Task.status)
)).all()
by_status = {s: int(c) for s, c in status_rows}
recent = (await db.execute(select(Task).order_by(Task.id.desc()).limit(8))).scalars().all()
recent_tasks = [
{
"id": t.id,
"task_no": t.task_no,
"name": t.name,
"skill_code": t.skill_code,
"executor_type": t.executor_type,
"product_id": t.product_id,
"operator_name": t.operator_name,
"status": t.status,
"cost_ms": t.cost_ms,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in recent
]
return ok({
"total_users": int(total_users),
"total_skills": int(total_skills),
"total_tasks": int(total_tasks),
"enabled_schedules": int(enabled_schedules),
"today_total": int(today_total),
"today_succeeded": int(today_succeeded),
"today_failed": int(today_failed),
"running": int(running),
"active_users_7d": int(active_users),
"by_status": by_status,
"recent_tasks": recent_tasks,
})
@router.get("/charts", dependencies=[Depends(require_perm("dashboard:view"))])
async def charts(db: DbDep, days: int = Query(7, ge=1, le=30)) -> dict:
"""仪表盘图表:7d 趋势 + 执行器分布 + Top5 Skill + 按部门统计。"""
now = datetime.now(timezone.utc)
start = (now - timedelta(days=days - 1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
# 1) 每天总量 + 成功数
total_rows = (await db.execute(
select(
func.date_trunc("day", Task.created_at).label("day"),
func.count(Task.id),
)
.where(Task.created_at >= start)
.group_by("day")
)).all()
ok_rows = (await db.execute(
select(
func.date_trunc("day", Task.created_at).label("day"),
func.count(Task.id),
)
.where(Task.created_at >= start, Task.status == "SUCCEEDED")
.group_by("day")
)).all()
total_by_day = {d.date().isoformat(): int(c) for d, c in total_rows if d}
ok_by_day = {d.date().isoformat(): int(c) for d, c in ok_rows if d}
trend: list[dict] = []
for i in range(days):
day_dt = start + timedelta(days=i)
day_key = day_dt.date().isoformat()
trend.append({
"day": day_key,
"label": day_dt.strftime("%m/%d"),
"total": total_by_day.get(day_key, 0),
"succeeded": ok_by_day.get(day_key, 0),
})
# 2) 执行器分布
exec_rows = (await db.execute(
select(Task.executor_type, func.count(Task.id)).group_by(Task.executor_type)
)).all()
exec_total = sum(int(c) for _, c in exec_rows) or 1
executor_split = [
{
"key": et,
"label": "龙虾 Agent" if et == "LOBSTER" else "脚本引擎",
"count": int(c),
"percent": round(int(c) / exec_total * 100, 1),
}
for et, c in exec_rows
]
# 3) Top 5 Skill(最近 days 天)
top_skill_rows = (await db.execute(
select(Task.skill_code, func.count(Task.id).label("cnt"))
.where(Task.created_at >= start)
.group_by(Task.skill_code)
.order_by(func.count(Task.id).desc())
.limit(5)
)).all()
top_max = max((int(c) for _, c in top_skill_rows), default=1)
top_skills = [
{
"skill_code": sc,
"count": int(c),
"percent": round(int(c) / top_max * 100, 1),
}
for sc, c in top_skill_rows
]
# 4) 按部门统计(通过 operator → User.dept_id → Organization.name)
op_rows = (await db.execute(
select(Task.operator_id, func.count(Task.id))
.where(Task.operator_id.is_not(None), Task.created_at >= start)
.group_by(Task.operator_id)
)).all()
dept_stats: list[dict] = []
if op_rows:
op_ids = [oid for oid, _ in op_rows]
user_rows = (await db.execute(
select(User.id, User.dept_id).where(User.id.in_(op_ids))
)).all()
user_dept = dict(user_rows)
dept_counts: dict[int | None, int] = {}
for oid, c in op_rows:
did = user_dept.get(oid)
dept_counts[did] = dept_counts.get(did, 0) + int(c)
dept_ids = [d for d in dept_counts if d is not None]
name_map: dict[int, str] = {}
if dept_ids:
rows = (await db.execute(
select(Organization.id, Organization.name).where(Organization.id.in_(dept_ids))
)).all()
name_map = dict(rows)
dept_total = sum(dept_counts.values()) or 1
dept_stats = sorted(
[
{
"dept_id": did,
"dept_name": name_map.get(did) if did else "未分配部门",
"count": c,
"percent": round(c / dept_total * 100, 1),
}
for did, c in dept_counts.items()
],
key=lambda x: x["count"],
reverse=True,
)
return ok({
"days": days,
"trend": trend,
"executor_split": executor_split,
"top_skills": top_skills,
"dept_stats": dept_stats,
})
@router.get("/report", dependencies=[Depends(require_perm("log:report:view"))])
async def report(
db: DbDep,
days: int = Query(7, ge=1, le=90),
) -> dict:
now = datetime.now(timezone.utc)
start = now - timedelta(days=days)
daily_rows = (await db.execute(
select(
func.date_trunc("day", Task.created_at).label("day"),
Task.status,
func.count(Task.id),
)
.where(Task.created_at >= start)
.group_by("day", Task.status)
.order_by("day")
)).all()
daily: dict[str, dict[str, int]] = {}
for day, s, c in daily_rows:
d = day.date().isoformat() if day else "n/a"
daily.setdefault(d, {})[s] = int(c)
top_skills_rows = (await db.execute(
select(Task.skill_code, func.count(Task.id).label("cnt"))
.where(Task.created_at >= start)
.group_by(Task.skill_code)
.order_by(func.count(Task.id).desc())
.limit(5)
)).all()
top_skills = [{"skill_code": s, "count": int(c)} for s, c in top_skills_rows]
avg_cost = (await db.execute(
select(func.avg(Task.cost_ms))
.where(Task.created_at >= start, Task.status == "SUCCEEDED")
)).scalar()
success = (await db.execute(
select(func.count(Task.id)).where(
Task.created_at >= start, Task.status == "SUCCEEDED"
)
)).scalar() or 0
failure = (await db.execute(
select(func.count(Task.id)).where(
Task.created_at >= start, Task.status.in_(["FAILED", "TIMEOUT"])
)
)).scalar() or 0
return ok({
"days": days,
"daily": daily,
"top_skills": top_skills,
"avg_cost_ms": int(avg_cost or 0),
"success_count": int(success),
"failure_count": int(failure),
})