271 lines
9.1 KiB
Python
271 lines
9.1 KiB
Python
"""仪表盘 & 统计报表聚合接口。"""
|
||
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),
|
||
})
|