feat: Profile health score system (scoring engine, API, frontend)
Health scoring engine with 4 dimensions: Gateway stability (40%), config
completeness (25%), skill consistency (20%), and Cron success rate (15%).
Grades A(80-100) B(60-79) C(40-59) D(0-39) with detailed deduction items
and fix suggestions per dimension.
API endpoints:
- GET /health/scores — list all profiles with grade filter + sort
- GET /health/scores/{profile} — detail with deductions + suggestions
- POST /health/compare — multi-profile config/skill comparison
- POST /health/calculate — manual recalculation (owner only)
Frontend HealthPage: grade distribution cards, score table with expandable
detail rows, batch compare view with diff highlighting, grade/select/sort
filters, and owner-only recalculation.
Data sources use placeholders (TODO: hermes CLI / file reads) but the full
scoring logic is implemented end-to-end.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""Health API — 健康分查询、对比、手动刷新。"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.middleware.auth import require_auth, require_role
|
||||
from app.models.user import User
|
||||
from app.services.health_scorer import (
|
||||
PLACEHOLDER_PROFILES,
|
||||
calculate_all_scores,
|
||||
calculate_health_score,
|
||||
invalidate_cache,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
def _score_to_row(hs, rank: int) -> dict:
|
||||
return {
|
||||
"rank": rank,
|
||||
"name": hs.profile_name,
|
||||
"total_score": hs.total_score,
|
||||
"grade": hs.grade,
|
||||
"gateway_score": hs.gateway.score,
|
||||
"config_score": hs.config.score,
|
||||
"skill_score": hs.skill.score,
|
||||
"cron_score": hs.cron.score,
|
||||
"deduct_summary": hs.deduct_summary,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/scores")
|
||||
async def list_scores(
|
||||
grade: str | None = Query(None, pattern="^(A|B|C|D)$"),
|
||||
sort: str = Query("asc", pattern="^(asc|desc)$"),
|
||||
_user: User = Depends(require_auth),
|
||||
):
|
||||
"""返回全部 Profile 健康分排行列表。
|
||||
|
||||
- ?grade=A|B|C|D 按等级筛选
|
||||
- ?sort=asc|desc 排序,默认从低到高(问题优先)
|
||||
"""
|
||||
scores = calculate_all_scores()
|
||||
|
||||
# 筛选
|
||||
if grade:
|
||||
scores = [s for s in scores if s.grade == grade]
|
||||
|
||||
# 排序
|
||||
scores.sort(key=lambda s: s.total_score, reverse=(sort == "desc"))
|
||||
|
||||
rows = [_score_to_row(hs, i + 1) for i, hs in enumerate(scores)]
|
||||
return {
|
||||
"total": len(rows),
|
||||
"distribution": {
|
||||
"A": sum(1 for s in scores if s.grade == "A"),
|
||||
"B": sum(1 for s in scores if s.grade == "B"),
|
||||
"C": sum(1 for s in scores if s.grade == "C"),
|
||||
"D": sum(1 for s in scores if s.grade == "D"),
|
||||
},
|
||||
"scores": rows,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/scores/{profile}")
|
||||
async def get_profile_score(
|
||||
profile: str,
|
||||
_user: User = Depends(require_auth),
|
||||
):
|
||||
"""返回单个 Profile 的健康分详情,含四维度扣分项和修复建议。"""
|
||||
if profile not in PLACEHOLDER_PROFILES:
|
||||
raise HTTPException(status_code=404, detail=f"Profile '{profile}' 不存在")
|
||||
|
||||
hs = calculate_health_score(profile)
|
||||
return {
|
||||
"profile_name": hs.profile_name,
|
||||
"total_score": hs.total_score,
|
||||
"grade": hs.grade,
|
||||
"calculated_at": hs.calculated_at,
|
||||
"dimensions": [
|
||||
{
|
||||
"name": "Gateway 稳定性",
|
||||
"weight": "40%",
|
||||
"score": hs.gateway.score,
|
||||
"max_score": hs.gateway.max_score,
|
||||
"deductions": hs.gateway.deductions,
|
||||
"suggestions": hs.gateway.suggestions,
|
||||
},
|
||||
{
|
||||
"name": "配置完整性",
|
||||
"weight": "25%",
|
||||
"score": hs.config.score,
|
||||
"max_score": hs.config.max_score,
|
||||
"deductions": hs.config.deductions,
|
||||
"suggestions": hs.config.suggestions,
|
||||
},
|
||||
{
|
||||
"name": "Skill 一致性",
|
||||
"weight": "20%",
|
||||
"score": hs.skill.score,
|
||||
"max_score": hs.skill.max_score,
|
||||
"deductions": hs.skill.deductions,
|
||||
"suggestions": hs.skill.suggestions,
|
||||
},
|
||||
{
|
||||
"name": "Cron 成功率",
|
||||
"weight": "15%",
|
||||
"score": hs.cron.score,
|
||||
"max_score": hs.cron.max_score,
|
||||
"deductions": hs.cron.deductions,
|
||||
"suggestions": hs.cron.suggestions,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/compare")
|
||||
async def compare_profiles(
|
||||
body: dict,
|
||||
_user: User = Depends(require_auth),
|
||||
):
|
||||
"""返回多个 Profile 的配置/Skill 横向对比。
|
||||
|
||||
body: {"profile_names": ["hebo", "ops-engineer"]}
|
||||
"""
|
||||
names = body.get("profile_names", [])
|
||||
if len(names) < 2:
|
||||
raise HTTPException(status_code=400, detail="至少选择 2 个 Profile")
|
||||
if len(names) > 20:
|
||||
raise HTTPException(status_code=400, detail="最多对比 20 个 Profile")
|
||||
|
||||
invalid = [n for n in names if n not in PLACEHOLDER_PROFILES]
|
||||
if invalid:
|
||||
raise HTTPException(status_code=404, detail=f"Profile 不存在: {', '.join(invalid)}")
|
||||
|
||||
scores = {hs.profile_name: hs for hs in calculate_all_scores(names)}
|
||||
|
||||
# 收集所有配置字段和 Skill
|
||||
from app.services.health_scorer import _REQUIRED_CONFIG_FIELDS, _BASELINE_SKILLS
|
||||
from app.services.health_scorer import _placeholder_config_data, _placeholder_skill_data
|
||||
|
||||
config_fields = list(_REQUIRED_CONFIG_FIELDS)
|
||||
skill_fields = sorted(_BASELINE_SKILLS)
|
||||
|
||||
rows: list[dict] = []
|
||||
for field in config_fields:
|
||||
row: dict = {"field": field, "category": "config"}
|
||||
for name in names:
|
||||
s = scores.get(name)
|
||||
if s is None:
|
||||
row[name] = "⚠"
|
||||
else:
|
||||
data = _placeholder_config_data(name)
|
||||
row[name] = "✅" if field in data["present_fields"] else "❌"
|
||||
rows.append(row)
|
||||
|
||||
for field in skill_fields:
|
||||
row = {"field": field, "category": "skill"}
|
||||
for name in names:
|
||||
s = scores.get(name)
|
||||
if s is None:
|
||||
row[name] = "⚠"
|
||||
else:
|
||||
data = _placeholder_skill_data(name)
|
||||
row[name] = "✅" if field in data["profile_skills"] else "❌"
|
||||
rows.append(row)
|
||||
|
||||
# 检测差异行
|
||||
for row in rows:
|
||||
vals = {str(row[n]) for n in names}
|
||||
row["has_diff"] = len(vals) > 1
|
||||
|
||||
return {
|
||||
"profiles": names,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/calculate")
|
||||
async def recalculate_scores(
|
||||
_user: User = Depends(require_role("owner")),
|
||||
):
|
||||
"""手动触发全量评分重算(仅 owner)。"""
|
||||
invalidate_cache()
|
||||
scores = calculate_all_scores()
|
||||
return {
|
||||
"message": f"已重新计算 {len(scores)} 个 Profile 的健康分",
|
||||
"count": len(scores),
|
||||
}
|
||||
+2
-1
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import engine, Base
|
||||
from app.api import auth, users, audit, dashboard, gateway
|
||||
from app.api import auth, users, audit, dashboard, gateway, health
|
||||
from app.middleware.audit import AuditMiddleware
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ app.include_router(users.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(dashboard.router, prefix="/api/v1")
|
||||
app.include_router(gateway.router, prefix="/api/v1")
|
||||
app.include_router(health.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""健康分计算引擎 — 评分规则 + 缓存。
|
||||
|
||||
评分维度:
|
||||
- Gateway 稳定性 40%: 7天在线率 + 日均重启惩罚
|
||||
- 配置完整性 25%: .env + config.yaml 必填字段检查
|
||||
- Skill 一致性 20%: 与基准 Skill 清单对比
|
||||
- Cron 成功率 15%: 24h Cron 成功率
|
||||
|
||||
等级: A(80-100) B(60-79) C(40-59) D(0-39)
|
||||
|
||||
数据来源暂用占位(TODO: hermes CLI / 文件读取),评分逻辑完整实现。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 类型 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class DimensionScore:
|
||||
score: float
|
||||
max_score: float
|
||||
deductions: list[str] = field(default_factory=list)
|
||||
suggestions: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthScore:
|
||||
profile_name: str
|
||||
total_score: float
|
||||
grade: str
|
||||
gateway: DimensionScore
|
||||
config: DimensionScore
|
||||
skill: DimensionScore
|
||||
cron: DimensionScore
|
||||
deduct_summary: list[str] = field(default_factory=list)
|
||||
calculated_at: str = ""
|
||||
|
||||
|
||||
GRADE_THRESHOLDS = [
|
||||
(80, "A"),
|
||||
(60, "B"),
|
||||
(40, "C"),
|
||||
(0, "D"),
|
||||
]
|
||||
|
||||
|
||||
def _to_grade(score: float) -> str:
|
||||
for threshold, grade in GRADE_THRESHOLDS:
|
||||
if score >= threshold:
|
||||
return grade
|
||||
return "D"
|
||||
|
||||
|
||||
def _fmt_dt(dt: datetime | None = None) -> str:
|
||||
return (dt or datetime.now(timezone.utc)).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
# ── 占位数据源(TODO: 对接真实数据) ──────────────────────────────────────
|
||||
|
||||
|
||||
def _placeholder_gateway_data(profile: str) -> dict:
|
||||
"""返回 Profile 的 Gateway 占位状态数据。"""
|
||||
# 根据 profile 名 hash 出不同状态以产生多样结果
|
||||
seed = sum(ord(c) for c in profile)
|
||||
uptime = 0.85 + (seed % 15) / 100 # 0.85~0.99
|
||||
restarts = (seed % 7) / 10 # 0.0~0.6
|
||||
return {
|
||||
"uptime_7d": min(uptime, 0.999),
|
||||
"avg_daily_restarts": restarts,
|
||||
"online": seed % 5 != 0,
|
||||
}
|
||||
|
||||
|
||||
_REQUIRED_CONFIG_FIELDS = [
|
||||
"BOT_TOKEN",
|
||||
"BOT_SECRET",
|
||||
"APP_ID",
|
||||
"APP_SECRET",
|
||||
"PROFILE_NAME",
|
||||
"LOG_LEVEL",
|
||||
]
|
||||
|
||||
|
||||
def _placeholder_config_data(profile: str) -> dict:
|
||||
"""返回 Profile 的配置完整性占位数据。"""
|
||||
seed = sum(ord(c) for c in profile)
|
||||
# 每个 Profile 缺失 0~3 个字段
|
||||
missing_count = seed % 4
|
||||
rng = random.Random(profile)
|
||||
missing = rng.sample(_REQUIRED_CONFIG_FIELDS, missing_count) if missing_count else []
|
||||
return {
|
||||
"total_required": len(_REQUIRED_CONFIG_FIELDS),
|
||||
"missing_fields": missing,
|
||||
"present_fields": [f for f in _REQUIRED_CONFIG_FIELDS if f not in missing],
|
||||
}
|
||||
|
||||
|
||||
_BASELINE_SKILLS = {"gateway", "feishu", "cron", "kanban", "search", "keys"}
|
||||
|
||||
|
||||
def _placeholder_skill_data(profile: str) -> dict:
|
||||
"""返回 Skill 一致性占位数据。"""
|
||||
seed = sum(ord(c) for c in profile)
|
||||
rng = random.Random(profile)
|
||||
profile_skills = set(_BASELINE_SKILLS)
|
||||
# 可能缺失几个
|
||||
for s in rng.sample(list(_BASELINE_SKILLS), seed % 4):
|
||||
profile_skills.discard(s)
|
||||
# 可能多出几个
|
||||
extras = {"notion", "slack", "jira", "github", "gitlab"}
|
||||
for s in rng.sample(list(extras), seed % 3):
|
||||
profile_skills.add(s)
|
||||
|
||||
missing = _BASELINE_SKILLS - profile_skills
|
||||
surplus = profile_skills - _BASELINE_SKILLS
|
||||
return {
|
||||
"baseline": list(_BASELINE_SKILLS),
|
||||
"profile_skills": list(profile_skills),
|
||||
"missing": list(missing),
|
||||
"surplus": list(surplus),
|
||||
}
|
||||
|
||||
|
||||
def _placeholder_cron_data(profile: str) -> dict:
|
||||
"""返回 Cron 执行状态占位数据。"""
|
||||
seed = sum(ord(c) for c in profile)
|
||||
success_rate = 0.7 + (seed % 6) / 20 # 0.7~0.95
|
||||
return {
|
||||
"total_executions_24h": 10 + seed % 10,
|
||||
"success_count": int((10 + seed % 10) * success_rate),
|
||||
"success_rate": success_rate,
|
||||
"recent_failures": [f"cron-job-{i}" for i in range(seed % 4)],
|
||||
}
|
||||
|
||||
|
||||
# ── 评分逻辑 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _score_gateway(profile: str) -> DimensionScore:
|
||||
data = _placeholder_gateway_data(profile)
|
||||
deductions: list[str] = []
|
||||
suggestions: list[str] = []
|
||||
|
||||
uptime = data["uptime_7d"]
|
||||
restarts = data["avg_daily_restarts"]
|
||||
|
||||
# 基础分: 7天在线率 × 40
|
||||
score = uptime * 40
|
||||
|
||||
if uptime < 0.99:
|
||||
deductions.append(f"7天在线率 {uptime:.1%}(目标 ≥99%)")
|
||||
suggestions.append("检查 Gateway 进程稳定性,考虑设置自动重启")
|
||||
|
||||
# 日均重启惩罚: >0.3 次/天 开始扣分
|
||||
if restarts > 0.3:
|
||||
penalty = min(score, (restarts - 0.3) * 15)
|
||||
score -= penalty
|
||||
deductions.append(f"日均重启 {restarts:.1f} 次(阈值 ≤0.3),扣 {penalty:.1f} 分")
|
||||
suggestions.append("排查频繁重启原因,检查内存泄漏或日志错误")
|
||||
|
||||
if not data["online"]:
|
||||
score *= 0.5
|
||||
deductions.append("Gateway 当前离线,得分减半")
|
||||
suggestions.append("立即启动 Gateway 进程")
|
||||
|
||||
return DimensionScore(
|
||||
score=max(0, round(score, 1)),
|
||||
max_score=40,
|
||||
deductions=deductions,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
|
||||
def _score_config(profile: str) -> DimensionScore:
|
||||
data = _placeholder_config_data(profile)
|
||||
deductions: list[str] = []
|
||||
suggestions: list[str] = []
|
||||
|
||||
missing = data["missing_fields"]
|
||||
total = data["total_required"]
|
||||
|
||||
score = 25.0 # 满分 25
|
||||
if missing:
|
||||
deduction_pct = len(missing) * 0.1 # 缺 1 个扣 10%
|
||||
score *= max(0, 1 - deduction_pct)
|
||||
deductions.append(f"缺 {len(missing)} 个必填字段: {', '.join(missing)}")
|
||||
suggestions.append(f"在 .env 或 config.yaml 中补充: {', '.join(missing)}")
|
||||
|
||||
return DimensionScore(
|
||||
score=max(0, round(score, 1)),
|
||||
max_score=25,
|
||||
deductions=deductions,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
|
||||
def _score_skill(profile: str) -> DimensionScore:
|
||||
data = _placeholder_skill_data(profile)
|
||||
deductions: list[str] = []
|
||||
suggestions: list[str] = []
|
||||
|
||||
missing = data["missing"]
|
||||
surplus = data["surplus"]
|
||||
|
||||
# 基准技能匹配率 → 满分 20
|
||||
baseline_hit = len(data["baseline"]) - len(missing)
|
||||
if data["baseline"]:
|
||||
match_ratio = baseline_hit / len(data["baseline"])
|
||||
else:
|
||||
match_ratio = 1.0
|
||||
score = match_ratio * 20
|
||||
|
||||
if missing:
|
||||
deductions.append(f"缺失基准 Skill: {', '.join(missing)}")
|
||||
suggestions.append(f"安装缺失 Skill: {', '.join(missing)}")
|
||||
if surplus:
|
||||
deductions.append(f"多余未识别 Skill: {', '.join(surplus)}")
|
||||
suggestions.append(f"检查多余 Skill 是否必要: {', '.join(surplus)}")
|
||||
|
||||
return DimensionScore(
|
||||
score=max(0, round(score, 1)),
|
||||
max_score=20,
|
||||
deductions=deductions,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
|
||||
def _score_cron(profile: str) -> DimensionScore:
|
||||
data = _placeholder_cron_data(profile)
|
||||
deductions: list[str] = []
|
||||
suggestions: list[str] = []
|
||||
|
||||
success_rate = data["success_rate"]
|
||||
score = success_rate * 15 # 成功率 × 15
|
||||
|
||||
if success_rate < 0.95:
|
||||
deductions.append(f"24h Cron 成功率 {success_rate:.1%}(目标 ≥95%)")
|
||||
suggestions.append("检查失败 Cron 任务的日志,确认是否因超时或配置错误")
|
||||
|
||||
if data["recent_failures"]:
|
||||
deductions.append(f"失败任务: {', '.join(data['recent_failures'])}")
|
||||
suggestions.append("逐个排查失败任务,优先修复高频失败项")
|
||||
|
||||
return DimensionScore(
|
||||
score=max(0, round(score, 1)),
|
||||
max_score=15,
|
||||
deductions=deductions,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
|
||||
# ── 对外接口 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
SCORE_CACHE: dict[str, HealthScore] = {}
|
||||
_LAST_CALCULATED: datetime | None = None
|
||||
_CACHE_TTL = timedelta(hours=24)
|
||||
|
||||
# 占位 Profile 列表(TODO: 从 hermes 配置目录读取)
|
||||
PLACEHOLDER_PROFILES = [
|
||||
"hebo", "ops-engineer", "monitor", "visualizer",
|
||||
"gateway-monitor", "feishu-gw", "taiwan-briefing",
|
||||
"daily-report", "weekly-digest", "hindsight",
|
||||
"guangxi-donation", "europe-briefing",
|
||||
]
|
||||
|
||||
|
||||
def calculate_health_score(profile_name: str) -> HealthScore:
|
||||
"""计算单个 Profile 的健康分。"""
|
||||
gw = _score_gateway(profile_name)
|
||||
cf = _score_config(profile_name)
|
||||
sk = _score_skill(profile_name)
|
||||
cr = _score_cron(profile_name)
|
||||
|
||||
total = round(gw.score + cf.score + sk.score + cr.score, 1)
|
||||
grade = _to_grade(total)
|
||||
|
||||
deduct_summary = []
|
||||
for dim, label in [(gw, "Gateway"), (cf, "配置"), (sk, "Skill"), (cr, "Cron")]:
|
||||
for d in dim.deductions:
|
||||
deduct_summary.append(f"[{label}] {d}")
|
||||
|
||||
return HealthScore(
|
||||
profile_name=profile_name,
|
||||
total_score=total,
|
||||
grade=grade,
|
||||
gateway=gw,
|
||||
config=cf,
|
||||
skill=sk,
|
||||
cron=cr,
|
||||
deduct_summary=deduct_summary[:5], # 最多显示 5 条
|
||||
calculated_at=_fmt_dt(),
|
||||
)
|
||||
|
||||
|
||||
def _is_cache_stale() -> bool:
|
||||
if _LAST_CALCULATED is None:
|
||||
return True
|
||||
return datetime.now(timezone.utc) - _LAST_CALCULATED > _CACHE_TTL
|
||||
|
||||
|
||||
def calculate_all_scores(profiles: list[str] | None = None) -> list[HealthScore]:
|
||||
"""计算全部 Profile 的健康分,使用缓存(TTL 24h)。"""
|
||||
global _LAST_CALCULATED
|
||||
|
||||
if not _is_cache_stale() and SCORE_CACHE:
|
||||
logger.info("健康分缓存命中(%d 条)", len(SCORE_CACHE))
|
||||
scores = list(SCORE_CACHE.values())
|
||||
if profiles:
|
||||
return [s for s in scores if s.profile_name in profiles]
|
||||
return scores
|
||||
|
||||
target_profiles = profiles or PLACEHOLDER_PROFILES
|
||||
scores: list[HealthScore] = []
|
||||
for name in target_profiles:
|
||||
try:
|
||||
hs = calculate_health_score(name)
|
||||
SCORE_CACHE[name] = hs
|
||||
scores.append(hs)
|
||||
except Exception as e:
|
||||
logger.error("计算 %s 健康分异常: %s", name, e)
|
||||
|
||||
_LAST_CALCULATED = datetime.now(timezone.utc)
|
||||
logger.info("健康分已刷新(%d 条)", len(scores))
|
||||
return scores
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
"""强制清除缓存,下次调用重新计算。"""
|
||||
global _LAST_CALCULATED
|
||||
SCORE_CACHE.clear()
|
||||
_LAST_CALCULATED = None
|
||||
logger.info("健康分缓存已清除")
|
||||
@@ -0,0 +1,69 @@
|
||||
import client from './client'
|
||||
|
||||
/* ── types ── */
|
||||
|
||||
export interface ScoreRow {
|
||||
rank: number
|
||||
name: string
|
||||
total_score: number
|
||||
grade: string
|
||||
gateway_score: number
|
||||
config_score: number
|
||||
skill_score: number
|
||||
cron_score: number
|
||||
deduct_summary: string[]
|
||||
}
|
||||
|
||||
export interface ScoreListResponse {
|
||||
total: number
|
||||
distribution: { A: number; B: number; C: number; D: number }
|
||||
scores: ScoreRow[]
|
||||
}
|
||||
|
||||
export interface DimensionDetail {
|
||||
name: string
|
||||
weight: string
|
||||
score: number
|
||||
max_score: number
|
||||
deductions: string[]
|
||||
suggestions: string[]
|
||||
}
|
||||
|
||||
export interface ProfileScoreDetail {
|
||||
profile_name: string
|
||||
total_score: number
|
||||
grade: string
|
||||
calculated_at: string
|
||||
dimensions: DimensionDetail[]
|
||||
}
|
||||
|
||||
export interface CompareRow {
|
||||
field: string
|
||||
category: string
|
||||
has_diff: boolean
|
||||
[profileName: string]: unknown
|
||||
}
|
||||
|
||||
export interface CompareResponse {
|
||||
profiles: string[]
|
||||
rows: CompareRow[]
|
||||
}
|
||||
|
||||
export interface CalculateResponse {
|
||||
message: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/* ── API functions ── */
|
||||
|
||||
export const getHealthScores = (params?: { grade?: string; sort?: string }) =>
|
||||
client.get<ScoreListResponse>('/health/scores', { params })
|
||||
|
||||
export const getProfileScore = (profile: string) =>
|
||||
client.get<ProfileScoreDetail>(`/health/scores/${encodeURIComponent(profile)}`)
|
||||
|
||||
export const compareProfiles = (profile_names: string[]) =>
|
||||
client.post<CompareResponse>('/health/compare', { profile_names })
|
||||
|
||||
export const recalculateScores = () =>
|
||||
client.post<CalculateResponse>('/health/calculate')
|
||||
@@ -1,3 +1,295 @@
|
||||
export default function HealthPage() {
|
||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>系统健康评分</p></div>
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import Badge from '../components/ui/Badge'
|
||||
import Button from '../components/ui/Button'
|
||||
import Table, { type TableColumn } from '../components/ui/Table'
|
||||
import Skeleton from '../components/ui/Skeleton'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import toast from '../components/ui/Toast'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
import {
|
||||
getHealthScores, getProfileScore, compareProfiles, recalculateScores,
|
||||
type ScoreListResponse, type ProfileScoreDetail, type CompareResponse,
|
||||
} from '../api/health'
|
||||
|
||||
const GRADE_META: Record<string, { color: string; bg: string; label: string }> = {
|
||||
A: { color: 'var(--green)', bg: 'var(--bg-success)', label: '优秀' },
|
||||
B: { color: 'var(--blue)', bg: 'var(--bg-info)', label: '良好' },
|
||||
C: { color: 'var(--yellow)', bg: 'var(--bg-warn)', label: '一般' },
|
||||
D: { color: 'var(--red)', bg: 'var(--bg-error)', label: '危险' },
|
||||
}
|
||||
|
||||
function GradeBadge({ grade }: { grade: string }) {
|
||||
const m = GRADE_META[grade]
|
||||
if (!m) return <Badge variant="outline">{grade}</Badge>
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-[8px] py-[2px] rounded-[10px] text-[11px] font-semibold"
|
||||
style={{ backgroundColor: m.bg, color: m.color }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: m.color, display: 'inline-block' }} />
|
||||
{grade}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function GradeCard({ grade, count, active, onClick }: {
|
||||
grade: string; count: number; active: boolean; onClick: () => void
|
||||
}) {
|
||||
const m = GRADE_META[grade]
|
||||
const as = active ? { boxShadow: '0 0 0 2px ' + m.color } : {}
|
||||
return (
|
||||
<div onClick={onClick} className="rounded-[var(--radius)] p-4 cursor-pointer transition-all duration-150"
|
||||
style={{ background: m.bg, ...as }}>
|
||||
<div className="text-[12px] uppercase tracking-wider" style={{ color: m.color }}>{grade}</div>
|
||||
<div className="text-[28px] font-bold leading-tight mt-1" style={{ color: m.color }}>{count}</div>
|
||||
<div className="text-[12px] mt-1" style={{ color: m.color, opacity: 0.7 }}>{m.label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanel({ profile }: { profile: string }) {
|
||||
const [detail, setDetail] = useState<ProfileScoreDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
getProfileScore(profile).then(r => setDetail(r.data))
|
||||
.catch(() => toast.error('加载详情失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [profile])
|
||||
if (loading) return <div className="p-4"><Skeleton variant="text" count={4} /></div>
|
||||
if (!detail) return <div className="p-4 text-[var(--text-dim)]">暂无数据</div>
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{detail.dimensions.map((dim, i) => {
|
||||
const ok = dim.score / dim.max_score >= 0.7
|
||||
return (
|
||||
<div key={i} className="rounded-[var(--radius-sm)] p-3" style={{ background: "var(--bg-hover)" }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[13px] font-semibold" style={{ color: "var(--text)" }}>{dim.name}</span>
|
||||
<span className="text-[12px]" style={{ color: ok ? "var(--green)" : "var(--red)" }}>
|
||||
{dim.score}/{dim.max_score} ({dim.weight})
|
||||
</span>
|
||||
</div>
|
||||
{dim.deductions.length > 0 && (
|
||||
<div className="space-y-1 mb-2">
|
||||
<div className="text-[11px] font-semibold" style={{ color: "var(--red)" }}>扣分项:</div>
|
||||
{dim.deductions.map((d, j) => <div key={j} className="text-[12px] pl-2" style={{ color: "var(--text-dim)" }}>\u2022 {d}</div>)}
|
||||
</div>
|
||||
)}
|
||||
{dim.suggestions.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] font-semibold" style={{ color: "var(--honey)" }}>修复建议:</div>
|
||||
{dim.suggestions.map((s, j) => <div key={j} className="text-[12px] pl-2" style={{ color: "var(--text-dim)" }}>\u2192 {s}</div>)}
|
||||
</div>
|
||||
)}
|
||||
{dim.deductions.length === 0 && <div className="text-[12px]" style={{ color: "var(--green)" }}>\u2713 满分</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompareTable({ data, showDiffOnly, profiles: p }: {
|
||||
data: CompareResponse; showDiffOnly: boolean; profiles: string[]
|
||||
}) {
|
||||
const rows = showDiffOnly ? data.rows.filter(r => r.has_diff) : data.rows
|
||||
if (rows.length === 0) {
|
||||
return <div className="p-4 text-center text-[var(--text-dim)]">没有差异</div>
|
||||
}
|
||||
return (
|
||||
<div className="w-full overflow-x-auto rounded-[var(--radius)] border border-[var(--border)]">
|
||||
<table className="w-full border-collapse text-[12px]">
|
||||
<thead><tr>
|
||||
<th className="sticky top-0 z-10 bg-[var(--bg-nav)] text-left px-3 h-[36px] uppercase text-[var(--text-dim)] font-semibold">字段</th>
|
||||
{p.map(n =>
|
||||
<th key={n} className="sticky top-0 z-10 bg-[var(--bg-nav)] text-center px-3 h-[36px] text-[var(--text-dim)] font-semibold">{n}</th>
|
||||
)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const vals = p.map(n => String(row[n] || ''))
|
||||
const hasDiff = new Set(vals).size > 1
|
||||
return (
|
||||
<tr key={i} className="border-t border-[var(--border)]" style={hasDiff ? { background: "var(--bg-warn)" } : {}}>
|
||||
<td className="px-3 font-medium h-[36px]" style={{ color: "var(--text)" }}>{row.field}</td>
|
||||
{p.map(n => {
|
||||
const v = String(row[n] || '')
|
||||
const c = v === '\u2705' ? 'var(--green)' : v === '\u274c' ? 'var(--red)' : 'var(--honey)'
|
||||
return <td key={n} className="px-3 text-center" style={{ color: c }}>{v}</td>
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HealthPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const { user } = useAuth()
|
||||
const [data, setData] = useState<ScoreListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const gradeFilter = searchParams.get('filter') || ''
|
||||
const [sortAsc, setSortAsc] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [compareData, setCompareData] = useState<CompareResponse | null>(null)
|
||||
const [compareLoading, setCompareLoading] = useState(false)
|
||||
const [showCompare, setShowCompare] = useState(false)
|
||||
const [showDiffOnly, setShowDiffOnly] = useState(false)
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null)
|
||||
const [recalcLoading, setRecalcLoading] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const params: Record<string, string> = { sort: sortAsc ? 'asc' : 'desc' }
|
||||
if (gradeFilter) params.grade = gradeFilter
|
||||
const r = await getHealthScores(params)
|
||||
setData(r.data)
|
||||
} catch { setError('加载健康分数据失败') }
|
||||
finally { setLoading(false) }
|
||||
}, [gradeFilter, sortAsc])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const toggleSelect = (name: string) => {
|
||||
setSelected(prev => { const n = new Set(prev); if (n.has(name)) n.delete(name); else n.add(name); return n })
|
||||
}
|
||||
|
||||
const handleCompare = async () => {
|
||||
if (selected.size < 2) { toast.error('至少选择 2 个 Profile'); return }
|
||||
setCompareLoading(true)
|
||||
try { const r = await compareProfiles(Array.from(selected)); setCompareData(r.data); setShowCompare(true) }
|
||||
catch { toast.error('对比失败') } finally { setCompareLoading(false) }
|
||||
}
|
||||
|
||||
const handleRecalculate = async () => {
|
||||
setRecalcLoading(true)
|
||||
try { await recalculateScores(); toast.success('已重新计算'); fetchData() }
|
||||
catch { toast.error('重算失败') } finally { setRecalcLoading(false) }
|
||||
}
|
||||
|
||||
const handleGradeFilter = (g: string) => {
|
||||
const p = new URLSearchParams(searchParams)
|
||||
if (g && g !== gradeFilter) p.set('filter', g); else p.delete('filter')
|
||||
setSearchParams(p)
|
||||
}
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{ key: '_select', label: '', width: 40 },
|
||||
{ key: 'rank', label: '排名', width: 50 },
|
||||
{ key: 'name', label: 'Profile' },
|
||||
{ key: 'total', label: '总分', width: 70 },
|
||||
{ key: 'grade', label: '等级', width: 60 },
|
||||
{ key: 'dims', label: '四维度得分', width: 200 },
|
||||
{ key: 'deduct', label: '主要扣分' },
|
||||
]
|
||||
|
||||
const dist = data?.distribution ?? { A: 0, B: 0, C: 0, D: 0 }
|
||||
const scores = data?.scores ?? []
|
||||
|
||||
/* loading */
|
||||
if (loading) {
|
||||
return (
|
||||
<div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
||||
{['A','B','C','D'].map(g => <div key={g} className="rounded-[var(--radius)] p-4" style={{ background: 'var(--bg-card)' }}><Skeleton variant="text" count={2} /></div>)}
|
||||
</div>
|
||||
<Skeleton variant="table" count={8} className="mt-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* error */
|
||||
if (error) {
|
||||
return (
|
||||
<div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
|
||||
<div className="mt-4 p-5 rounded-[var(--radius)] text-center" style={{ background: "var(--bg-card)" }}>
|
||||
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
||||
<Button onClick={fetchData}>重试</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* table rows */
|
||||
const tableData = scores.map(s => {
|
||||
const tc = s.total_score >= 80 ? 'var(--green)' : s.total_score >= 60 ? 'var(--blue)' : s.total_score >= 40 ? 'var(--yellow)' : 'var(--red)'
|
||||
return {
|
||||
key: s.name,
|
||||
_select: <input type="checkbox" checked={selected.has(s.name)} onChange={() => toggleSelect(s.name)} className="cursor-pointer" style={{ accentColor: 'var(--honey)' }} />,
|
||||
rank: <span style={{ color: 'var(--text-dim)' }}>{s.rank}</span>,
|
||||
name: <span style={{ color: 'var(--text)', fontWeight: 500 }}>{s.name}</span>,
|
||||
total: <span style={{ color: tc, fontWeight: 700, fontSize: 15 }}>{s.total_score}</span>,
|
||||
grade: <GradeBadge grade={s.grade} />,
|
||||
dims: (
|
||||
<div className="flex gap-3 text-[12px]">
|
||||
<span style={{ color: 'var(--text-dim)' }}>G:<span style={{ color: 'var(--green)' }}>{s.gateway_score}</span></span>
|
||||
<span style={{ color: 'var(--text-dim)' }}>C:<span style={{ color: 'var(--blue)' }}>{s.config_score}</span></span>
|
||||
<span style={{ color: 'var(--text-dim)' }}>S:<span style={{ color: 'var(--honey)' }}>{s.skill_score}</span></span>
|
||||
<span style={{ color: 'var(--text-dim)' }}>R:<span style={{ color: 'var(--yellow)' }}>{s.cron_score}</span></span>
|
||||
</div>
|
||||
),
|
||||
deduct: <div className="text-[12px] truncate max-w-[200px]" style={{ color: 'var(--text-dim)' }}>{s.deduct_summary[0] || '-'}</div>,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
|
||||
|
||||
{/* grade cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
||||
{Object.entries(GRADE_META).map(([g]) =>
|
||||
<GradeCard key={g} grade={g} count={dist[g as keyof typeof dist]} active={gradeFilter === g} onClick={() => handleGradeFilter(g)} />)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* filter bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-4 mb-3">
|
||||
<div className="flex gap-1">
|
||||
{['', 'A', 'B', 'C', 'D'].map(g =>
|
||||
<button key={g} onClick={() => handleGradeFilter(g)}
|
||||
className="px-3 py-1.5 text-[12px] rounded-[var(--radius-sm)] font-semibold cursor-pointer border-none transition-colors"
|
||||
style={{ background: gradeFilter === g ? 'var(--honey)' : 'var(--bg-hover)', color: gradeFilter === g ? '#000' : 'var(--text-dim)' }}>
|
||||
{g || '全部'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={selected.size < 2} loading={compareLoading} onClick={handleCompare}>对比选中 ({selected.size})</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setSortAsc(!sortAsc) }}>{sortAsc ? '\u2191 低到高' : '\u2193 高到低'}</Button>
|
||||
{user?.role === 'owner' && <Button size="sm" variant="outline" loading={recalcLoading} onClick={handleRecalculate}>重新计算</Button>}
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>共 {scores.length} 个 Profile</span>
|
||||
</div>
|
||||
|
||||
{/* table */}
|
||||
<Table columns={columns} data={tableData as any} rowKey="key"
|
||||
onRowClick={(record) => setExpandedRow(expandedRow === record.key ? null : String(record.key))}
|
||||
rowClassName={(record) => String(record.grade?.props?.grade || '') === 'D' ? 'row-danger' : ''} />
|
||||
|
||||
{/* expanded detail */}
|
||||
{expandedRow && (
|
||||
<div className="mt-2 mb-4 rounded-[var(--radius)] border border-[var(--border)] overflow-hidden"
|
||||
style={{ background: "var(--bg-card)" }}>
|
||||
<DetailPanel profile={expandedRow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* compare modal */}
|
||||
<Modal open={showCompare} onClose={() => setShowCompare(false)} title="Profile \u5bf9\u6bd4"
|
||||
footer={
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer" style={{ color: "var(--text-dim)" }}>
|
||||
<input type="checkbox" checked={showDiffOnly} onChange={() => setShowDiffOnly(!showDiffOnly)} style={{ accentColor: 'var(--honey)' }} />
|
||||
只看差异
|
||||
</label>
|
||||
}>
|
||||
{compareData && <CompareTable data={compareData} showDiffOnly={showDiffOnly} profiles={Array.from(selected)} />}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user